shinestacker 1.8.1__py3-none-any.whl → 1.9.1__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/exif.py +364 -58
- shinestacker/algorithms/multilayer.py +6 -4
- shinestacker/algorithms/stack.py +26 -14
- shinestacker/algorithms/stack_framework.py +2 -2
- shinestacker/algorithms/utils.py +18 -2
- shinestacker/algorithms/vignetting.py +1 -1
- shinestacker/app/main.py +1 -1
- shinestacker/config/constants.py +0 -1
- shinestacker/gui/action_config_dialog.py +2 -5
- shinestacker/gui/config_dialog.py +6 -5
- shinestacker/gui/folder_file_selection.py +3 -2
- shinestacker/gui/gui_run.py +2 -2
- shinestacker/gui/new_project.py +5 -5
- shinestacker/retouch/exif_data.py +57 -34
- shinestacker/retouch/file_loader.py +3 -3
- shinestacker/retouch/image_editor_ui.py +45 -2
- shinestacker/retouch/io_gui_handler.py +13 -15
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/METADATA +3 -1
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/RECORD +24 -24
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/WHEEL +0 -0
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/entry_points.txt +0 -0
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-1.8.1.dist-info → shinestacker-1.9.1.dist-info}/top_level.txt +0 -0
shinestacker/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '1.
|
|
1
|
+
__version__ = '1.9.1'
|
shinestacker/algorithms/exif.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
|
-
import re
|
|
4
|
-
import io
|
|
5
3
|
import logging
|
|
4
|
+
import traceback
|
|
6
5
|
import cv2
|
|
7
6
|
import numpy as np
|
|
8
7
|
from PIL import Image
|
|
9
8
|
from PIL.TiffImagePlugin import IFDRational
|
|
9
|
+
from PIL.PngImagePlugin import PngInfo
|
|
10
10
|
from PIL.ExifTags import TAGS
|
|
11
11
|
import tifffile
|
|
12
12
|
from .. config.constants import constants
|
|
@@ -35,14 +35,17 @@ NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCo
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def extract_enclosed_data_for_jpg(data, head, foot):
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
48
|
+
return None
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def get_exif(exif_filename):
|
|
@@ -58,9 +61,48 @@ def get_exif(exif_filename):
|
|
|
58
61
|
if data is not None:
|
|
59
62
|
exif_data[XMLPACKET] = data
|
|
60
63
|
return exif_data
|
|
64
|
+
if extension_png(exif_filename):
|
|
65
|
+
exif_data = get_exif_from_png(image)
|
|
66
|
+
return exif_data if exif_data else image.getexif()
|
|
61
67
|
return image.getexif()
|
|
62
68
|
|
|
63
69
|
|
|
70
|
+
def get_exif_from_png(image):
|
|
71
|
+
exif_data = {}
|
|
72
|
+
try:
|
|
73
|
+
exif_from_image = image.getexif()
|
|
74
|
+
if exif_from_image:
|
|
75
|
+
exif_data.update(dict(exif_from_image))
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
try:
|
|
79
|
+
if hasattr(image, 'text') and image.text:
|
|
80
|
+
for key, value in image.text.items():
|
|
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
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return exif_data
|
|
89
|
+
|
|
90
|
+
|
|
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:
|
|
96
|
+
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 >>>"
|
|
104
|
+
|
|
105
|
+
|
|
64
106
|
def exif_extra_tags_for_tif(exif):
|
|
65
107
|
logger = logging.getLogger(__name__)
|
|
66
108
|
res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
|
|
@@ -81,8 +123,13 @@ def exif_extra_tags_for_tif(exif):
|
|
|
81
123
|
try:
|
|
82
124
|
if tag_id not in (IMAGERESOURCES, INTERCOLORPROFILE):
|
|
83
125
|
if tag_id == XMLPACKET:
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
|
86
133
|
except Exception:
|
|
87
134
|
logger.warning(msg=f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
|
|
88
135
|
data = '<<< decode error >>>'
|
|
@@ -125,50 +172,309 @@ def get_tiff_dtype_count(value):
|
|
|
125
172
|
return 2, len(str(value)) + 1 # Default for othre cases (ASCII string)
|
|
126
173
|
|
|
127
174
|
|
|
128
|
-
def add_exif_data_to_jpg_file(exif,
|
|
175
|
+
def add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose=False):
|
|
129
176
|
logger = logging.getLogger(__name__)
|
|
130
177
|
if exif is None:
|
|
131
178
|
raise RuntimeError('No exif data provided.')
|
|
132
179
|
if verbose:
|
|
133
180
|
print_exif(exif)
|
|
134
|
-
xmp_data =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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):
|
|
197
|
+
try:
|
|
198
|
+
_insert_xmp_into_jpeg(out_filename, xmp_data, verbose)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
if verbose:
|
|
201
|
+
logger.warning(msg=f"Failed to insert XMP data: {e}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _insert_xmp_into_jpeg(jpeg_path, xmp_data, verbose=False):
|
|
205
|
+
logger = logging.getLogger(__name__)
|
|
206
|
+
with open(jpeg_path, 'rb') as f:
|
|
207
|
+
jpeg_data = f.read()
|
|
208
|
+
soi_pos = jpeg_data.find(b'\xFF\xD8')
|
|
209
|
+
if soi_pos == -1:
|
|
210
|
+
if verbose:
|
|
211
|
+
logger.warning("No SOI marker found, cannot insert XMP")
|
|
212
|
+
return
|
|
213
|
+
insert_pos = soi_pos + 2
|
|
214
|
+
current_pos = insert_pos
|
|
215
|
+
while current_pos < len(jpeg_data) - 4:
|
|
216
|
+
if jpeg_data[current_pos] != 0xFF:
|
|
217
|
+
break
|
|
218
|
+
marker = jpeg_data[current_pos + 1]
|
|
219
|
+
if marker == 0xDA:
|
|
220
|
+
break
|
|
221
|
+
segment_length = int.from_bytes(jpeg_data[current_pos + 2:current_pos + 4], 'big')
|
|
222
|
+
if marker == 0xE1:
|
|
223
|
+
insert_pos = current_pos + 2 + segment_length
|
|
224
|
+
current_pos = insert_pos
|
|
225
|
+
continue
|
|
226
|
+
current_pos += 2 + segment_length
|
|
227
|
+
xmp_identifier = b'http://ns.adobe.com/xap/1.0/\x00'
|
|
228
|
+
xmp_payload = xmp_identifier + xmp_data
|
|
229
|
+
segment_length = len(xmp_payload) + 2
|
|
230
|
+
xmp_segment = b'\xFF\xE1' + segment_length.to_bytes(2, 'big') + xmp_payload
|
|
231
|
+
updated_data = (
|
|
232
|
+
jpeg_data[:insert_pos] +
|
|
233
|
+
xmp_segment +
|
|
234
|
+
jpeg_data[insert_pos:]
|
|
235
|
+
)
|
|
236
|
+
with open(jpeg_path, 'wb') as f:
|
|
237
|
+
f.write(updated_data)
|
|
238
|
+
if verbose:
|
|
239
|
+
logger.info("Successfully inserted XMP data into JPEG")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def create_xmp_from_exif(exif_data):
|
|
243
|
+
xmp_elements = []
|
|
244
|
+
if exif_data:
|
|
245
|
+
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')
|
|
251
|
+
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')
|
|
258
|
+
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')
|
|
265
|
+
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>')
|
|
290
|
+
if xmp_elements:
|
|
291
|
+
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'?>"""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
|
|
318
|
+
logger = logging.getLogger(__name__)
|
|
319
|
+
if isinstance(image, np.ndarray) and image.dtype == np.uint16:
|
|
320
|
+
if verbose:
|
|
321
|
+
logger.warning(msg="EXIF data not supported for 16-bit PNG format")
|
|
322
|
+
write_img(out_filename, image)
|
|
323
|
+
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')
|
|
343
|
+
|
|
344
|
+
|
|
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
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _prepare_png_metadata(exif, verbose, logger):
|
|
358
|
+
pnginfo = PngInfo()
|
|
359
|
+
icc_profile = None
|
|
360
|
+
xmp_data = _extract_xmp_data(exif, verbose, logger)
|
|
361
|
+
if xmp_data:
|
|
362
|
+
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)
|
|
367
|
+
return pnginfo, icc_profile
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _extract_xmp_data(exif, verbose, logger):
|
|
371
|
+
for key, value in exif.items():
|
|
372
|
+
if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
|
|
373
|
+
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}")
|
|
384
|
+
return value
|
|
385
|
+
if verbose:
|
|
386
|
+
logger.info("Generated new XMP data from EXIF")
|
|
387
|
+
return create_xmp_from_exif(exif)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger):
|
|
391
|
+
for tag_id, value in exif.items():
|
|
392
|
+
if value is None:
|
|
393
|
+
continue
|
|
394
|
+
if isinstance(tag_id, int):
|
|
395
|
+
_add_exif_tag(pnginfo, tag_id, value, verbose, logger)
|
|
396
|
+
elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
|
|
397
|
+
_add_png_text_tag(pnginfo, tag_id, value, verbose, logger)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
|
|
401
|
+
try:
|
|
402
|
+
tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
|
|
403
|
+
if isinstance(value, bytes) and len(value) > 1000:
|
|
404
|
+
return
|
|
405
|
+
if isinstance(value, (int, float, str)):
|
|
406
|
+
pnginfo.add_text(tag_name, str(value))
|
|
407
|
+
elif isinstance(value, bytes):
|
|
408
|
+
try:
|
|
409
|
+
decoded_value = value.decode('utf-8', errors='replace')
|
|
410
|
+
pnginfo.add_text(tag_name, decoded_value)
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
elif hasattr(value, 'numerator'): # IFDRational
|
|
414
|
+
rational_str = f"{value.numerator}/{value.denominator}"
|
|
415
|
+
pnginfo.add_text(tag_name, rational_str)
|
|
416
|
+
else:
|
|
417
|
+
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}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _add_png_text_tag(pnginfo, key, value, verbose, logger):
|
|
424
|
+
try:
|
|
425
|
+
clean_key = key[4:] if key.startswith('PNG_') else key
|
|
426
|
+
if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
|
|
427
|
+
return
|
|
428
|
+
if isinstance(value, bytes):
|
|
429
|
+
try:
|
|
430
|
+
decoded_value = value.decode('utf-8', errors='replace')
|
|
431
|
+
pnginfo.add_text(clean_key, decoded_value)
|
|
432
|
+
except Exception:
|
|
433
|
+
truncated_value = str(value)[:100] + "..."
|
|
434
|
+
pnginfo.add_text(clean_key, truncated_value)
|
|
435
|
+
else:
|
|
436
|
+
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}")
|
|
154
440
|
|
|
155
441
|
|
|
156
|
-
def
|
|
442
|
+
def _extract_icc_profile(exif, verbose, logger):
|
|
443
|
+
for key, value in exif.items():
|
|
444
|
+
if (isinstance(key, str) and
|
|
445
|
+
isinstance(value, bytes) and
|
|
446
|
+
('icc' in key.lower() or 'profile' in key.lower())):
|
|
447
|
+
if verbose:
|
|
448
|
+
logger.info(f"Found ICC profile: {key}")
|
|
449
|
+
return value
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def write_image_with_exif_data_jpg(exif, image, out_filename, verbose):
|
|
454
|
+
cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|
455
|
+
add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
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
|
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
462
|
+
tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
|
|
463
|
+
extratags=extra_tags, **exif_tags)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
|
|
157
467
|
if exif is None:
|
|
158
468
|
write_img(out_filename, image)
|
|
159
469
|
return None
|
|
160
470
|
if verbose:
|
|
161
471
|
print_exif(exif)
|
|
162
472
|
if extension_jpg(out_filename):
|
|
163
|
-
|
|
164
|
-
add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
|
|
473
|
+
write_image_with_exif_data_jpg(exif, image, out_filename, verbose)
|
|
165
474
|
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)
|
|
475
|
+
write_image_with_exif_data_tif(exif, image, out_filename)
|
|
170
476
|
elif extension_png(out_filename):
|
|
171
|
-
|
|
477
|
+
write_image_with_exif_data_png(exif, image, out_filename, verbose, color_order=color_order)
|
|
172
478
|
return exif
|
|
173
479
|
|
|
174
480
|
|
|
@@ -181,8 +487,10 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
|
|
|
181
487
|
print_exif(exif)
|
|
182
488
|
if extension_tif(in_filename):
|
|
183
489
|
image_new = tifffile.imread(in_filename)
|
|
184
|
-
|
|
490
|
+
elif extension_jpg(in_filename):
|
|
185
491
|
image_new = Image.open(in_filename)
|
|
492
|
+
elif extension_png(in_filename):
|
|
493
|
+
image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
|
|
186
494
|
if extension_jpg(in_filename):
|
|
187
495
|
add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
|
|
188
496
|
elif extension_tif(in_filename):
|
|
@@ -191,7 +499,7 @@ def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
|
|
|
191
499
|
tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
|
|
192
500
|
extratags=extra_tags, **exif_tags)
|
|
193
501
|
elif extension_png(in_filename):
|
|
194
|
-
|
|
502
|
+
write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
|
|
195
503
|
return exif
|
|
196
504
|
|
|
197
505
|
|
|
@@ -204,22 +512,13 @@ def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, v
|
|
|
204
512
|
return save_exif_data(exif, in_filename, out_filename, verbose)
|
|
205
513
|
|
|
206
514
|
|
|
207
|
-
def exif_dict(exif
|
|
515
|
+
def exif_dict(exif):
|
|
208
516
|
if exif is None:
|
|
209
517
|
return None
|
|
210
518
|
exif_data = {}
|
|
211
519
|
for tag_id in exif:
|
|
212
520
|
tag = TAGS.get(tag_id, tag_id)
|
|
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 >>>"
|
|
221
|
-
else:
|
|
222
|
-
data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
|
|
521
|
+
data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
|
|
223
522
|
if isinstance(data, bytes):
|
|
224
523
|
try:
|
|
225
524
|
data = data.decode()
|
|
@@ -229,12 +528,19 @@ def exif_dict(exif, hide_xml=True):
|
|
|
229
528
|
return exif_data
|
|
230
529
|
|
|
231
530
|
|
|
232
|
-
def print_exif(exif
|
|
233
|
-
exif_data = exif_dict(exif
|
|
531
|
+
def print_exif(exif):
|
|
532
|
+
exif_data = exif_dict(exif)
|
|
234
533
|
if exif_data is None:
|
|
235
534
|
raise RuntimeError('Image has no exif data.')
|
|
236
535
|
logger = logging.getLogger(__name__)
|
|
237
536
|
for tag, (tag_id, data) in exif_data.items():
|
|
238
537
|
if isinstance(data, IFDRational):
|
|
239
538
|
data = f"{data.numerator}/{data.denominator}"
|
|
240
|
-
|
|
539
|
+
data_str = f"{data}"
|
|
540
|
+
if len(data_str) > 40:
|
|
541
|
+
data_str = f"{data_str[:40]}..."
|
|
542
|
+
if isinstance(tag_id, int):
|
|
543
|
+
tag_id_str = f"[#{tag_id:5d}]"
|
|
544
|
+
else:
|
|
545
|
+
tag_id_str = f"[ {tag_id:20} ]"
|
|
546
|
+
logger.info(msg=f"{tag:25} {tag_id_str}: {data_str}")
|
|
@@ -13,7 +13,7 @@ from .. config.constants import constants
|
|
|
13
13
|
from .. config.config import config
|
|
14
14
|
from .. core.colors import color_str
|
|
15
15
|
from .. core.framework import TaskBase
|
|
16
|
-
from .utils import EXTENSIONS_TIF, EXTENSIONS_JPG, EXTENSIONS_PNG
|
|
16
|
+
from .utils import EXTENSIONS_TIF, EXTENSIONS_JPG, EXTENSIONS_PNG, EXTENSIONS_SUPPORTED
|
|
17
17
|
from .stack_framework import ImageSequenceManager
|
|
18
18
|
from .exif import exif_extra_tags_for_tif, get_exif
|
|
19
19
|
|
|
@@ -142,14 +142,16 @@ def write_multilayer_tiff_from_images(image_dict, output_file, exif_path='', cal
|
|
|
142
142
|
elif os.path.isdir(exif_path):
|
|
143
143
|
_dirpath, _, fnames = next(os.walk(exif_path))
|
|
144
144
|
fnames = [name for name in fnames
|
|
145
|
-
if os.path.splitext(name)[-1][1:].lower() in
|
|
146
|
-
|
|
145
|
+
if os.path.splitext(name)[-1][1:].lower() in EXTENSIONS_SUPPORTED]
|
|
146
|
+
file_path = os.path.join(exif_path, fnames[0])
|
|
147
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(get_exif(file_path))
|
|
148
|
+
extra_tags = [tag for tag in extra_tags if isinstance(tag[0], int)]
|
|
147
149
|
tiff_tags['extratags'] += extra_tags
|
|
148
150
|
tiff_tags = {**tiff_tags, **exif_tags}
|
|
149
151
|
if callbacks:
|
|
150
152
|
callback = callbacks.get('write_msg', None)
|
|
151
153
|
if callback:
|
|
152
|
-
callback(
|
|
154
|
+
callback(os.path.basename(output_file))
|
|
153
155
|
compression = 'adobe_deflate'
|
|
154
156
|
overlayed_images = overlay(
|
|
155
157
|
*((np.concatenate((image, np.expand_dims(transp, axis=-1)),
|
shinestacker/algorithms/stack.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
# pylint: disable=C0114, C0115, C0116, R0913, R0917
|
|
1
|
+
# pylint: disable=C0114, C0115, C0116, R0913, R0917, W0718
|
|
2
2
|
import os
|
|
3
3
|
import traceback
|
|
4
|
+
import logging
|
|
5
|
+
import numpy as np
|
|
4
6
|
from .. config.constants import constants
|
|
5
7
|
from .. core.framework import TaskBase
|
|
6
8
|
from .. core.colors import color_str
|
|
7
9
|
from .. core.exceptions import InvalidOptionError
|
|
8
|
-
from .utils import write_img,
|
|
10
|
+
from .utils import write_img, extension_supported
|
|
9
11
|
from .stack_framework import ImageSequenceManager, SequentialTask
|
|
10
12
|
from .exif import copy_exif_from_file_to_file
|
|
11
13
|
from .denoise import denoise
|
|
@@ -35,18 +37,28 @@ class FocusStackBase(TaskBase, ImageSequenceManager):
|
|
|
35
37
|
stacked_img = denoise(stacked_img, self.denoise_amount, self.denoise_amount)
|
|
36
38
|
write_img(out_filename, stacked_img)
|
|
37
39
|
if self.exif_path != '':
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
if stacked_img.dtype == np.uint16 and \
|
|
41
|
+
os.path.splitext(out_filename)[-1].lower() == '.png':
|
|
42
|
+
self.sub_message_r(color_str(': exif not supported for 16-bit PNG format',
|
|
43
|
+
constants.LOG_COLOR_WARNING),
|
|
44
|
+
level=logging.WARNING)
|
|
45
|
+
else:
|
|
46
|
+
self.sub_message_r(color_str(': copy exif data', constants.LOG_COLOR_LEVEL_3))
|
|
47
|
+
if not os.path.exists(self.exif_path):
|
|
48
|
+
raise RuntimeError(f"path {self.exif_path} does not exist.")
|
|
49
|
+
try:
|
|
50
|
+
_dirpath, _, fnames = next(os.walk(self.exif_path))
|
|
51
|
+
fnames = [name for name in fnames if extension_supported(name)]
|
|
52
|
+
if len(fnames) == 0:
|
|
53
|
+
raise RuntimeError(f"path {self.exif_path} does not contain image files.")
|
|
54
|
+
exif_filename = os.path.join(self.exif_path, fnames[0])
|
|
55
|
+
copy_exif_from_file_to_file(exif_filename, out_filename)
|
|
56
|
+
self.sub_message_r(' ' * 60)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
traceback.print_tb(e.__traceback__)
|
|
59
|
+
self.sub_message_r(color_str(f': failed to copy EXIF data: {str(e)}',
|
|
60
|
+
constants.LOG_COLOR_WARNING),
|
|
61
|
+
level=logging.WARNING)
|
|
50
62
|
if self.plot_stack:
|
|
51
63
|
idx_str = f"{self.frame_count + 1:04d}" if self.frame_count >= 0 else ''
|
|
52
64
|
name = f"{self.name}: {self.stack_algo.name()}"
|
|
@@ -7,7 +7,7 @@ from .. core.colors import color_str
|
|
|
7
7
|
from .. core.framework import Job, SequentialTask
|
|
8
8
|
from .. core.core_utils import check_path_exists
|
|
9
9
|
from .. core.exceptions import RunStopException
|
|
10
|
-
from .utils import read_img, write_img,
|
|
10
|
+
from .utils import read_img, write_img, extension_supported, get_img_metadata, validate_image
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class StackJob(Job):
|
|
@@ -95,7 +95,7 @@ class ImageSequenceManager:
|
|
|
95
95
|
filelist = []
|
|
96
96
|
for _dirpath, _, filenames in os.walk(d):
|
|
97
97
|
filelist = [os.path.join(_dirpath, name)
|
|
98
|
-
for name in filenames if
|
|
98
|
+
for name in filenames if extension_supported(name)]
|
|
99
99
|
filelist.sort()
|
|
100
100
|
if self.reverse_order:
|
|
101
101
|
filelist.reverse()
|
shinestacker/algorithms/utils.py
CHANGED
|
@@ -18,6 +18,15 @@ EXTENSIONS_TIF = ['tif', 'tiff']
|
|
|
18
18
|
EXTENSIONS_JPG = ['jpg', 'jpeg']
|
|
19
19
|
EXTENSIONS_PNG = ['png']
|
|
20
20
|
EXTENSIONS_PDF = ['pdf']
|
|
21
|
+
EXTENSIONS_SUPPORTED = EXTENSIONS_TIF + EXTENSIONS_JPG + EXTENSIONS_PNG
|
|
22
|
+
EXTENSIONS_GUI_STR = " ".join([f"*.{ext}" for ext in EXTENSIONS_SUPPORTED])
|
|
23
|
+
EXTENSION_GUI_TIF = " ".join([f"*.{ext}" for ext in EXTENSIONS_TIF])
|
|
24
|
+
EXTENSION_GUI_JPG = " ".join([f"*.{ext}" for ext in EXTENSIONS_JPG])
|
|
25
|
+
EXTENSION_GUI_PNG = " ".join([f"*.{ext}" for ext in EXTENSIONS_PNG])
|
|
26
|
+
EXTENSIONS_GUI_SAVE_STR = f"TIFF Files ({EXTENSION_GUI_TIF});;" \
|
|
27
|
+
f"JPEG Files ({EXTENSION_GUI_JPG});;" \
|
|
28
|
+
f"PNG Files ({EXTENSION_GUI_PNG});;" \
|
|
29
|
+
"All Files (*)"
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
def extension_in(path, exts):
|
|
@@ -56,6 +65,10 @@ def extension_jpg_tif_png(path):
|
|
|
56
65
|
return extension_in(path, EXTENSIONS_JPG + EXTENSIONS_TIF + EXTENSIONS_PNG)
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
def extension_supported(path):
|
|
69
|
+
return extension_in(path, EXTENSIONS_SUPPORTED)
|
|
70
|
+
|
|
71
|
+
|
|
59
72
|
def read_img(file_path):
|
|
60
73
|
if not os.path.isfile(file_path):
|
|
61
74
|
raise RuntimeError("File does not exist: " + file_path)
|
|
@@ -73,7 +86,10 @@ def write_img(file_path, img):
|
|
|
73
86
|
elif extension_tif(file_path):
|
|
74
87
|
cv2.imwrite(file_path, img, [int(cv2.IMWRITE_TIFF_COMPRESSION), 1])
|
|
75
88
|
elif extension_png(file_path):
|
|
76
|
-
cv2.imwrite(file_path, img
|
|
89
|
+
cv2.imwrite(file_path, img, [
|
|
90
|
+
int(cv2.IMWRITE_PNG_COMPRESSION), 9,
|
|
91
|
+
int(cv2.IMWRITE_PNG_STRATEGY), cv2.IMWRITE_PNG_STRATEGY_HUFFMAN_ONLY
|
|
92
|
+
])
|
|
77
93
|
|
|
78
94
|
|
|
79
95
|
def img_8bit(img):
|
|
@@ -96,7 +112,7 @@ def img_bw(img):
|
|
|
96
112
|
def get_first_image_file(filenames):
|
|
97
113
|
first_img_file = None
|
|
98
114
|
for filename in filenames:
|
|
99
|
-
if os.path.isfile(filename) and
|
|
115
|
+
if os.path.isfile(filename) and extension_supported(filename):
|
|
100
116
|
first_img_file = filename
|
|
101
117
|
break
|
|
102
118
|
if first_img_file is None:
|