shinestacker 1.8.0__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.

Files changed (46) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +202 -81
  3. shinestacker/algorithms/align_auto.py +13 -11
  4. shinestacker/algorithms/align_parallel.py +50 -21
  5. shinestacker/algorithms/balance.py +1 -1
  6. shinestacker/algorithms/base_stack_algo.py +1 -1
  7. shinestacker/algorithms/exif.py +848 -127
  8. shinestacker/algorithms/multilayer.py +6 -4
  9. shinestacker/algorithms/noise_detection.py +10 -8
  10. shinestacker/algorithms/pyramid_tiles.py +1 -1
  11. shinestacker/algorithms/stack.py +33 -17
  12. shinestacker/algorithms/stack_framework.py +16 -11
  13. shinestacker/algorithms/utils.py +18 -2
  14. shinestacker/algorithms/vignetting.py +16 -3
  15. shinestacker/app/main.py +1 -1
  16. shinestacker/app/settings_dialog.py +297 -173
  17. shinestacker/config/constants.py +10 -6
  18. shinestacker/config/settings.py +25 -7
  19. shinestacker/core/exceptions.py +1 -1
  20. shinestacker/core/framework.py +2 -2
  21. shinestacker/gui/action_config.py +23 -20
  22. shinestacker/gui/action_config_dialog.py +38 -25
  23. shinestacker/gui/config_dialog.py +6 -5
  24. shinestacker/gui/folder_file_selection.py +3 -2
  25. shinestacker/gui/gui_images.py +27 -3
  26. shinestacker/gui/gui_run.py +2 -2
  27. shinestacker/gui/main_window.py +6 -0
  28. shinestacker/gui/menu_manager.py +8 -2
  29. shinestacker/gui/new_project.py +23 -12
  30. shinestacker/gui/project_controller.py +14 -6
  31. shinestacker/gui/project_editor.py +12 -2
  32. shinestacker/gui/project_model.py +4 -4
  33. shinestacker/retouch/brush_tool.py +20 -0
  34. shinestacker/retouch/exif_data.py +106 -38
  35. shinestacker/retouch/file_loader.py +3 -3
  36. shinestacker/retouch/image_editor_ui.py +79 -3
  37. shinestacker/retouch/image_viewer.py +6 -1
  38. shinestacker/retouch/io_gui_handler.py +13 -16
  39. shinestacker/retouch/shortcuts_help.py +15 -8
  40. shinestacker/retouch/view_strategy.py +12 -2
  41. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/METADATA +37 -39
  42. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/RECORD +46 -46
  43. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/WHEEL +0 -0
  44. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/entry_points.txt +0 -0
  45. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/licenses/LICENSE +0 -0
  46. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/top_level.txt +0 -0
@@ -1,99 +1,471 @@
1
- # pylint: disable=C0114, C0116, W0718, R0911, R0912, E1101
1
+ # pylint: disable=C0114, C0116, W0718, R0911, R0912, E1101, R0915, R1702, R0914, R0917, R0913
2
2
  import os
3
3
  import re
4
- import io
5
4
  import logging
5
+ import traceback
6
6
  import cv2
7
7
  import numpy as np
8
8
  from PIL import Image
9
9
  from PIL.TiffImagePlugin import IFDRational
10
+ 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
- size = len(foot.decode('ascii'))
39
- xmp_start, xmp_end = data.find(head), data.find(foot)
40
- if xmp_start != -1 and xmp_end != -1:
41
- return re.sub(
42
- b'[^\x20-\x7E]', b'',
43
- data[xmp_start:xmp_end + size]
44
- ).decode().replace('\x00', '').encode()
45
- return None
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:
281
+ return None
282
+ xmp_end += len(foot)
283
+ return data[xmp_start:xmp_end]
46
284
 
47
285
 
48
- def get_exif(exif_filename):
286
+ def get_exif(exif_filename, enhanced_png_parsing=True):
49
287
  if not os.path.isfile(exif_filename):
50
288
  raise RuntimeError(f"File does not exist: {exif_filename}")
51
289
  image = Image.open(exif_filename)
52
290
  if extension_tif(exif_filename):
53
- return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
291
+ return get_exif_from_tiff(image, exif_filename)
54
292
  if extension_jpg(exif_filename):
55
- exif_data = image.getexif()
56
- with open(exif_filename, 'rb') as f:
57
- data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
58
- if data is not None:
59
- exif_data[XMLPACKET] = data
60
- return exif_data
293
+ return get_exif_from_jpg(image, exif_filename)
294
+ if extension_png(exif_filename):
295
+ if enhanced_png_parsing:
296
+ return get_enhanced_exif_from_png(image)
297
+ exif_data = get_exif_from_png(image)
298
+ return exif_data if exif_data else image.getexif()
61
299
  return image.getexif()
62
300
 
63
301
 
64
- def exif_extra_tags_for_tif(exif):
65
- logger = logging.getLogger(__name__)
66
- res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
67
- if not (res_x is None or res_y is None):
68
- resolution = ((res_x.numerator, res_x.denominator), (res_y.numerator, res_y.denominator))
69
- else:
70
- resolution = ((720000, 10000), (720000, 10000))
71
- res_u = exif.get(RESOLUTIONUNIT)
72
- resolutionunit = res_u if res_u is not None else 'inch'
73
- sw = exif.get(SOFTWARE)
74
- software = sw if sw is not None else "N/A"
75
- phint = exif.get(PHOTOMETRICINTERPRETATION)
76
- photometric = phint if phint is not None else None
77
- extra = []
78
- for tag_id in exif:
79
- tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
80
- if isinstance(data, bytes):
302
+ def get_exif_from_tiff(image, exif_filename):
303
+ exif_data = image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
304
+ try:
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}")
318
+ try:
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
343
+ except Exception:
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
351
+ return exif_data
352
+
353
+
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('/')
81
396
  try:
82
- if tag_id not in (IMAGERESOURCES, INTERCOLORPROFILE):
83
- if tag_id == XMLPACKET:
84
- data = re.sub(b'[^\x20-\x7E]', b'', data)
85
- data = data.decode()
86
- except Exception:
87
- logger.warning(msg=f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
88
- data = '<<< decode error >>>'
89
- if isinstance(data, IFDRational):
90
- data = (data.numerator, data.denominator)
91
- if tag not in NO_COPY_TIFF_TAGS and tag_id not in NO_COPY_TIFF_TAGS_ID:
92
- extra.append((tag_id, *get_tiff_dtype_count(data), data, False))
93
- else:
94
- logger.debug(msg=f"Skip tag {tag:25} [#{tag_id}]")
95
- return extra, {'resolution': resolution, 'resolutionunit': resolutionunit,
96
- 'software': software, 'photometric': photometric}
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]
409
+ try:
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
418
+
419
+
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:'):
430
+ try:
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)}
97
469
 
98
470
 
99
471
  def get_tiff_dtype_count(value):
@@ -122,53 +494,397 @@ def get_tiff_dtype_count(value):
122
494
  return 4, 1 # uint32
123
495
  if isinstance(value, float):
124
496
  return 11, 1 # float64
125
- 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)
126
498
 
127
499
 
128
- def add_exif_data_to_jpg_file(exif, in_filenama, out_filename, verbose=False):
129
- logger = logging.getLogger(__name__)
500
+ def add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose=False):
130
501
  if exif is None:
131
502
  raise RuntimeError('No exif data provided.')
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}")
546
+ try:
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]
560
+ except Exception as e:
561
+ if verbose:
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
584
+
585
+
586
+ def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
587
+ logger = logging.getLogger(__name__)
588
+ with open(jpeg_path, 'rb') as f:
589
+ jpeg_data = f.read()
590
+ soi_pos = jpeg_data.find(b'\xFF\xD8')
591
+ if soi_pos == -1:
592
+ if verbose:
593
+ logger.warning("No SOI marker found, cannot insert XMP")
594
+ return
595
+ insert_pos = soi_pos + 2
596
+ current_pos = insert_pos
597
+ while current_pos < len(jpeg_data) - 4:
598
+ if jpeg_data[current_pos] != 0xFF:
599
+ break
600
+ marker = jpeg_data[current_pos + 1]
601
+ if marker == 0xDA:
602
+ break
603
+ segment_length = int.from_bytes(jpeg_data[current_pos + 2:current_pos + 4], 'big')
604
+ if marker == 0xE1:
605
+ insert_pos = current_pos + 2 + segment_length
606
+ current_pos = insert_pos
607
+ continue
608
+ current_pos += 2 + segment_length
609
+ xmp_identifier = b'http://ns.adobe.com/xap/1.0/\x00'
610
+ xmp_payload = xmp_identifier + xmp_data
611
+ segment_length = len(xmp_payload) + 2
612
+ xmp_segment = b'\xFF\xE1' + segment_length.to_bytes(2, 'big') + xmp_payload
613
+ updated_data = (
614
+ jpeg_data[:insert_pos] +
615
+ xmp_segment +
616
+ jpeg_data[insert_pos:]
617
+ )
618
+ with open(jpeg_path, 'wb') as f:
619
+ f.write(updated_data)
132
620
  if verbose:
133
- print_exif(exif)
134
- xmp_data = extract_enclosed_data_for_jpg(exif[XMLPACKET], b'<x:xmpmeta', b'</x:xmpmeta>')
135
- with Image.open(in_filenama) as image:
136
- with io.BytesIO() as buffer:
137
- image.save(buffer, format="JPEG", exif=exif.tobytes(), quality=100)
138
- jpeg_data = buffer.getvalue()
139
- if xmp_data is not None:
140
- app1_marker_pos = jpeg_data.find(b'\xFF\xE1')
141
- if app1_marker_pos == -1:
142
- app1_marker_pos = len(jpeg_data) - 2
143
- updated_data = (
144
- jpeg_data[:app1_marker_pos] +
145
- b'\xFF\xE1' + len(xmp_data).to_bytes(2, 'big') +
146
- xmp_data + jpeg_data[app1_marker_pos:]
147
- )
621
+ logger.info("Successfully inserted XMP data into JPEG")
622
+
623
+
624
+ def create_xmp_from_exif(exif_data):
625
+ xmp_elements = []
626
+ if exif_data:
627
+ for tag_id, value in exif_data.items():
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':
640
+ xmp_elements.append(
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')
647
+ xmp_elements.append(
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)
653
+ xmp_elements.append(
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"]}>')
659
+ if xmp_elements:
660
+ xmp_content = '\n '.join(xmp_elements)
661
+ return XMP_TEMPLATE.format(content=xmp_content)
662
+ return XMP_EMPTY_TEMPLATE
663
+
664
+
665
+ def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
666
+ logger = logging.getLogger(__name__)
667
+ if isinstance(image, np.ndarray) and image.dtype == np.uint16:
668
+ if verbose:
669
+ logger.warning(msg="EXIF data not supported for 16-bit PNG format")
670
+ write_img(out_filename, image)
671
+ return
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)
678
+
679
+
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
686
+
687
+
688
+ def _prepare_png_metadata(exif):
689
+ pnginfo = PngInfo()
690
+ icc_profile = None
691
+ xmp_data = _extract_xmp_data(exif)
692
+ if xmp_data:
693
+ pnginfo.add_text("XML:com.adobe.xmp", xmp_data)
694
+ _add_exif_tags_to_pnginfo(exif, pnginfo)
695
+ icc_profile = _extract_icc_profile(exif)
696
+ return pnginfo, icc_profile
697
+
698
+
699
+ def _extract_xmp_data(exif):
700
+ for key, value in exif.items():
701
+ if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
702
+ if isinstance(value, bytes):
703
+ return value.decode('utf-8', errors='ignore')
704
+ if isinstance(value, str):
705
+ return value
706
+ return create_xmp_from_exif(exif)
707
+
708
+
709
+ def _add_exif_tags_to_pnginfo(exif, pnginfo):
710
+ for tag_id, value in exif.items():
711
+ if value is None:
712
+ continue
713
+ if isinstance(tag_id, int):
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)
148
718
  else:
149
- logger.warning("Copy: can't find XMLPacket in JPG EXIF data")
150
- updated_data = jpeg_data
151
- with open(out_filename, 'wb') as f:
152
- f.write(updated_data)
153
- return exif
719
+ _add_exif_tag(pnginfo, tag_id, value)
720
+ elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
721
+ _add_png_text_tag(pnginfo, tag_id, value)
722
+
723
+
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):
747
+ try:
748
+ tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
749
+ if isinstance(value, bytes) and len(value) > 1000:
750
+ return
751
+ if isinstance(value, (int, float, str)):
752
+ pnginfo.add_text(tag_name, str(value))
753
+ elif isinstance(value, bytes):
754
+ try:
755
+ decoded_value = value.decode('utf-8', errors='replace')
756
+ pnginfo.add_text(tag_name, decoded_value)
757
+ except Exception:
758
+ pass
759
+ elif hasattr(value, 'numerator'):
760
+ rational_str = f"{value.numerator}/{value.denominator}"
761
+ pnginfo.add_text(tag_name, rational_str)
762
+ else:
763
+ pnginfo.add_text(tag_name, str(value))
764
+ except Exception:
765
+ pass
766
+
767
+
768
+ def _add_png_text_tag(pnginfo, key, value):
769
+ try:
770
+ clean_key = key[4:] if key.startswith('PNG_') else key
771
+ if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
772
+ return
773
+ if isinstance(value, bytes):
774
+ try:
775
+ decoded_value = value.decode('utf-8', errors='replace')
776
+ pnginfo.add_text(clean_key, decoded_value)
777
+ except Exception:
778
+ truncated_value = str(value)[:100] + "..."
779
+ pnginfo.add_text(clean_key, truncated_value)
780
+ else:
781
+ pnginfo.add_text(clean_key, str(value))
782
+ except Exception:
783
+ pass
784
+
785
+
786
+ def _extract_icc_profile(exif):
787
+ for key, value in exif.items():
788
+ if (isinstance(key, str) and
789
+ isinstance(value, bytes) and
790
+ ('icc' in key.lower() or 'profile' in key.lower())):
791
+ return value
792
+ return None
793
+
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
154
804
 
155
805
 
156
- def write_image_with_exif_data(exif, image, out_filename, verbose=False):
806
+ def write_image_with_exif_data_jpg(exif, image, out_filename, verbose):
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])
809
+ add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
810
+
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
+
865
+ def write_image_with_exif_data_tif(exif, image, out_filename):
866
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
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')
874
+
875
+
876
+ def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
157
877
  if exif is None:
158
878
  write_img(out_filename, image)
159
879
  return None
160
880
  if verbose:
161
881
  print_exif(exif)
162
882
  if extension_jpg(out_filename):
163
- cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
164
- add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
883
+ write_image_with_exif_data_jpg(exif, image, out_filename, verbose)
165
884
  elif extension_tif(out_filename):
166
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
167
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
168
- tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
169
- extratags=extra_tags, **exif_tags)
885
+ write_image_with_exif_data_tif(exif, image, out_filename)
170
886
  elif extension_png(out_filename):
171
- image.save(out_filename, 'PNG', exif=exif, quality=100)
887
+ write_image_with_exif_data_png(exif, image, out_filename, verbose, color_order=color_order)
172
888
  return exif
173
889
 
174
890
 
@@ -179,19 +895,21 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
179
895
  raise RuntimeError('No exif data provided.')
180
896
  if verbose:
181
897
  print_exif(exif)
182
- if extension_tif(in_filename):
183
- image_new = tifffile.imread(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)
184
911
  else:
185
- image_new = Image.open(in_filename)
186
- if extension_jpg(in_filename):
187
912
  add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
188
- elif extension_tif(in_filename):
189
- metadata = {"description": f"image generated with {constants.APP_STRING} package"}
190
- extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
191
- tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
192
- extratags=extra_tags, **exif_tags)
193
- elif extension_png(in_filename):
194
- image_new.save(out_filename, 'PNG', exif=exif, quality=100)
195
913
  return exif
196
914
 
197
915
 
@@ -204,37 +922,40 @@ def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, v
204
922
  return save_exif_data(exif, in_filename, out_filename, verbose)
205
923
 
206
924
 
207
- def exif_dict(exif, hide_xml=True):
208
- if exif is None:
925
+ def exif_dict(exif_data):
926
+ if exif_data is None:
209
927
  return None
210
- exif_data = {}
211
- for tag_id in exif:
212
- tag = TAGS.get(tag_id, tag_id)
213
- if tag_id == XMLPACKET and hide_xml:
214
- data = "<<< XML data >>>"
215
- elif tag_id in (IMAGERESOURCES, INTERCOLORPROFILE):
216
- data = "<<< Photoshop data >>>"
217
- elif tag_id == STRIPOFFSETS:
218
- data = "<<< Strip offsets >>>"
219
- elif tag_id == STRIPBYTECOUNTS:
220
- data = "<<< Strip byte counts >>>"
928
+ result = {}
929
+ for tag, value in exif_data.items():
930
+ if isinstance(tag, int):
931
+ tag_name = TAGS.get(tag, str(tag))
221
932
  else:
222
- data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
223
- if isinstance(data, bytes):
224
- try:
225
- data = data.decode()
226
- except Exception:
227
- pass
228
- exif_data[tag] = (tag_id, data)
229
- return exif_data
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
230
944
 
231
945
 
232
- def print_exif(exif, hide_xml=True):
233
- exif_data = exif_dict(exif, hide_xml)
946
+ def print_exif(exif):
947
+ exif_data = exif_dict(exif)
234
948
  if exif_data is None:
235
949
  raise RuntimeError('Image has no exif data.')
236
950
  logger = logging.getLogger(__name__)
237
951
  for tag, (tag_id, data) in exif_data.items():
238
952
  if isinstance(data, IFDRational):
239
953
  data = f"{data.numerator}/{data.denominator}"
240
- logger.info(msg=f"{tag:25} [#{tag_id:5d}]: {data}")
954
+ data_str = f"{data}"
955
+ if len(data_str) > 40:
956
+ data_str = f"{data_str[:40]}... (truncated)"
957
+ if isinstance(tag_id, int):
958
+ tag_id_str = f"[#{tag_id:5d}]"
959
+ else:
960
+ tag_id_str = f"[ {tag_id:20} ]"
961
+ logger.info(msg=f"{tag:25} {tag_id_str}: {data_str}")