shinestacker 1.9.1__py3-none-any.whl → 1.9.3__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,5 +1,6 @@
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
5
  import traceback
5
6
  import cv2
@@ -10,137 +11,461 @@ from PIL.PngImagePlugin import PngInfo
10
11
  from PIL.ExifTags import TAGS
11
12
  import tifffile
12
13
  from .. config.constants import constants
13
- from .utils import write_img, extension_jpg, extension_tif, extension_png
14
+ from .utils import read_img, write_img, extension_jpg, extension_tif, extension_png
14
15
 
16
+ # TIFF/EXIF Tag Constants
15
17
  IMAGEWIDTH = 256
16
18
  IMAGELENGTH = 257
17
- RESOLUTIONX = 282
18
- RESOLUTIONY = 283
19
- RESOLUTIONUNIT = 296
20
19
  BITSPERSAMPLE = 258
20
+ COMPRESSION = 259
21
21
  PHOTOMETRICINTERPRETATION = 262
22
+ IMAGEDESCRIPTION = 270
23
+ MAKE = 271
24
+ MODEL = 272
25
+ STRIPOFFSETS = 273
26
+ ORIENTATION = 274
22
27
  SAMPLESPERPIXEL = 277
28
+ ROWSPERSTRIP = 278
29
+ STRIPBYTECOUNTS = 279
30
+ XRESOLUTION = 282
31
+ YRESOLUTION = 283
23
32
  PLANARCONFIGURATION = 284
33
+ RESOLUTIONUNIT = 296
24
34
  SOFTWARE = 305
35
+ DATETIME = 306
36
+ ARTIST = 315
37
+ PREDICTOR = 317
38
+ WHITEPOINT = 318
39
+ PRIMARYCHROMATICITIES = 319
40
+ COLORMAP = 320
41
+ TILEWIDTH = 322
42
+ TILELENGTH = 323
43
+ TILEOFFSETS = 324
44
+ TILEBYTECOUNTS = 325
45
+ EXIFIFD = 34665
46
+ ICCPROFILE = 34675
47
+ COPYRIGHT = 33432
48
+ EXPOSURETIME = 33434
49
+ FNUMBER = 33437
50
+ EXPOSUREPROGRAM = 34850
51
+ ISOSPEEDRATINGS = 34855
52
+ EXIFVERSION = 36864
53
+ DATETIMEORIGINAL = 36867
54
+ DATETIMEDIGITIZED = 36868
55
+ SHUTTERSPEEDVALUE = 37377
56
+ APERTUREVALUE = 37378
57
+ BRIGHTNESSVALUE = 37379
58
+ EXPOSUREBIASVALUE = 37380
59
+ MAXAPERTUREVALUE = 37381
60
+ SUBJECTDISTANCE = 37382
61
+ METERINGMODE = 37383
62
+ LIGHTSOURCE = 37384
63
+ FLASH = 37385
64
+ FOCALLENGTH = 37386
65
+ MAKERNOTE = 37500
66
+ USERCOMMENT = 37510
67
+ SUBSECTIME = 37520
68
+ SUBSECTIMEORIGINAL = 37521
69
+ SUBSECTIMEDIGITIZED = 37522
70
+ FLASHPIXVERSION = 40960
71
+ COLORSPACE = 40961
72
+ PIXELXDIMENSION = 40962
73
+ PIXELYDIMENSION = 40963
74
+ RELATEDSOUNDFILE = 40964
75
+ FLASHENERGY = 41483
76
+ SPATIALFREQUENCYRESPONSE = 41484
77
+ FOCALPLANEXRESOLUTION = 41486
78
+ FOCALPLANEYRESOLUTION = 41487
79
+ FOCALPLANERESOLUTIONUNIT = 41488
80
+ SUBJECTLOCATION = 41492
81
+ EXPOSUREINDEX = 41493
82
+ SENSINGMETHOD = 41495
83
+ FILESOURCE = 41728
84
+ SCENETYPE = 41729
85
+ CFAPATTERN = 41730
86
+ CUSTOMRENDERED = 41985
87
+ EXPOSUREMODE = 41986
88
+ WHITEBALANCE = 41987
89
+ DIGITALZOOMRATIO = 41988
90
+ FOCALLENGTHIN35MMFILM = 41989
91
+ SCENECAPTURETYPE = 41990
92
+ GAINCONTROL = 41991
93
+ CONTRAST = 41992
94
+ SATURATION = 41993
95
+ SHARPNESS = 41994
96
+ DEVICESETTINGDESCRIPTION = 41995
97
+ SUBJECTDISTANCERANGE = 41996
98
+ IMAGEUNIQUEID = 42016
99
+ LENSINFO = 42034
100
+ LENSMAKE = 42035
101
+ LENSMODEL = 42036
102
+ GPSIFD = 34853
103
+ XMLPACKET = 700
25
104
  IMAGERESOURCES = 34377
26
105
  INTERCOLORPROFILE = 34675
27
- EXIFTAG = 34665
28
- XMLPACKET = 700
29
- STRIPOFFSETS = 273
30
- STRIPBYTECOUNTS = 279
31
- NO_COPY_TIFF_TAGS_ID = [IMAGEWIDTH, IMAGELENGTH, RESOLUTIONX, RESOLUTIONY, BITSPERSAMPLE,
32
- PHOTOMETRICINTERPRETATION, SAMPLESPERPIXEL, PLANARCONFIGURATION, SOFTWARE,
33
- RESOLUTIONUNIT, EXIFTAG, INTERCOLORPROFILE, IMAGERESOURCES]
106
+
107
+ NO_COPY_TIFF_TAGS_ID = [
108
+ IMAGEWIDTH, IMAGELENGTH, XRESOLUTION, YRESOLUTION, BITSPERSAMPLE,
109
+ PHOTOMETRICINTERPRETATION, SAMPLESPERPIXEL, PLANARCONFIGURATION, SOFTWARE,
110
+ RESOLUTIONUNIT, EXIFIFD, INTERCOLORPROFILE, IMAGERESOURCES,
111
+ STRIPOFFSETS, STRIPBYTECOUNTS, TILEOFFSETS, TILEBYTECOUNTS
112
+ ]
113
+
34
114
  NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCounts"]
35
115
 
116
+ XMP_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
117
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
118
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
119
+ <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/'>
120
+ {content}
121
+ </rdf:Description>
122
+ </rdf:RDF>
123
+ </x:xmpmeta>
124
+ <?xpacket end='w'?>""" # noqa
125
+
126
+ XMP_EMPTY_TEMPLATE = """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
127
+ <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
128
+ <rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
129
+ <rdf:Description rdf:about=''/>
130
+ </rdf:RDF>
131
+ </x:xmpmeta>
132
+ <?xpacket end='w'?>""" # noqa
133
+
134
+ XMP_TO_EXIF_MAP = {
135
+ 'tiff:Make': MAKE,
136
+ 'tiff:Model': MODEL,
137
+ 'exif:ExposureTime': EXPOSURETIME,
138
+ 'exif:FNumber': FNUMBER,
139
+ 'exif:ISOSpeedRatings': ISOSPEEDRATINGS,
140
+ 'exif:FocalLength': FOCALLENGTH,
141
+ 'exif:DateTimeOriginal': DATETIMEORIGINAL,
142
+ 'xmp:CreateDate': DATETIME,
143
+ 'xmp:CreatorTool': SOFTWARE,
144
+ 'aux:Lens': LENSMODEL, # Adobe's auxiliary namespace
145
+ 'exifEX:LensModel': LENSMODEL, # EXIF 2.3 namespace
146
+ 'exif:Flash': FLASH,
147
+ 'exif:WhiteBalance': WHITEBALANCE,
148
+ 'dc:description': IMAGEDESCRIPTION,
149
+ 'dc:creator': ARTIST,
150
+ 'dc:rights': COPYRIGHT,
151
+ 'exif:ShutterSpeedValue': SHUTTERSPEEDVALUE,
152
+ 'exif:ApertureValue': APERTUREVALUE,
153
+ 'exif:ExposureBiasValue': EXPOSUREBIASVALUE,
154
+ 'exif:MaxApertureValue': MAXAPERTUREVALUE,
155
+ 'exif:MeteringMode': METERINGMODE,
156
+ 'exif:ExposureMode': EXPOSUREMODE,
157
+ 'exif:SceneCaptureType': SCENECAPTURETYPE
158
+ }
159
+
160
+ PNG_TAG_MAP = {
161
+ 'EXIF_CameraMake': MAKE,
162
+ 'EXIF_CameraModel': MODEL,
163
+ 'EXIF_Software': SOFTWARE,
164
+ 'EXIF_DateTime': DATETIME,
165
+ 'EXIF_Artist': ARTIST,
166
+ 'EXIF_Copyright': COPYRIGHT,
167
+ 'EXIF_ExposureTime': EXPOSURETIME,
168
+ 'EXIF_FNumber': FNUMBER,
169
+ 'EXIF_ISOSpeed': ISOSPEEDRATINGS,
170
+ 'EXIF_ShutterSpeedValue': SHUTTERSPEEDVALUE,
171
+ 'EXIF_ApertureValue': APERTUREVALUE,
172
+ 'EXIF_FocalLength': FOCALLENGTH,
173
+ 'EXIF_LensModel': LENSMODEL,
174
+ 'EXIF_ExposureBiasValue': EXPOSUREBIASVALUE,
175
+ 'EXIF_MaxApertureValue': MAXAPERTUREVALUE,
176
+ 'EXIF_MeteringMode': METERINGMODE,
177
+ 'EXIF_Flash': FLASH,
178
+ 'EXIF_WhiteBalance': WHITEBALANCE,
179
+ 'EXIF_ExposureMode': EXPOSUREMODE,
180
+ 'EXIF_SceneCaptureType': SCENECAPTURETYPE,
181
+ 'EXIF_DateTimeOriginal': DATETIMEORIGINAL
182
+ }
183
+
184
+
185
+ def safe_decode_bytes(data, encoding='utf-8'):
186
+ if not isinstance(data, bytes):
187
+ return data
188
+ encodings = [encoding, 'latin-1', 'cp1252', 'utf-16', 'ascii']
189
+ for enc in encodings:
190
+ try:
191
+ return data.decode(enc, errors='strict')
192
+ except UnicodeDecodeError:
193
+ continue
194
+ return data.decode('utf-8', errors='replace')
195
+
196
+
197
+ XMP_TAG_MAP = {
198
+ IMAGEDESCRIPTION: {'format': 'dc:description', 'type': 'rdf_alt',
199
+ 'processor': safe_decode_bytes},
200
+ ARTIST: {'format': 'dc:creator', 'type': 'rdf_seq', 'processor': safe_decode_bytes},
201
+ COPYRIGHT: {'format': 'dc:rights', 'type': 'rdf_alt', 'processor': safe_decode_bytes},
202
+ MAKE: {'format': 'tiff:Make', 'type': 'simple', 'processor': safe_decode_bytes},
203
+ MODEL: {'format': 'tiff:Model', 'type': 'simple', 'processor': safe_decode_bytes},
204
+ DATETIME: {'format': 'xmp:CreateDate', 'type': 'datetime', 'processor': safe_decode_bytes},
205
+ DATETIMEORIGINAL: {'format': 'exif:DateTimeOriginal', 'type': 'datetime',
206
+ 'processor': safe_decode_bytes},
207
+ SOFTWARE: {'format': 'xmp:CreatorTool', 'type': 'simple', 'processor': safe_decode_bytes},
208
+ EXPOSURETIME: {'format': 'exif:ExposureTime', 'type': 'rational', 'processor': None},
209
+ FNUMBER: {'format': 'exif:FNumber', 'type': 'rational', 'processor': None},
210
+ ISOSPEEDRATINGS: {'format': 'exif:ISOSpeedRatings', 'type': 'rdf_seq', 'processor': None},
211
+ FOCALLENGTH: {'format': 'exif:FocalLength', 'type': 'rational', 'processor': None},
212
+ LENSMODEL: {'format': 'aux:Lens', 'type': 'simple', 'processor': safe_decode_bytes},
213
+ SHUTTERSPEEDVALUE: {'format': 'exif:ShutterSpeedValue', 'type': 'rational', 'processor': None},
214
+ APERTUREVALUE: {'format': 'exif:ApertureValue', 'type': 'rational', 'processor': None},
215
+ EXPOSUREBIASVALUE: {'format': 'exif:ExposureBiasValue', 'type': 'rational', 'processor': None},
216
+ MAXAPERTUREVALUE: {'format': 'exif:MaxApertureValue', 'type': 'rational', 'processor': None},
217
+ METERINGMODE: {'format': 'exif:MeteringMode', 'type': 'simple', 'processor': None},
218
+ FLASH: {'format': 'exif:Flash', 'type': 'simple', 'processor': None},
219
+ WHITEBALANCE: {'format': 'exif:WhiteBalance', 'type': 'mapped', 'processor': None,
220
+ 'map': {0: 'Auto', 1: 'Manual'}},
221
+ EXPOSUREMODE: {'format': 'exif:ExposureMode', 'type': 'mapped', 'processor': None,
222
+ 'map': {0: 'Auto', 1: 'Manual', 2: 'Auto bracket'}},
223
+ SCENECAPTURETYPE: {'format': 'exif:SceneCaptureType', 'type': 'mapped', 'processor': None,
224
+ 'map': {0: 'Standard', 1: 'Landscape', 2: 'Portrait', 3: 'Night scene'}}
225
+ }
226
+
227
+ CAMERA_TAGS_MAP = {
228
+ MAKE: 'CameraMake',
229
+ MODEL: 'CameraModel',
230
+ SOFTWARE: 'Software',
231
+ DATETIME: 'DateTime',
232
+ ARTIST: 'Artist',
233
+ COPYRIGHT: 'Copyright'
234
+ }
235
+
236
+ EXPOSURE_TAGS_MAP = {
237
+ EXPOSURETIME: 'ExposureTime',
238
+ FNUMBER: 'FNumber',
239
+ ISOSPEEDRATINGS: 'ISOSpeed',
240
+ SHUTTERSPEEDVALUE: 'ShutterSpeedValue',
241
+ APERTUREVALUE: 'ApertureValue',
242
+ FOCALLENGTH: 'FocalLength',
243
+ LENSMODEL: 'LensModel',
244
+ EXPOSUREBIASVALUE: 'ExposureBiasValue',
245
+ MAXAPERTUREVALUE: 'MaxApertureValue',
246
+ METERINGMODE: 'MeteringMode',
247
+ FLASH: 'Flash',
248
+ WHITEBALANCE: 'WhiteBalance',
249
+ EXPOSUREMODE: 'ExposureMode',
250
+ SCENECAPTURETYPE: 'SceneCaptureType',
251
+ DATETIMEORIGINAL: 'DateTimeOriginal'
252
+ }
253
+
254
+ EXPOSURE_DATA_TIFF = {
255
+ 'ExposureTime': EXPOSURETIME,
256
+ 'FNumber': FNUMBER,
257
+ 'ISOSpeedRatings': ISOSPEEDRATINGS,
258
+ 'FocalLength': FOCALLENGTH,
259
+ 'LensModel': LENSMODEL,
260
+ 'ShutterSpeedValue': SHUTTERSPEEDVALUE,
261
+ 'ApertureValue': APERTUREVALUE,
262
+ 'ExposureBiasValue': EXPOSUREBIASVALUE,
263
+ 'MaxApertureValue': MAXAPERTUREVALUE,
264
+ 'MeteringMode': METERINGMODE,
265
+ 'Flash': FLASH,
266
+ 'WhiteBalance': WHITEBALANCE,
267
+ 'ExposureMode': EXPOSUREMODE,
268
+ 'SceneCaptureType': SCENECAPTURETYPE,
269
+ 'DateTimeOriginal': DATETIMEORIGINAL,
270
+ 'Make': MAKE,
271
+ 'Model': MODEL
272
+ }
273
+
36
274
 
37
275
  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:
276
+ xmp_start = data.find(head)
277
+ if xmp_start == -1:
278
+ return None
279
+ xmp_end = data.find(foot, xmp_start)
280
+ if xmp_end == -1:
48
281
  return None
282
+ xmp_end += len(foot)
283
+ return data[xmp_start:xmp_end]
49
284
 
50
285
 
51
- def get_exif(exif_filename):
286
+ def get_exif(exif_filename, enhanced_png_parsing=True):
52
287
  if not os.path.isfile(exif_filename):
53
288
  raise RuntimeError(f"File does not exist: {exif_filename}")
54
289
  image = Image.open(exif_filename)
55
290
  if extension_tif(exif_filename):
56
- return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
291
+ return get_exif_from_tiff(image, exif_filename)
57
292
  if extension_jpg(exif_filename):
58
- exif_data = image.getexif()
59
- with open(exif_filename, 'rb') as f:
60
- data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
61
- if data is not None:
62
- exif_data[XMLPACKET] = data
63
- return exif_data
293
+ return get_exif_from_jpg(image, exif_filename)
64
294
  if extension_png(exif_filename):
295
+ if enhanced_png_parsing:
296
+ return get_enhanced_exif_from_png(image)
65
297
  exif_data = get_exif_from_png(image)
66
298
  return exif_data if exif_data else image.getexif()
67
299
  return image.getexif()
68
300
 
69
301
 
70
- def get_exif_from_png(image):
71
- exif_data = {}
302
+ def get_exif_from_tiff(image, exif_filename):
303
+ exif_data = image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
72
304
  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
305
+ with tifffile.TiffFile(exif_filename) as tif:
306
+ for page in tif.pages:
307
+ if EXIFIFD in page.tags:
308
+ exif_dict_data = page.tags[EXIFIFD].value
309
+ for exif_key, tag_id in EXPOSURE_DATA_TIFF.items():
310
+ if exif_key in exif_dict_data:
311
+ value = exif_dict_data[exif_key]
312
+ if isinstance(value, tuple) and len(value) == 2:
313
+ value = IFDRational(value[0], value[1])
314
+ exif_data[tag_id] = value
315
+ break
316
+ except Exception as e:
317
+ print(f"Error reading EXIF with tifffile: {e}")
78
318
  try:
79
- if hasattr(image, 'text') and image.text:
80
- for key, value in image.text.items():
81
- 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
319
+ if XMLPACKET in exif_data:
320
+ xmp_data = exif_data[XMLPACKET]
321
+ if isinstance(xmp_data, bytes):
322
+ xmp_string = xmp_data.decode('utf-8', errors='ignore')
323
+ else:
324
+ xmp_string = str(xmp_data)
325
+ xmp_exif = parse_xmp_to_exif(xmp_string)
326
+ for tag_id in [EXPOSURETIME, FNUMBER, ISOSPEEDRATINGS, FOCALLENGTH, LENSMODEL]:
327
+ if tag_id in xmp_exif and tag_id not in exif_data:
328
+ exif_data[tag_id] = xmp_exif[tag_id]
329
+ except Exception as e:
330
+ print(f"Error processing XMP: {e}")
331
+ return exif_data
332
+
333
+
334
+ def get_exif_from_jpg(image, exif_filename):
335
+ exif_data = image.getexif()
336
+ try:
337
+ exif_subifd = exif_data.get_ifd(EXIFIFD)
338
+ for tag_id, value in exif_subifd.items():
339
+ if tag_id in EXPOSURE_TAGS_MAP:
340
+ exif_data[tag_id] = value
341
+ elif tag_id not in exif_data:
342
+ exif_data[tag_id] = value
86
343
  except Exception:
87
344
  pass
345
+ if MAKERNOTE in exif_data:
346
+ del exif_data[MAKERNOTE]
347
+ with open(exif_filename, 'rb') as f:
348
+ data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
349
+ if data is not None:
350
+ exif_data[XMLPACKET] = data
88
351
  return exif_data
89
352
 
90
353
 
91
- def safe_decode_bytes(data, encoding='utf-8'):
92
- if not isinstance(data, bytes):
93
- return data
94
- encodings = [encoding, 'latin-1', 'cp1252', 'utf-16', 'ascii']
95
- for enc in encodings:
354
+ def get_exif_from_png(image):
355
+ exif_data = {}
356
+ exif_from_image = image.getexif()
357
+ if exif_from_image:
358
+ exif_data.update(dict(exif_from_image))
359
+ for attr_name in ['text', 'info']:
360
+ if hasattr(image, attr_name) and getattr(image, attr_name):
361
+ for key, value in getattr(image, attr_name).items():
362
+ if attr_name == 'info' and key in ['dpi', 'gamma']:
363
+ continue
364
+ exif_data[f"PNG_{key}"] = value
365
+ return exif_data
366
+
367
+
368
+ def parse_xmp_to_exif(xmp_data):
369
+ exif_data = {}
370
+ if not xmp_data:
371
+ return exif_data
372
+ if isinstance(xmp_data, bytes):
373
+ xmp_data = xmp_data.decode('utf-8', errors='ignore')
374
+ for xmp_tag, exif_tag in XMP_TO_EXIF_MAP.items():
375
+ attr_pattern = f'{xmp_tag}="([^"]*)"'
376
+ attr_matches = re.findall(attr_pattern, xmp_data)
377
+ for value in attr_matches:
378
+ if value:
379
+ exif_data[exif_tag] = _parse_xmp_value(exif_tag, value)
380
+ start_tag = f'<{xmp_tag}>'
381
+ end_tag = f'</{xmp_tag}>'
382
+ if start_tag in xmp_data:
383
+ start = xmp_data.find(start_tag) + len(start_tag)
384
+ end = xmp_data.find(end_tag, start)
385
+ if end != -1:
386
+ value = xmp_data[start:end].strip()
387
+ if value:
388
+ exif_data[exif_tag] = _parse_xmp_value(exif_tag, value)
389
+ return exif_data
390
+
391
+
392
+ def _parse_xmp_value(exif_tag, value):
393
+ if exif_tag in [EXPOSURETIME, FNUMBER, FOCALLENGTH]:
394
+ if '/' in value:
395
+ num, den = value.split('/')
396
+ try:
397
+ return IFDRational(int(num), int(den))
398
+ except (ValueError, ZeroDivisionError):
399
+ try:
400
+ return float(value) if value else 0.0
401
+ except ValueError:
402
+ return 0.0 # Return 0.0 if float conversion also fails
403
+ return float(value) if value else 0.0
404
+ if exif_tag == ISOSPEEDRATINGS: # ISO
405
+ if '<rdf:li>' in value:
406
+ matches = re.findall(r'<rdf:li>([^<]+)</rdf:li>', value)
407
+ if matches:
408
+ value = matches[0]
96
409
  try:
97
- return data.decode(enc, errors='strict')
98
- except UnicodeDecodeError:
99
- continue
100
- try:
101
- return data.decode('utf-8', errors='replace')
102
- except Exception:
103
- return "<<< decode error >>>"
410
+ return int(value)
411
+ except ValueError:
412
+ return value
413
+ if exif_tag in [DATETIME, DATETIMEORIGINAL]: # DateTime and DateTimeOriginal
414
+ if 'T' in value:
415
+ value = value.replace('T', ' ').replace('-', ':')
416
+ return value
417
+ return value
104
418
 
105
419
 
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):
420
+ def parse_typed_png_text(value):
421
+ if isinstance(value, str):
422
+ if value.startswith('RATIONAL:'):
423
+ parts = value[9:].split('/')
424
+ if len(parts) == 2:
425
+ try:
426
+ return IFDRational(int(parts[0]), int(parts[1]))
427
+ except (ValueError, ZeroDivisionError):
428
+ return value # Return original value if parsing fails
429
+ elif value.startswith('INT:'):
123
430
  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}
431
+ return int(value[4:])
432
+ except ValueError:
433
+ return value[4:] # Return string part if int conversion fails
434
+ elif value.startswith('FLOAT:'):
435
+ try:
436
+ return float(value[6:])
437
+ except ValueError:
438
+ return value[6:] # Return string part if float conversion fails
439
+ elif value.startswith('STRING:'):
440
+ return value[7:]
441
+ elif value.startswith('BYTES:'):
442
+ return value[6:].encode('utf-8')
443
+ elif value.startswith('ARRAY:'):
444
+ return [x.strip() for x in value[6:].split(',')]
445
+ return value
446
+
447
+
448
+ def get_enhanced_exif_from_png(image):
449
+ basic_exif = get_exif_from_png(image)
450
+ enhanced_exif = {}
451
+ enhanced_exif.update(basic_exif)
452
+ xmp_data = None
453
+ if hasattr(image, 'text') and image.text:
454
+ xmp_data = image.text.get('XML:com.adobe.xmp') or image.text.get('xml:com.adobe.xmp')
455
+ if not xmp_data and XMLPACKET in basic_exif:
456
+ xmp_data = basic_exif[XMLPACKET]
457
+ if xmp_data:
458
+ enhanced_exif.update(parse_xmp_to_exif(xmp_data))
459
+ if hasattr(image, 'text') and image.text:
460
+ for key, value in image.text.items():
461
+ if key.startswith('EXIF_'):
462
+ parsed_value = parse_typed_png_text(value)
463
+ tag_id = PNG_TAG_MAP.get(key)
464
+ if tag_id:
465
+ enhanced_exif[tag_id] = parsed_value
466
+ if MAKERNOTE in enhanced_exif:
467
+ del enhanced_exif[MAKERNOTE]
468
+ return {k: v for k, v in enhanced_exif.items() if isinstance(k, int)}
144
469
 
145
470
 
146
471
  def get_tiff_dtype_count(value):
@@ -169,36 +494,93 @@ def get_tiff_dtype_count(value):
169
494
  return 4, 1 # uint32
170
495
  if isinstance(value, float):
171
496
  return 11, 1 # float64
172
- return 2, len(str(value)) + 1 # Default for othre cases (ASCII string)
497
+ return 2, len(str(value)) + 1 # Default for other cases (ASCII string)
173
498
 
174
499
 
175
500
  def add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose=False):
176
- logger = logging.getLogger(__name__)
177
501
  if exif is None:
178
502
  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]
190
- with Image.open(in_filename) as image:
191
- if hasattr(exif, 'tobytes'):
192
- exif_bytes = exif.tobytes()
193
- else:
194
- exif_bytes = exif
195
- image.save(out_filename, "JPEG", exif=exif_bytes, quality=100)
196
- if xmp_data and isinstance(xmp_data, bytes):
503
+ logger = logging.getLogger(__name__)
504
+ xmp_data = exif.get(XMLPACKET) if hasattr(exif, 'get') else None
505
+ if out_filename is None:
506
+ out_filename = in_filename
507
+ use_temp = in_filename == out_filename
508
+ if use_temp:
509
+ temp_filename = out_filename + ".tmp"
510
+ final_filename = temp_filename
511
+ else:
512
+ final_filename = out_filename
513
+ try:
514
+ with Image.open(in_filename) as image:
515
+ jpeg_exif = Image.Exif()
516
+ compatible_tags = [
517
+ MAKE, MODEL, SOFTWARE, DATETIME, ARTIST, COPYRIGHT,
518
+ EXPOSURETIME, FNUMBER, ISOSPEEDRATINGS, EXPOSUREPROGRAM,
519
+ SHUTTERSPEEDVALUE, APERTUREVALUE, BRIGHTNESSVALUE, EXPOSUREBIASVALUE,
520
+ MAXAPERTUREVALUE, SUBJECTDISTANCE, METERINGMODE, LIGHTSOURCE, FLASH,
521
+ FOCALLENGTH, EXPOSUREMODE, WHITEBALANCE, EXPOSUREINDEX,
522
+ SCENECAPTURETYPE, DATETIMEORIGINAL, LENSMODEL, LENSMAKE,
523
+ FOCALLENGTHIN35MMFILM, GAINCONTROL, CONTRAST, SATURATION, SHARPNESS,
524
+ CUSTOMRENDERED, DIGITALZOOMRATIO, SUBJECTDISTANCERANGE,
525
+ EXIFVERSION, FLASHPIXVERSION,
526
+ COLORSPACE, PIXELXDIMENSION, PIXELYDIMENSION, IMAGEWIDTH, IMAGELENGTH,
527
+ BITSPERSAMPLE, ORIENTATION, XRESOLUTION, YRESOLUTION, RESOLUTIONUNIT
528
+ ]
529
+ for tag_id in compatible_tags:
530
+ if tag_id in exif:
531
+ value = exif[tag_id]
532
+ try:
533
+ if tag_id in [EXIFVERSION, FLASHPIXVERSION]:
534
+ if isinstance(value, str):
535
+ jpeg_exif[tag_id] = value.encode('ascii')
536
+ else:
537
+ jpeg_exif[tag_id] = value
538
+ elif isinstance(value, tuple) and len(value) == 2:
539
+ value = IFDRational(value[0], value[1])
540
+ jpeg_exif[tag_id] = value
541
+ elif isinstance(value, (int, str, float, IFDRational)):
542
+ jpeg_exif[tag_id] = value
543
+ except Exception as e:
544
+ if verbose:
545
+ logger.warning(msg=f"Failed to add tag {tag_id}: {e}")
197
546
  try:
198
- _insert_xmp_into_jpeg(out_filename, xmp_data, verbose)
547
+ if hasattr(jpeg_exif, 'get_ifd'):
548
+ exif_ifd = jpeg_exif.get_ifd(EXIFIFD)
549
+ if exif_ifd is None:
550
+ exif_ifd = {}
551
+ tags_to_move = [
552
+ LENSMODEL, EXPOSURETIME, FNUMBER, ISOSPEEDRATINGS, FOCALLENGTH,
553
+ SHUTTERSPEEDVALUE, APERTUREVALUE, EXPOSUREBIASVALUE,
554
+ ]
555
+ for tag_id in tags_to_move:
556
+ if tag_id in exif:
557
+ exif_ifd[tag_id] = exif[tag_id]
558
+ if tag_id in jpeg_exif:
559
+ del jpeg_exif[tag_id]
199
560
  except Exception as e:
200
561
  if verbose:
201
- logger.warning(msg=f"Failed to insert XMP data: {e}")
562
+ logger.warning(msg=f"Failed to move tags to EXIF sub-IFD: {e}")
563
+ exif_bytes = jpeg_exif.tobytes()
564
+ image.save(final_filename, "JPEG", exif=exif_bytes, quality=100)
565
+ if xmp_data and isinstance(xmp_data, bytes):
566
+ _insert_xmp_into_jpeg(final_filename, xmp_data, verbose)
567
+ if use_temp:
568
+ if os.path.exists(out_filename):
569
+ os.remove(out_filename)
570
+ os.rename(temp_filename, out_filename)
571
+ except Exception as e:
572
+ traceback.print_tb(e.__traceback__)
573
+ if use_temp and os.path.exists(temp_filename):
574
+ try:
575
+ os.remove(temp_filename)
576
+ except Exception as ee:
577
+ traceback.print_tb(ee.__traceback__)
578
+ else:
579
+ try:
580
+ write_img(out_filename, read_img(in_filename))
581
+ except Exception as ee:
582
+ traceback.print_tb(ee.__traceback__)
583
+ raise
202
584
 
203
585
 
204
586
  def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
@@ -243,75 +625,41 @@ def create_xmp_from_exif(exif_data):
243
625
  xmp_elements = []
244
626
  if exif_data:
245
627
  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')
628
+ if isinstance(tag_id, int) and value and tag_id in XMP_TAG_MAP:
629
+ config = XMP_TAG_MAP[tag_id]
630
+ processed_value = config['processor'](value) if config['processor'] else value
631
+ if config['type'] == 'simple':
632
+ xmp_elements.append(
633
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
634
+ elif config['type'] == 'rdf_alt':
635
+ xmp_elements.append(
636
+ f'<{config["format"]}><rdf:Alt>'
637
+ f'<rdf:li xml:lang="x-default">{processed_value}</rdf:li>'
638
+ f'</rdf:Alt></{config["format"]}>')
639
+ elif config['type'] == 'rdf_seq':
251
640
  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')
641
+ f'<{config["format"]}><rdf:Seq>'
642
+ f'<rdf:li>{processed_value}</rdf:li>'
643
+ f'</rdf:Seq></{config["format"]}>')
644
+ elif config['type'] == 'datetime':
645
+ if ':' in processed_value:
646
+ processed_value = processed_value.replace(':', '-', 2).replace(' ', 'T')
258
647
  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')
648
+ f'<{config["format"]}>{processed_value}</{config["format"]}>')
649
+ elif config['type'] == 'rational':
650
+ float_value = float(value) \
651
+ if hasattr(value, 'numerator') \
652
+ else (float(value) if value else 0)
265
653
  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>')
654
+ f'<{config["format"]}>{float_value}</{config["format"]}>')
655
+ elif config['type'] == 'mapped':
656
+ mapped_value = config['map'].get(value, str(value))
657
+ xmp_elements.append(
658
+ f'<{config["format"]}>{mapped_value}</{config["format"]}>')
290
659
  if xmp_elements:
291
660
  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'?>"""
661
+ return XMP_TEMPLATE.format(content=xmp_content)
662
+ return XMP_EMPTY_TEMPLATE
315
663
 
316
664
 
317
665
  def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -321,83 +669,81 @@ def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, col
321
669
  logger.warning(msg="EXIF data not supported for 16-bit PNG format")
322
670
  write_img(out_filename, image)
323
671
  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')
672
+ pil_image = _convert_to_pil_image(image, color_order)
673
+ pnginfo, icc_profile = _prepare_png_metadata(exif)
674
+ save_args = {'format': 'PNG', 'pnginfo': pnginfo}
675
+ if icc_profile:
676
+ save_args['icc_profile'] = icc_profile
677
+ pil_image.save(out_filename, **save_args)
343
678
 
344
679
 
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
680
+ def _convert_to_pil_image(image, color_order):
681
+ if isinstance(image, np.ndarray) and len(image.shape) == 3 and image.shape[2] == 3:
682
+ if color_order in ['auto', 'bgr']:
683
+ image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
684
+ return Image.fromarray(image_rgb)
685
+ return Image.fromarray(image) if isinstance(image, np.ndarray) else image
355
686
 
356
687
 
357
- def _prepare_png_metadata(exif, verbose, logger):
688
+ def _prepare_png_metadata(exif):
358
689
  pnginfo = PngInfo()
359
690
  icc_profile = None
360
- xmp_data = _extract_xmp_data(exif, verbose, logger)
691
+ xmp_data = _extract_xmp_data(exif)
361
692
  if xmp_data:
362
693
  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)
694
+ _add_exif_tags_to_pnginfo(exif, pnginfo)
695
+ icc_profile = _extract_icc_profile(exif)
367
696
  return pnginfo, icc_profile
368
697
 
369
698
 
370
- def _extract_xmp_data(exif, verbose, logger):
699
+ def _extract_xmp_data(exif):
371
700
  for key, value in exif.items():
372
701
  if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
373
702
  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}")
703
+ return value.decode('utf-8', errors='ignore')
704
+ if isinstance(value, str):
384
705
  return value
385
- if verbose:
386
- logger.info("Generated new XMP data from EXIF")
387
706
  return create_xmp_from_exif(exif)
388
707
 
389
708
 
390
- def _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger):
709
+ def _add_exif_tags_to_pnginfo(exif, pnginfo):
391
710
  for tag_id, value in exif.items():
392
711
  if value is None:
393
712
  continue
394
713
  if isinstance(tag_id, int):
395
- _add_exif_tag(pnginfo, tag_id, value, verbose, logger)
714
+ if tag_id in CAMERA_TAGS_MAP:
715
+ _add_typed_tag(pnginfo, f"EXIF_{CAMERA_TAGS_MAP[tag_id]}", value)
716
+ elif tag_id in EXPOSURE_TAGS_MAP:
717
+ _add_typed_tag(pnginfo, f"EXIF_{EXPOSURE_TAGS_MAP[tag_id]}", value)
718
+ else:
719
+ _add_exif_tag(pnginfo, tag_id, value)
396
720
  elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
397
- _add_png_text_tag(pnginfo, tag_id, value, verbose, logger)
721
+ _add_png_text_tag(pnginfo, tag_id, value)
398
722
 
399
723
 
400
- def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
724
+ def _add_typed_tag(pnginfo, key, value):
725
+ try:
726
+ if hasattr(value, 'numerator'):
727
+ stored_value = f"RATIONAL:{value.numerator}/{value.denominator}"
728
+ elif isinstance(value, bytes):
729
+ try:
730
+ stored_value = f"STRING:{value.decode('utf-8', errors='replace')}"
731
+ except Exception:
732
+ stored_value = f"BYTES:{str(value)[:100]}"
733
+ elif isinstance(value, (list, tuple)):
734
+ stored_value = f"ARRAY:{','.join(str(x) for x in value)}"
735
+ elif isinstance(value, int):
736
+ stored_value = f"INT:{value}"
737
+ elif isinstance(value, float):
738
+ stored_value = f"FLOAT:{value}"
739
+ else:
740
+ stored_value = f"STRING:{str(value)}"
741
+ pnginfo.add_text(key, stored_value)
742
+ except Exception:
743
+ pass
744
+
745
+
746
+ def _add_exif_tag(pnginfo, tag_id, value):
401
747
  try:
402
748
  tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
403
749
  if isinstance(value, bytes) and len(value) > 1000:
@@ -410,17 +756,16 @@ def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
410
756
  pnginfo.add_text(tag_name, decoded_value)
411
757
  except Exception:
412
758
  pass
413
- elif hasattr(value, 'numerator'): # IFDRational
759
+ elif hasattr(value, 'numerator'):
414
760
  rational_str = f"{value.numerator}/{value.denominator}"
415
761
  pnginfo.add_text(tag_name, rational_str)
416
762
  else:
417
763
  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}")
764
+ except Exception:
765
+ pass
421
766
 
422
767
 
423
- def _add_png_text_tag(pnginfo, key, value, verbose, logger):
768
+ def _add_png_text_tag(pnginfo, key, value):
424
769
  try:
425
770
  clean_key = key[4:] if key.startswith('PNG_') else key
426
771
  if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
@@ -434,33 +779,98 @@ def _add_png_text_tag(pnginfo, key, value, verbose, logger):
434
779
  pnginfo.add_text(clean_key, truncated_value)
435
780
  else:
436
781
  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}")
782
+ except Exception:
783
+ pass
440
784
 
441
785
 
442
- def _extract_icc_profile(exif, verbose, logger):
786
+ def _extract_icc_profile(exif):
443
787
  for key, value in exif.items():
444
788
  if (isinstance(key, str) and
445
789
  isinstance(value, bytes) and
446
790
  ('icc' in key.lower() or 'profile' in key.lower())):
447
- if verbose:
448
- logger.info(f"Found ICC profile: {key}")
449
791
  return value
450
792
  return None
451
793
 
452
794
 
795
+ def clean_data_for_tiff(data):
796
+ if isinstance(data, str):
797
+ return data.encode('ascii', 'ignore').decode('ascii')
798
+ if isinstance(data, bytes):
799
+ decoded = data.decode('utf-8', 'ignore')
800
+ return decoded.encode('ascii', 'ignore').decode('ascii')
801
+ if isinstance(data, IFDRational):
802
+ return (data.numerator, data.denominator)
803
+ return data
804
+
805
+
453
806
  def write_image_with_exif_data_jpg(exif, image, out_filename, verbose):
454
- cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
807
+ save_img = (image // 256).astype(np.uint8) if image.dtype == np.uint16 else image
808
+ cv2.imwrite(out_filename, save_img, [cv2.IMWRITE_JPEG_QUALITY, 100])
455
809
  add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
456
810
 
457
811
 
812
+ def exif_extra_tags_for_tif(exif):
813
+ res_x, res_y = exif.get(XRESOLUTION), exif.get(YRESOLUTION)
814
+ resolution = (
815
+ (res_x.numerator, res_x.denominator),
816
+ (res_y.numerator, res_y.denominator)
817
+ ) if res_x and res_y else (
818
+ (720000, 10000), (720000, 10000)
819
+ )
820
+ exif_tags = {
821
+ 'resolution': resolution,
822
+ 'resolutionunit': exif.get(RESOLUTIONUNIT, 'inch'),
823
+ 'software': clean_data_for_tiff(exif.get(SOFTWARE)) or constants.APP_TITLE,
824
+ 'photometric': exif.get(PHOTOMETRICINTERPRETATION)
825
+ }
826
+ extra = []
827
+ for tag_id in exif:
828
+ tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
829
+ if tag in NO_COPY_TIFF_TAGS or tag_id in NO_COPY_TIFF_TAGS_ID or tag_id == SOFTWARE:
830
+ continue
831
+ if isinstance(data, IFDRational):
832
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
833
+ extra.append((tag_id, 5, 1, data, False))
834
+ continue
835
+ processed_data = _process_tiff_data(data)
836
+ if processed_data:
837
+ dtype, count, data_value = processed_data
838
+ extra.append((tag_id, dtype, count, data_value, False))
839
+ return extra, exif_tags
840
+
841
+
842
+ def _process_tiff_data(data):
843
+ if isinstance(data, IFDRational):
844
+ data = (data.numerator, data.denominator) if data.denominator != 0 else (0, 1)
845
+ return 5, 1, data
846
+ if hasattr(data, '__iter__') and not isinstance(data, (str, bytes)):
847
+ try:
848
+ clean_data = [float(x)
849
+ if not hasattr(x, 'denominator') or x.denominator != 0
850
+ else float('nan') for x in data]
851
+ return 12, len(clean_data), tuple(clean_data)
852
+ except Exception:
853
+ return None
854
+ if isinstance(data, (str, bytes)):
855
+ clean_data = clean_data_for_tiff(data)
856
+ if clean_data:
857
+ return 2, len(clean_data) + 1, clean_data
858
+ try:
859
+ dtype, count = get_tiff_dtype_count(data)
860
+ return dtype, count, data
861
+ except Exception:
862
+ return None
863
+
864
+
458
865
  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
866
  image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
462
- tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
463
- extratags=extra_tags, **exif_tags)
867
+ try:
868
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
869
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
870
+ tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
871
+ extratags=extra_tags, **exif_tags)
872
+ except Exception:
873
+ tifffile.imwrite(out_filename, image, compression='adobe_deflate')
464
874
 
465
875
 
466
876
  def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
@@ -485,21 +895,21 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
485
895
  raise RuntimeError('No exif data provided.')
486
896
  if verbose:
487
897
  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):
898
+ if extension_png(in_filename) or extension_tif(in_filename):
899
+ if extension_tif(in_filename):
900
+ image_new = tifffile.imread(in_filename)
901
+ elif extension_png(in_filename):
902
+ image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
903
+ if extension_tif(in_filename):
904
+ metadata = {"description": f"image generated with {constants.APP_STRING} package"}
905
+ extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
906
+ tifffile.imwrite(
907
+ out_filename, image_new, metadata=metadata, compression='adobe_deflate',
908
+ extratags=extra_tags, **exif_tags)
909
+ elif extension_png(in_filename):
910
+ write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
911
+ else:
495
912
  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
913
  return exif
504
914
 
505
915
 
@@ -512,20 +922,25 @@ def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, v
512
922
  return save_exif_data(exif, in_filename, out_filename, verbose)
513
923
 
514
924
 
515
- def exif_dict(exif):
516
- if exif is None:
925
+ def exif_dict(exif_data):
926
+ if exif_data is None:
517
927
  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
928
+ result = {}
929
+ for tag, value in exif_data.items():
930
+ if isinstance(tag, int):
931
+ tag_name = TAGS.get(tag, str(tag))
932
+ else:
933
+ tag_name = str(tag)
934
+ if tag_name.startswith('PNG_EXIF_'):
935
+ standard_tag = tag_name[9:]
936
+ elif tag_name.startswith('EXIF_'):
937
+ standard_tag = tag_name[5:]
938
+ elif tag_name.startswith('PNG_'):
939
+ continue
940
+ else:
941
+ standard_tag = tag_name
942
+ result[standard_tag] = (tag, value)
943
+ return result
529
944
 
530
945
 
531
946
  def print_exif(exif):
@@ -538,7 +953,7 @@ def print_exif(exif):
538
953
  data = f"{data.numerator}/{data.denominator}"
539
954
  data_str = f"{data}"
540
955
  if len(data_str) > 40:
541
- data_str = f"{data_str[:40]}..."
956
+ data_str = f"{data_str[:40]}... (truncated)"
542
957
  if isinstance(tag_id, int):
543
958
  tag_id_str = f"[#{tag_id:5d}]"
544
959
  else: