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.
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/align.py +202 -81
- shinestacker/algorithms/align_auto.py +13 -11
- shinestacker/algorithms/align_parallel.py +50 -21
- shinestacker/algorithms/balance.py +1 -1
- shinestacker/algorithms/base_stack_algo.py +1 -1
- shinestacker/algorithms/exif.py +848 -127
- shinestacker/algorithms/multilayer.py +6 -4
- shinestacker/algorithms/noise_detection.py +10 -8
- shinestacker/algorithms/pyramid_tiles.py +1 -1
- shinestacker/algorithms/stack.py +33 -17
- shinestacker/algorithms/stack_framework.py +16 -11
- shinestacker/algorithms/utils.py +18 -2
- shinestacker/algorithms/vignetting.py +16 -3
- shinestacker/app/main.py +1 -1
- shinestacker/app/settings_dialog.py +297 -173
- shinestacker/config/constants.py +10 -6
- shinestacker/config/settings.py +25 -7
- shinestacker/core/exceptions.py +1 -1
- shinestacker/core/framework.py +2 -2
- shinestacker/gui/action_config.py +23 -20
- shinestacker/gui/action_config_dialog.py +38 -25
- shinestacker/gui/config_dialog.py +6 -5
- shinestacker/gui/folder_file_selection.py +3 -2
- shinestacker/gui/gui_images.py +27 -3
- shinestacker/gui/gui_run.py +2 -2
- shinestacker/gui/main_window.py +6 -0
- shinestacker/gui/menu_manager.py +8 -2
- shinestacker/gui/new_project.py +23 -12
- shinestacker/gui/project_controller.py +14 -6
- shinestacker/gui/project_editor.py +12 -2
- shinestacker/gui/project_model.py +4 -4
- shinestacker/retouch/brush_tool.py +20 -0
- shinestacker/retouch/exif_data.py +106 -38
- shinestacker/retouch/file_loader.py +3 -3
- shinestacker/retouch/image_editor_ui.py +79 -3
- shinestacker/retouch/image_viewer.py +6 -1
- shinestacker/retouch/io_gui_handler.py +13 -16
- shinestacker/retouch/shortcuts_help.py +15 -8
- shinestacker/retouch/view_strategy.py +12 -2
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/METADATA +37 -39
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/RECORD +46 -46
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/WHEEL +0 -0
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/entry_points.txt +0 -0
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/top_level.txt +0 -0
shinestacker/algorithms/exif.py
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
xmp_start
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
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
|
|
291
|
+
return get_exif_from_tiff(image, exif_filename)
|
|
54
292
|
if extension_jpg(exif_filename):
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
208
|
-
if
|
|
925
|
+
def exif_dict(exif_data):
|
|
926
|
+
if exif_data is None:
|
|
209
927
|
return None
|
|
210
|
-
|
|
211
|
-
for
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
223
|
-
if
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
233
|
-
exif_data = exif_dict(exif
|
|
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
|
-
|
|
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}")
|