shinestacker 1.9.1__py3-none-any.whl → 1.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

@@ -1,7 +1,7 @@
1
1
  # pylint: disable=C0114, C0116, W0718, R0911, R0912, E1101, R0915, R1702, R0914, R0917, R0913
2
2
  import os
3
+ import re
3
4
  import logging
4
- import traceback
5
5
  import cv2
6
6
  import numpy as np
7
7
  from PIL import Image
@@ -34,21 +34,37 @@ NO_COPY_TIFF_TAGS_ID = [IMAGEWIDTH, IMAGELENGTH, RESOLUTIONX, RESOLUTIONY, BITSP
34
34
  NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCounts"]
35
35
 
36
36
 
37
+ XMP_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
38
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
39
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
40
+ <rdf:Description rdf:about='' xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:xmp='http://ns.adobe.com/xap/1.0/' xmlns:tiff='http://ns.adobe.com/tiff/1.0/' xmlns:exif='http://ns.adobe.com/exif/1.0/' xmlns:aux='http://ns.adobe.com/exif/1.0/aux/'>
41
+ {content}
42
+ </rdf:Description>
43
+ </rdf:RDF>
44
+ </x:xmpmeta>
45
+ <?xpacket end='w'?>""" # noqa
46
+
47
+ XMP_EMPTY_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
48
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
49
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
50
+ <rdf:Description rdf:about=''/>
51
+ </rdf:RDF>
52
+ </x:xmpmeta>
53
+ <?xpacket end='w'?>""" # noqa
54
+
55
+
37
56
  def extract_enclosed_data_for_jpg(data, head, foot):
38
- try:
39
- xmp_start = data.find(head)
40
- if xmp_start == -1:
41
- return None
42
- xmp_end = data.find(foot, xmp_start)
43
- if xmp_end == -1:
44
- return None
45
- xmp_end += len(foot)
46
- return data[xmp_start:xmp_end]
47
- except Exception:
57
+ xmp_start = data.find(head)
58
+ if xmp_start == -1:
48
59
  return None
60
+ xmp_end = data.find(foot, xmp_start)
61
+ if xmp_end == -1:
62
+ return None
63
+ xmp_end += len(foot)
64
+ return data[xmp_start:xmp_end]
49
65
 
50
66
 
51
- def get_exif(exif_filename):
67
+ def get_exif(exif_filename, enhanced_png_parsing=True):
52
68
  if not os.path.isfile(exif_filename):
53
69
  raise RuntimeError(f"File does not exist: {exif_filename}")
54
70
  image = Image.open(exif_filename)
@@ -56,12 +72,20 @@ def get_exif(exif_filename):
56
72
  return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
57
73
  if extension_jpg(exif_filename):
58
74
  exif_data = image.getexif()
75
+ try:
76
+ exif_subifd = image.getexif().get_ifd(34665)
77
+ exif_data.update(exif_subifd)
78
+ except Exception:
79
+ pass # EXIF SubIFD is optional
59
80
  with open(exif_filename, 'rb') as f:
60
- data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
81
+ data = extract_enclosed_data_for_jpg(
82
+ f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
61
83
  if data is not None:
62
84
  exif_data[XMLPACKET] = data
63
85
  return exif_data
64
86
  if extension_png(exif_filename):
87
+ if enhanced_png_parsing:
88
+ return get_enhanced_exif_from_png(image)
65
89
  exif_data = get_exif_from_png(image)
66
90
  return exif_data if exif_data else image.getexif()
67
91
  return image.getexif()
@@ -69,25 +93,137 @@ def get_exif(exif_filename):
69
93
 
70
94
  def get_exif_from_png(image):
71
95
  exif_data = {}
72
- try:
73
- exif_from_image = image.getexif()
74
- if exif_from_image:
75
- exif_data.update(dict(exif_from_image))
76
- except Exception:
77
- pass
78
- try:
79
- if hasattr(image, 'text') and image.text:
80
- for key, value in image.text.items():
96
+ exif_from_image = image.getexif()
97
+ if exif_from_image:
98
+ exif_data.update(dict(exif_from_image))
99
+ for attr_name in ['text', 'info']:
100
+ if hasattr(image, attr_name) and getattr(image, attr_name):
101
+ for key, value in getattr(image, attr_name).items():
102
+ if attr_name == 'info' and key in ['dpi', 'gamma']:
103
+ continue
81
104
  exif_data[f"PNG_{key}"] = value
82
- if hasattr(image, 'info') and image.info:
83
- for key, value in image.info.items():
84
- if key not in ['dpi', 'gamma']:
85
- exif_data[f"PNG_{key}"] = value
86
- except Exception:
87
- pass
88
105
  return exif_data
89
106
 
90
107
 
108
+ def parse_xmp_to_exif(xmp_data):
109
+ exif_data = {}
110
+ if not xmp_data:
111
+ return exif_data
112
+ if isinstance(xmp_data, bytes):
113
+ xmp_data = xmp_data.decode('utf-8', errors='ignore')
114
+ xmp_to_exif_map = {
115
+ 'tiff:Make': 271, 'tiff:Model': 272, 'exif:ExposureTime': 33434,
116
+ 'exif:FNumber': 33437, 'exif:ISOSpeedRatings': 34855, 'exif:FocalLength': 37386,
117
+ 'exif:DateTimeOriginal': 36867, 'xmp:CreateDate': 306, 'xmp:CreatorTool': 305,
118
+ 'aux:Lens': 42036, 'exif:Flash': 37385, 'exif:WhiteBalance': 41987,
119
+ 'dc:description': 270, 'dc:creator': 315, 'dc:rights': 33432,
120
+ 'exif:ShutterSpeedValue': 37377, 'exif:ApertureValue': 37378,
121
+ 'exif:ExposureBiasValue': 37380, 'exif:MaxApertureValue': 37381,
122
+ 'exif:MeteringMode': 37383, 'exif:ExposureMode': 41986,
123
+ 'exif:SceneCaptureType': 41990
124
+ }
125
+ for xmp_tag, exif_tag in xmp_to_exif_map.items():
126
+ start_tag = f'<{xmp_tag}>'
127
+ end_tag = f'</{xmp_tag}>'
128
+ if start_tag in xmp_data:
129
+ start = xmp_data.find(start_tag) + len(start_tag)
130
+ end = xmp_data.find(end_tag, start)
131
+ if end != -1:
132
+ value = xmp_data[start:end].strip()
133
+ if value:
134
+ exif_data[exif_tag] = _parse_xmp_value(exif_tag, value)
135
+ return exif_data
136
+
137
+
138
+ def _parse_xmp_value(exif_tag, value):
139
+ if exif_tag in [33434, 33437, 37386]: # Rational values
140
+ if '/' in value:
141
+ num, den = value.split('/')
142
+ try:
143
+ return IFDRational(int(num), int(den))
144
+ except (ValueError, ZeroDivisionError):
145
+ return float(value) if value else 0.0
146
+ return float(value) if value else 0.0
147
+ if exif_tag == 34855: # ISO
148
+ if '<rdf:li>' in value:
149
+ matches = re.findall(r'<rdf:li>([^<]+)</rdf:li>', value)
150
+ if matches:
151
+ value = matches[0]
152
+ try:
153
+ return int(value)
154
+ except ValueError:
155
+ return value
156
+ if exif_tag in [306, 36867]: # DateTime and DateTimeOriginal
157
+ if 'T' in value:
158
+ value = value.replace('T', ' ').replace('-', ':')
159
+ return value
160
+ return value
161
+
162
+
163
+ def parse_typed_png_text(value):
164
+ if isinstance(value, str):
165
+ if value.startswith('RATIONAL:'):
166
+ parts = value[9:].split('/')
167
+ if len(parts) == 2:
168
+ try:
169
+ return IFDRational(int(parts[0]), int(parts[1]))
170
+ except (ValueError, ZeroDivisionError):
171
+ return value
172
+ elif value.startswith('INT:'):
173
+ try:
174
+ return int(value[4:])
175
+ except ValueError:
176
+ return value[4:]
177
+ elif value.startswith('FLOAT:'):
178
+ try:
179
+ return float(value[6:])
180
+ except ValueError:
181
+ return value[6:]
182
+ elif value.startswith('STRING:'):
183
+ return value[7:]
184
+ elif value.startswith('BYTES:'):
185
+ return value[6:].encode('utf-8')
186
+ elif value.startswith('ARRAY:'):
187
+ return [x.strip() for x in value[6:].split(',')]
188
+ return value
189
+
190
+
191
+ def get_enhanced_exif_from_png(image):
192
+ basic_exif = get_exif_from_png(image)
193
+ enhanced_exif = {}
194
+ enhanced_exif.update(basic_exif)
195
+ xmp_data = None
196
+ if hasattr(image, 'text') and image.text:
197
+ xmp_data = image.text.get('XML:com.adobe.xmp') or image.text.get('xml:com.adobe.xmp')
198
+ if not xmp_data and 700 in basic_exif:
199
+ xmp_data = basic_exif[700]
200
+ if xmp_data:
201
+ enhanced_exif.update(parse_xmp_to_exif(xmp_data))
202
+ if hasattr(image, 'text') and image.text:
203
+ for key, value in image.text.items():
204
+ if key.startswith('EXIF_'):
205
+ parsed_value = parse_typed_png_text(value)
206
+ tag_id = _get_tag_id_from_png_key(key)
207
+ if tag_id:
208
+ enhanced_exif[tag_id] = parsed_value
209
+ return {k: v for k, v in enhanced_exif.items() if isinstance(k, int)}
210
+
211
+
212
+ def _get_tag_id_from_png_key(key):
213
+ tag_map = {
214
+ 'EXIF_CameraMake': 271, 'EXIF_CameraModel': 272, 'EXIF_Software': 305,
215
+ 'EXIF_DateTime': 306, 'EXIF_Artist': 315, 'EXIF_Copyright': 33432,
216
+ 'EXIF_ExposureTime': 33434, 'EXIF_FNumber': 33437, 'EXIF_ISOSpeed': 34855,
217
+ 'EXIF_ShutterSpeedValue': 37377, 'EXIF_ApertureValue': 37378,
218
+ 'EXIF_FocalLength': 37386, 'EXIF_LensModel': 42036,
219
+ 'EXIF_ExposureBiasValue': 37380, 'EXIF_MaxApertureValue': 37381,
220
+ 'EXIF_MeteringMode': 37383, 'EXIF_Flash': 37385, 'EXIF_WhiteBalance': 41987,
221
+ 'EXIF_ExposureMode': 41986, 'EXIF_SceneCaptureType': 41990,
222
+ 'EXIF_DateTimeOriginal': 36867
223
+ }
224
+ return tag_map.get(key)
225
+
226
+
91
227
  def safe_decode_bytes(data, encoding='utf-8'):
92
228
  if not isinstance(data, bytes):
93
229
  return data
@@ -97,50 +233,7 @@ def safe_decode_bytes(data, encoding='utf-8'):
97
233
  return data.decode(enc, errors='strict')
98
234
  except UnicodeDecodeError:
99
235
  continue
100
- try:
101
- return data.decode('utf-8', errors='replace')
102
- except Exception:
103
- return "<<< decode error >>>"
104
-
105
-
106
- def exif_extra_tags_for_tif(exif):
107
- logger = logging.getLogger(__name__)
108
- res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
109
- if not (res_x is None or res_y is None):
110
- resolution = ((res_x.numerator, res_x.denominator), (res_y.numerator, res_y.denominator))
111
- else:
112
- resolution = ((720000, 10000), (720000, 10000))
113
- res_u = exif.get(RESOLUTIONUNIT)
114
- resolutionunit = res_u if res_u is not None else 'inch'
115
- sw = exif.get(SOFTWARE)
116
- software = sw if sw is not None else "N/A"
117
- phint = exif.get(PHOTOMETRICINTERPRETATION)
118
- photometric = phint if phint is not None else None
119
- extra = []
120
- for tag_id in exif:
121
- tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
122
- if isinstance(data, bytes):
123
- try:
124
- if tag_id not in (IMAGERESOURCES, INTERCOLORPROFILE):
125
- if tag_id == XMLPACKET:
126
- try:
127
- decoded = data.decode('utf-8')
128
- data = decoded.encode('utf-8')
129
- except UnicodeDecodeError:
130
- logger.debug("XMLPACKET contains non-UTF8 data, preserving as bytes")
131
- else:
132
- data = safe_decode_bytes(data)
133
- except Exception:
134
- logger.warning(msg=f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
135
- data = '<<< decode error >>>'
136
- if isinstance(data, IFDRational):
137
- data = (data.numerator, data.denominator)
138
- if tag not in NO_COPY_TIFF_TAGS and tag_id not in NO_COPY_TIFF_TAGS_ID:
139
- extra.append((tag_id, *get_tiff_dtype_count(data), data, False))
140
- else:
141
- logger.debug(msg=f"Skip tag {tag:25} [#{tag_id}]")
142
- return extra, {'resolution': resolution, 'resolutionunit': resolutionunit,
143
- 'software': software, 'photometric': photometric}
236
+ return data.decode('utf-8', errors='replace')
144
237
 
145
238
 
146
239
  def get_tiff_dtype_count(value):
@@ -173,32 +266,36 @@ def get_tiff_dtype_count(value):
173
266
 
174
267
 
175
268
  def add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose=False):
176
- logger = logging.getLogger(__name__)
177
269
  if exif is None:
178
270
  raise RuntimeError('No exif data provided.')
179
- if verbose:
180
- print_exif(exif)
181
- xmp_data = None
182
- if XMLPACKET in exif:
183
- xmp_data = exif[XMLPACKET]
184
- if isinstance(xmp_data, bytes):
185
- xmp_start = xmp_data.find(b'<x:xmpmeta')
186
- xmp_end = xmp_data.find(b'</x:xmpmeta>')
187
- if xmp_start != -1 and xmp_end != -1:
188
- xmp_end += len(b'</x:xmpmeta>')
189
- xmp_data = xmp_data[xmp_start:xmp_end]
271
+ logger = logging.getLogger(__name__)
272
+ xmp_data = exif.get(XMLPACKET) if hasattr(exif, 'get') else None
190
273
  with Image.open(in_filename) as image:
191
- if hasattr(exif, 'tobytes'):
274
+ if hasattr(exif, 'tobytes') and 'TiffImagePlugin' in str(type(exif)):
275
+ jpeg_exif = Image.Exif()
276
+ for tag_id in exif:
277
+ if tag_id != XMLPACKET:
278
+ try:
279
+ jpeg_exif[tag_id] = exif[tag_id]
280
+ except Exception as e:
281
+ if verbose:
282
+ logger.warning(msg=f"Failed to add tag {tag_id}: {e}")
283
+ exif_bytes = jpeg_exif.tobytes()
284
+ elif hasattr(exif, 'tobytes'):
192
285
  exif_bytes = exif.tobytes()
193
286
  else:
194
- exif_bytes = exif
287
+ jpeg_exif = Image.Exif()
288
+ for tag_id, value in exif.items():
289
+ if tag_id != XMLPACKET:
290
+ try:
291
+ jpeg_exif[tag_id] = value
292
+ except Exception as e:
293
+ if verbose:
294
+ logger.warning(msg=f"Failed to add tag {tag_id}: {e}")
295
+ exif_bytes = jpeg_exif.tobytes()
195
296
  image.save(out_filename, "JPEG", exif=exif_bytes, quality=100)
196
297
  if xmp_data and isinstance(xmp_data, bytes):
197
- try:
198
- _insert_xmp_into_jpeg(out_filename, xmp_data, verbose)
199
- except Exception as e:
200
- if verbose:
201
- logger.warning(msg=f"Failed to insert XMP data: {e}")
298
+ _insert_xmp_into_jpeg(out_filename, xmp_data, verbose)
202
299
 
203
300
 
204
301
  def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
@@ -242,76 +339,85 @@ def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
242
339
  def create_xmp_from_exif(exif_data):
243
340
  xmp_elements = []
244
341
  if exif_data:
342
+ xmp_tag_map = {
343
+ 270: {'format': 'dc:description', 'type': 'rdf_alt', 'processor': safe_decode_bytes},
344
+ 315: {'format': 'dc:creator', 'type': 'rdf_seq', 'processor': safe_decode_bytes},
345
+ 33432: {'format': 'dc:rights', 'type': 'rdf_alt', 'processor': safe_decode_bytes},
346
+ 271: {'format': 'tiff:Make', 'type': 'simple', 'processor': safe_decode_bytes},
347
+ 272: {'format': 'tiff:Model', 'type': 'simple', 'processor': safe_decode_bytes},
348
+ 306: {'format': 'xmp:CreateDate', 'type': 'datetime', 'processor': safe_decode_bytes},
349
+ 36867: {
350
+ 'format': 'exif:DateTimeOriginal',
351
+ 'type': 'datetime',
352
+ 'processor': safe_decode_bytes
353
+ },
354
+ 305: {'format': 'xmp:CreatorTool', 'type': 'simple', 'processor': safe_decode_bytes},
355
+ 33434: {'format': 'exif:ExposureTime', 'type': 'rational', 'processor': None},
356
+ 33437: {'format': 'exif:FNumber', 'type': 'rational', 'processor': None},
357
+ 34855: {'format': 'exif:ISOSpeedRatings', 'type': 'rdf_seq', 'processor': None},
358
+ 37386: {'format': 'exif:FocalLength', 'type': 'rational', 'processor': None},
359
+ 42036: {'format': 'aux:Lens', 'type': 'simple', 'processor': safe_decode_bytes},
360
+ 37377: {'format': 'exif:ShutterSpeedValue', 'type': 'rational', 'processor': None},
361
+ 37378: {'format': 'exif:ApertureValue', 'type': 'rational', 'processor': None},
362
+ 37380: {'format': 'exif:ExposureBiasValue', 'type': 'rational', 'processor': None},
363
+ 37381: {'format': 'exif:MaxApertureValue', 'type': 'rational', 'processor': None},
364
+ 37383: {'format': 'exif:MeteringMode', 'type': 'simple', 'processor': None},
365
+ 37385: {'format': 'exif:Flash', 'type': 'simple', 'processor': None},
366
+ 41987: {
367
+ 'format': 'exif:WhiteBalance',
368
+ 'type': 'mapped',
369
+ 'processor': None,
370
+ 'map': {0: 'Auto', 1: 'Manual'}
371
+ },
372
+ 41986: {
373
+ 'format': 'exif:ExposureMode',
374
+ 'type': 'mapped',
375
+ 'processor': None,
376
+ 'map': {0: 'Auto', 1: 'Manual', 2: 'Auto bracket'}
377
+ },
378
+ 41990: {
379
+ 'format': 'exif:SceneCaptureType',
380
+ 'type': 'mapped',
381
+ 'processor': None,
382
+ 'map': {0: 'Standard', 1: 'Landscape', 2: 'Portrait', 3: 'Night scene'}
383
+ }
384
+ }
245
385
  for tag_id, value in exif_data.items():
246
- if isinstance(tag_id, int):
247
- if tag_id == 270 and value: # ImageDescription
248
- desc = value
249
- if isinstance(desc, bytes):
250
- desc = desc.decode('utf-8', errors='ignore')
386
+ if isinstance(tag_id, int) and value and tag_id in xmp_tag_map:
387
+ config = xmp_tag_map[tag_id]
388
+ processed_value = config['processor'](value) if config['processor'] else value
389
+ if config['type'] == 'simple':
390
+ xmp_elements.append(
391
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
392
+ elif config['type'] == 'rdf_alt':
251
393
  xmp_elements.append(
252
- f'<dc:description><rdf:Alt><rdf:li xml:lang="x-default">{desc}</rdf:li>'
253
- '</rdf:Alt></dc:description>')
254
- elif tag_id == 315 and value: # Artist
255
- artist = value
256
- if isinstance(artist, bytes):
257
- artist = artist.decode('utf-8', errors='ignore')
394
+ f'<{config["format"]}><rdf:Alt>'
395
+ f'<rdf:li xml:lang="x-default">{processed_value}</rdf:li>'
396
+ f'</rdf:Alt></{config["format"]}>')
397
+ elif config['type'] == 'rdf_seq':
258
398
  xmp_elements.append(
259
- f'<dc:creator><rdf:Seq><rdf:li>{artist}</rdf:li>'
260
- '</rdf:Seq></dc:creator>')
261
- elif tag_id == 33432 and value: # Copyright
262
- copyright_tag = value
263
- if isinstance(copyright_tag, bytes):
264
- copyright_tag = copyright_tag.decode('utf-8', errors='ignore')
399
+ f'<{config["format"]}><rdf:Seq>'
400
+ f'<rdf:li>{processed_value}</rdf:li>'
401
+ f'</rdf:Seq></{config["format"]}>')
402
+ elif config['type'] == 'datetime':
403
+ if ':' in processed_value:
404
+ processed_value = processed_value.replace(':', '-', 2).replace(' ', 'T')
265
405
  xmp_elements.append(
266
- f'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">{copyright_tag}</rdf:li>'
267
- '</rdf:Alt></dc:rights>')
268
- elif tag_id == 271 and value: # Make
269
- make = value
270
- if isinstance(make, bytes):
271
- make = make.decode('utf-8', errors='ignore')
272
- xmp_elements.append(f'<tiff:Make>{make}</tiff:Make>')
273
- elif tag_id == 272 and value: # Model
274
- model = value
275
- if isinstance(model, bytes):
276
- model = model.decode('utf-8', errors='ignore')
277
- xmp_elements.append(f'<tiff:Model>{model}</tiff:Model>')
278
- elif tag_id == 306 and value: # DateTime
279
- datetime_val = value
280
- if isinstance(datetime_val, bytes):
281
- datetime_val = datetime_val.decode('utf-8', errors='ignore')
282
- if ':' in datetime_val:
283
- datetime_val = datetime_val.replace(':', '-', 2).replace(' ', 'T')
284
- xmp_elements.append(f'<xmp:CreateDate>{datetime_val}</xmp:CreateDate>')
285
- elif tag_id == 305 and value: # Software
286
- software = value
287
- if isinstance(software, bytes):
288
- software = software.decode('utf-8', errors='ignore')
289
- xmp_elements.append(f'<xmp:CreatorTool>{software}</xmp:CreatorTool>')
406
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
407
+ elif config['type'] == 'rational':
408
+ float_value = float(value) \
409
+ if hasattr(value, 'numerator') \
410
+ else (float(value) if value else 0)
411
+ xmp_elements.append(
412
+ f'<{config["format"]}>{float_value}</{config["format"]}>')
413
+ elif config['type'] == 'mapped':
414
+ mapped_value = config['map'].get(value, str(value))
415
+ xmp_elements.append(
416
+ f'<{config["format"]}>{mapped_value}</{config["format"]}>')
290
417
  if xmp_elements:
291
418
  xmp_content = '\n '.join(xmp_elements)
292
- xmp_template = f"""<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
293
- <x:xmpmeta xmlns:x='adobe:ns:meta/'
294
- x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
295
- <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
296
- <rdf:Description rdf:about=''
297
- xmlns:dc='http://purl.org/dc/elements/1.1/'
298
- xmlns:xmp='http://ns.adobe.com/xap/1.0/'
299
- xmlns:tiff='http://ns.adobe.com/tiff/1.0/'
300
- xmlns:exif='http://ns.adobe.com/exif/1.0/'>
301
- {xmp_content}
302
- </rdf:Description>
303
- </rdf:RDF>
304
- </x:xmpmeta>
305
- <?xpacket end='w'?>"""
306
- return xmp_template
307
- return """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
308
- <x:xmpmeta xmlns:x='adobe:ns:meta/'
309
- x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
310
- <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
311
- <rdf:Description rdf:about=''/>
312
- </rdf:RDF>
313
- </x:xmpmeta>
314
- <?xpacket end='w'?>"""
419
+ return XMP_TEMPLATE.format(content=xmp_content)
420
+ return XMP_EMPTY_TEMPLATE
315
421
 
316
422
 
317
423
  def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -321,83 +427,92 @@ def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, col
321
427
  logger.warning(msg="EXIF data not supported for 16-bit PNG format")
322
428
  write_img(out_filename, image)
323
429
  return
324
- pil_image = _convert_to_pil_image(image, color_order, verbose, logger)
325
- pnginfo, icc_profile = _prepare_png_metadata(exif, verbose, logger)
326
- try:
327
- save_args = {'format': 'PNG', 'pnginfo': pnginfo}
328
- if icc_profile:
329
- save_args['icc_profile'] = icc_profile
330
- if verbose:
331
- logger.info(msg="Saved PNG with ICC profile and metadata")
332
- else:
333
- if verbose:
334
- logger.info(msg="Saved PNG without ICC profile but with metadata")
335
- pil_image.save(out_filename, **save_args)
336
- if verbose:
337
- logger.info(msg=f"Successfully wrote PNG with metadata: {out_filename}")
338
- except Exception as e:
339
- if verbose:
340
- logger.error(msg=f"Failed to write PNG with metadata: {e}")
341
- logger.error(traceback.format_exc())
342
- pil_image.save(out_filename, format='PNG')
430
+ pil_image = _convert_to_pil_image(image, color_order)
431
+ pnginfo, icc_profile = _prepare_png_metadata(exif)
432
+ save_args = {'format': 'PNG', 'pnginfo': pnginfo}
433
+ if icc_profile:
434
+ save_args['icc_profile'] = icc_profile
435
+ pil_image.save(out_filename, **save_args)
343
436
 
344
437
 
345
- def _convert_to_pil_image(image, color_order, verbose, logger):
346
- if isinstance(image, np.ndarray):
347
- if len(image.shape) == 3 and image.shape[2] == 3:
348
- if color_order in ['auto', 'bgr']:
349
- image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
350
- if verbose:
351
- logger.info(msg="Converted BGR to RGB for PIL")
352
- return Image.fromarray(image_rgb)
353
- return Image.fromarray(image)
354
- return image
438
+ def _convert_to_pil_image(image, color_order):
439
+ if isinstance(image, np.ndarray) and len(image.shape) == 3 and image.shape[2] == 3:
440
+ if color_order in ['auto', 'bgr']:
441
+ image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
442
+ return Image.fromarray(image_rgb)
443
+ return Image.fromarray(image) if isinstance(image, np.ndarray) else image
355
444
 
356
445
 
357
- def _prepare_png_metadata(exif, verbose, logger):
446
+ def _prepare_png_metadata(exif):
358
447
  pnginfo = PngInfo()
359
448
  icc_profile = None
360
- xmp_data = _extract_xmp_data(exif, verbose, logger)
449
+ xmp_data = _extract_xmp_data(exif)
361
450
  if xmp_data:
362
451
  pnginfo.add_text("XML:com.adobe.xmp", xmp_data)
363
- if verbose:
364
- logger.info(msg="Added XMP data to PNG info")
365
- _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger)
366
- icc_profile = _extract_icc_profile(exif, verbose, logger)
452
+ _add_exif_tags_to_pnginfo(exif, pnginfo)
453
+ icc_profile = _extract_icc_profile(exif)
367
454
  return pnginfo, icc_profile
368
455
 
369
456
 
370
- def _extract_xmp_data(exif, verbose, logger):
457
+ def _extract_xmp_data(exif):
371
458
  for key, value in exif.items():
372
459
  if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
373
460
  if isinstance(value, bytes):
374
- try:
375
- xmp_data = value.decode('utf-8', errors='ignore')
376
- if verbose:
377
- logger.info(msg=f"Found existing XMP data in source: {key}")
378
- return xmp_data
379
- except Exception:
380
- continue
381
- elif isinstance(value, str):
382
- if verbose:
383
- logger.info(msg=f"Found existing XMP data in source: {key}")
461
+ return value.decode('utf-8', errors='ignore')
462
+ if isinstance(value, str):
384
463
  return value
385
- if verbose:
386
- logger.info("Generated new XMP data from EXIF")
387
464
  return create_xmp_from_exif(exif)
388
465
 
389
466
 
390
- def _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger):
467
+ def _add_exif_tags_to_pnginfo(exif, pnginfo):
468
+ camera_tags = {
469
+ 271: 'CameraMake', 272: 'CameraModel', 305: 'Software',
470
+ 306: 'DateTime', 315: 'Artist', 33432: 'Copyright'
471
+ }
472
+ exposure_tags = {
473
+ 33434: 'ExposureTime', 33437: 'FNumber', 34855: 'ISOSpeed',
474
+ 37377: 'ShutterSpeedValue', 37378: 'ApertureValue', 37386: 'FocalLength',
475
+ 42036: 'LensModel', 37380: 'ExposureBiasValue', 37381: 'MaxApertureValue',
476
+ 37383: 'MeteringMode', 37385: 'Flash', 41987: 'WhiteBalance',
477
+ 41986: 'ExposureMode', 41990: 'SceneCaptureType', 36867: 'DateTimeOriginal'
478
+ }
391
479
  for tag_id, value in exif.items():
392
480
  if value is None:
393
481
  continue
394
482
  if isinstance(tag_id, int):
395
- _add_exif_tag(pnginfo, tag_id, value, verbose, logger)
483
+ if tag_id in camera_tags:
484
+ _add_typed_tag(pnginfo, f"EXIF_{camera_tags[tag_id]}", value)
485
+ elif tag_id in exposure_tags:
486
+ _add_typed_tag(pnginfo, f"EXIF_{exposure_tags[tag_id]}", value)
487
+ else:
488
+ _add_exif_tag(pnginfo, tag_id, value)
396
489
  elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
397
- _add_png_text_tag(pnginfo, tag_id, value, verbose, logger)
490
+ _add_png_text_tag(pnginfo, tag_id, value)
491
+
492
+
493
+ def _add_typed_tag(pnginfo, key, value):
494
+ try:
495
+ if hasattr(value, 'numerator'):
496
+ stored_value = f"RATIONAL:{value.numerator}/{value.denominator}"
497
+ elif isinstance(value, bytes):
498
+ try:
499
+ stored_value = f"STRING:{value.decode('utf-8', errors='replace')}"
500
+ except Exception:
501
+ stored_value = f"BYTES:{str(value)[:100]}"
502
+ elif isinstance(value, (list, tuple)):
503
+ stored_value = f"ARRAY:{','.join(str(x) for x in value)}"
504
+ elif isinstance(value, int):
505
+ stored_value = f"INT:{value}"
506
+ elif isinstance(value, float):
507
+ stored_value = f"FLOAT:{value}"
508
+ else:
509
+ stored_value = f"STRING:{str(value)}"
510
+ pnginfo.add_text(key, stored_value)
511
+ except Exception:
512
+ pass
398
513
 
399
514
 
400
- def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
515
+ def _add_exif_tag(pnginfo, tag_id, value):
401
516
  try:
402
517
  tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
403
518
  if isinstance(value, bytes) and len(value) > 1000:
@@ -410,17 +525,16 @@ def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
410
525
  pnginfo.add_text(tag_name, decoded_value)
411
526
  except Exception:
412
527
  pass
413
- elif hasattr(value, 'numerator'): # IFDRational
528
+ elif hasattr(value, 'numerator'):
414
529
  rational_str = f"{value.numerator}/{value.denominator}"
415
530
  pnginfo.add_text(tag_name, rational_str)
416
531
  else:
417
532
  pnginfo.add_text(tag_name, str(value))
418
- except Exception as e:
419
- if verbose:
420
- logger.warning(f"Could not store EXIF tag {tag_id}: {e}")
533
+ except Exception:
534
+ pass
421
535
 
422
536
 
423
- def _add_png_text_tag(pnginfo, key, value, verbose, logger):
537
+ def _add_png_text_tag(pnginfo, key, value):
424
538
  try:
425
539
  clean_key = key[4:] if key.startswith('PNG_') else key
426
540
  if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
@@ -434,33 +548,97 @@ def _add_png_text_tag(pnginfo, key, value, verbose, logger):
434
548
  pnginfo.add_text(clean_key, truncated_value)
435
549
  else:
436
550
  pnginfo.add_text(clean_key, str(value))
437
- except Exception as e:
438
- if verbose:
439
- logger.warning(msg=f"Could not store PNG metadata {key}: {e}")
551
+ except Exception:
552
+ pass
440
553
 
441
554
 
442
- def _extract_icc_profile(exif, verbose, logger):
555
+ def _extract_icc_profile(exif):
443
556
  for key, value in exif.items():
444
557
  if (isinstance(key, str) and
445
558
  isinstance(value, bytes) and
446
559
  ('icc' in key.lower() or 'profile' in key.lower())):
447
- if verbose:
448
- logger.info(f"Found ICC profile: {key}")
449
560
  return value
450
561
  return None
451
562
 
452
563
 
564
+ def clean_data_for_tiff(data):
565
+ if isinstance(data, str):
566
+ return data.encode('ascii', 'ignore').decode('ascii')
567
+ if isinstance(data, bytes):
568
+ decoded = data.decode('utf-8', 'ignore')
569
+ return decoded.encode('ascii', 'ignore').decode('ascii')
570
+ if isinstance(data, IFDRational):
571
+ return (data.numerator, data.denominator)
572
+ return data
573
+
574
+
453
575
  def write_image_with_exif_data_jpg(exif, image, out_filename, verbose):
454
- cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
576
+ cv2.imwrite(out_filename, image, [cv2.IMWRITE_JPEG_QUALITY, 100])
455
577
  add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
456
578
 
457
579
 
580
+ def exif_extra_tags_for_tif(exif):
581
+ res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
582
+ resolution = (
583
+ (res_x.numerator, res_x.denominator),
584
+ (res_y.numerator, res_y.denominator)
585
+ ) if res_x and res_y else (
586
+ (720000, 10000), (720000, 10000)
587
+ )
588
+ exif_tags = {
589
+ 'resolution': resolution,
590
+ 'resolutionunit': exif.get(RESOLUTIONUNIT, 'inch'),
591
+ 'software': clean_data_for_tiff(exif.get(SOFTWARE)) or constants.APP_TITLE,
592
+ 'photometric': exif.get(PHOTOMETRICINTERPRETATION)
593
+ }
594
+ extra = []
595
+ for tag_id in exif:
596
+ tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
597
+ if tag in NO_COPY_TIFF_TAGS or tag_id in NO_COPY_TIFF_TAGS_ID or tag_id == SOFTWARE:
598
+ continue
599
+ if isinstance(data, IFDRational):
600
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
601
+ extra.append((tag_id, 5, 1, data, False))
602
+ continue
603
+ processed_data = _process_tiff_data(data)
604
+ if processed_data:
605
+ dtype, count, data_value = processed_data
606
+ extra.append((tag_id, dtype, count, data_value, False))
607
+ return extra, exif_tags
608
+
609
+
610
+ def _process_tiff_data(data):
611
+ if isinstance(data, IFDRational):
612
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
613
+ return 5, 1, data
614
+ if hasattr(data, '__iter__') and not isinstance(data, (str, bytes)):
615
+ try:
616
+ clean_data = [float(x)
617
+ if not hasattr(x, 'denominator') or x.denominator != 0
618
+ else float('nan') for x in data]
619
+ return 12, len(clean_data), tuple(clean_data)
620
+ except Exception:
621
+ return None
622
+ if isinstance(data, (str, bytes)):
623
+ clean_data = clean_data_for_tiff(data)
624
+ if clean_data:
625
+ return 2, len(clean_data) + 1, clean_data
626
+ try:
627
+ dtype, count = get_tiff_dtype_count(data)
628
+ return dtype, count, data
629
+ except Exception:
630
+ return None
631
+
632
+
458
633
  def write_image_with_exif_data_tif(exif, image, out_filename):
459
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
460
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
461
634
  image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
462
- tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
463
- extratags=extra_tags, **exif_tags)
635
+ try:
636
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
637
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
638
+ tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
639
+ extratags=extra_tags, **exif_tags)
640
+ except Exception:
641
+ tifffile.imwrite(out_filename, image, compression='adobe_deflate')
464
642
 
465
643
 
466
644
  def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -485,21 +663,21 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
485
663
  raise RuntimeError('No exif data provided.')
486
664
  if verbose:
487
665
  print_exif(exif)
488
- if extension_tif(in_filename):
489
- image_new = tifffile.imread(in_filename)
490
- elif extension_jpg(in_filename):
491
- image_new = Image.open(in_filename)
492
- elif extension_png(in_filename):
493
- image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
494
- if extension_jpg(in_filename):
666
+ if extension_png(in_filename) or extension_tif(in_filename):
667
+ if extension_tif(in_filename):
668
+ image_new = tifffile.imread(in_filename)
669
+ elif extension_png(in_filename):
670
+ image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
671
+ if extension_tif(in_filename):
672
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
673
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
674
+ tifffile.imwrite(
675
+ out_filename, image_new, metadata=metadata, compression='adobe_deflate',
676
+ extratags=extra_tags, **exif_tags)
677
+ elif extension_png(in_filename):
678
+ write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
679
+ else:
495
680
  add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
496
- elif extension_tif(in_filename):
497
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
498
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
499
- tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
500
- extratags=extra_tags, **exif_tags)
501
- elif extension_png(in_filename):
502
- write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
503
681
  return exif
504
682
 
505
683
 
@@ -512,20 +690,25 @@ def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, v
512
690
  return save_exif_data(exif, in_filename, out_filename, verbose)
513
691
 
514
692
 
515
- def exif_dict(exif):
516
- if exif is None:
693
+ def exif_dict(exif_data):
694
+ if exif_data is None:
517
695
  return None
518
- exif_data = {}
519
- for tag_id in exif:
520
- tag = TAGS.get(tag_id, tag_id)
521
- data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
522
- if isinstance(data, bytes):
523
- try:
524
- data = data.decode()
525
- except Exception:
526
- pass
527
- exif_data[tag] = (tag_id, data)
528
- return exif_data
696
+ result = {}
697
+ for tag, value in exif_data.items():
698
+ if isinstance(tag, int):
699
+ tag_name = TAGS.get(tag, str(tag))
700
+ else:
701
+ tag_name = str(tag)
702
+ if tag_name.startswith('PNG_EXIF_'):
703
+ standard_tag = tag_name[9:]
704
+ elif tag_name.startswith('EXIF_'):
705
+ standard_tag = tag_name[5:]
706
+ elif tag_name.startswith('PNG_'):
707
+ continue
708
+ else:
709
+ standard_tag = tag_name
710
+ result[standard_tag] = (tag, value)
711
+ return result
529
712
 
530
713
 
531
714
  def print_exif(exif):
@@ -538,7 +721,7 @@ def print_exif(exif):
538
721
  data = f"{data.numerator}/{data.denominator}"
539
722
  data_str = f"{data}"
540
723
  if len(data_str) > 40:
541
- data_str = f"{data_str[:40]}..."
724
+ data_str = f"{data_str[:40]}... (truncated)"
542
725
  if isinstance(tag_id, int):
543
726
  tag_id_str = f"[#{tag_id:5d}]"
544
727
  else: