shinestacker 0.2.0.post1.dev1__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/__init__.py +3 -0
- shinestacker/_version.py +1 -0
- shinestacker/algorithms/__init__.py +14 -0
- shinestacker/algorithms/align.py +307 -0
- shinestacker/algorithms/balance.py +367 -0
- shinestacker/algorithms/core_utils.py +22 -0
- shinestacker/algorithms/depth_map.py +164 -0
- shinestacker/algorithms/exif.py +238 -0
- shinestacker/algorithms/multilayer.py +187 -0
- shinestacker/algorithms/noise_detection.py +182 -0
- shinestacker/algorithms/pyramid.py +176 -0
- shinestacker/algorithms/stack.py +112 -0
- shinestacker/algorithms/stack_framework.py +248 -0
- shinestacker/algorithms/utils.py +71 -0
- shinestacker/algorithms/vignetting.py +137 -0
- shinestacker/app/__init__.py +0 -0
- shinestacker/app/about_dialog.py +24 -0
- shinestacker/app/app_config.py +39 -0
- shinestacker/app/gui_utils.py +35 -0
- shinestacker/app/help_menu.py +16 -0
- shinestacker/app/main.py +176 -0
- shinestacker/app/open_frames.py +39 -0
- shinestacker/app/project.py +91 -0
- shinestacker/app/retouch.py +82 -0
- shinestacker/config/__init__.py +4 -0
- shinestacker/config/config.py +53 -0
- shinestacker/config/constants.py +174 -0
- shinestacker/config/gui_constants.py +85 -0
- shinestacker/core/__init__.py +5 -0
- shinestacker/core/colors.py +60 -0
- shinestacker/core/core_utils.py +52 -0
- shinestacker/core/exceptions.py +50 -0
- shinestacker/core/framework.py +210 -0
- shinestacker/core/logging.py +89 -0
- shinestacker/gui/__init__.py +0 -0
- shinestacker/gui/action_config.py +879 -0
- shinestacker/gui/actions_window.py +283 -0
- shinestacker/gui/colors.py +57 -0
- shinestacker/gui/gui_images.py +152 -0
- shinestacker/gui/gui_logging.py +213 -0
- shinestacker/gui/gui_run.py +393 -0
- shinestacker/gui/img/close-round-line-icon.png +0 -0
- shinestacker/gui/img/forward-button-icon.png +0 -0
- shinestacker/gui/img/play-button-round-icon.png +0 -0
- shinestacker/gui/img/plus-round-line-icon.png +0 -0
- shinestacker/gui/main_window.py +599 -0
- shinestacker/gui/new_project.py +170 -0
- shinestacker/gui/project_converter.py +148 -0
- shinestacker/gui/project_editor.py +539 -0
- shinestacker/gui/project_model.py +138 -0
- shinestacker/retouch/__init__.py +0 -0
- shinestacker/retouch/brush.py +9 -0
- shinestacker/retouch/brush_controller.py +57 -0
- shinestacker/retouch/brush_preview.py +126 -0
- shinestacker/retouch/exif_data.py +65 -0
- shinestacker/retouch/file_loader.py +104 -0
- shinestacker/retouch/image_editor.py +651 -0
- shinestacker/retouch/image_editor_ui.py +380 -0
- shinestacker/retouch/image_viewer.py +356 -0
- shinestacker/retouch/shortcuts_help.py +98 -0
- shinestacker/retouch/undo_manager.py +38 -0
- shinestacker-0.2.0.post1.dev1.dist-info/METADATA +55 -0
- shinestacker-0.2.0.post1.dev1.dist-info/RECORD +67 -0
- shinestacker-0.2.0.post1.dev1.dist-info/WHEEL +5 -0
- shinestacker-0.2.0.post1.dev1.dist-info/entry_points.txt +4 -0
- shinestacker-0.2.0.post1.dev1.dist-info/licenses/LICENSE +1 -0
- shinestacker-0.2.0.post1.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import io
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PIL import Image
|
|
7
|
+
from PIL.TiffImagePlugin import IFDRational
|
|
8
|
+
from PIL.ExifTags import TAGS
|
|
9
|
+
import logging
|
|
10
|
+
import tifffile
|
|
11
|
+
from .. config.constants import constants
|
|
12
|
+
from .utils import write_img
|
|
13
|
+
|
|
14
|
+
IMAGEWIDTH = 256
|
|
15
|
+
IMAGELENGTH = 257
|
|
16
|
+
RESOLUTIONX = 282
|
|
17
|
+
RESOLUTIONY = 283
|
|
18
|
+
RESOLUTIONUNIT = 296
|
|
19
|
+
BITSPERSAMPLE = 258
|
|
20
|
+
PHOTOMETRICINTERPRETATION = 262
|
|
21
|
+
SAMPLESPERPIXEL = 277
|
|
22
|
+
PLANARCONFIGURATION = 284
|
|
23
|
+
SOFTWARE = 305
|
|
24
|
+
IMAGERESOURCES = 34377
|
|
25
|
+
INTERCOLORPROFILE = 34675
|
|
26
|
+
EXIFTAG = 34665
|
|
27
|
+
XMLPACKET = 700
|
|
28
|
+
STRIPOFFSETS = 273
|
|
29
|
+
STRIPBYTECOUNTS = 279
|
|
30
|
+
NO_COPY_TIFF_TAGS_ID = [IMAGEWIDTH, IMAGELENGTH, RESOLUTIONX, RESOLUTIONY, BITSPERSAMPLE,
|
|
31
|
+
PHOTOMETRICINTERPRETATION, SAMPLESPERPIXEL, PLANARCONFIGURATION, SOFTWARE,
|
|
32
|
+
RESOLUTIONUNIT, EXIFTAG, INTERCOLORPROFILE, IMAGERESOURCES]
|
|
33
|
+
NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCounts"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_enclosed_data_for_jpg(data, head, foot):
|
|
37
|
+
size = len(foot.decode('ascii'))
|
|
38
|
+
xmp_start, xmp_end = data.find(head), data.find(foot)
|
|
39
|
+
if xmp_start != -1 and xmp_end != -1:
|
|
40
|
+
return re.sub(b'[^\x20-\x7E]', b'', data[xmp_start:xmp_end + size]).decode().replace('\x00', '').encode()
|
|
41
|
+
else:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_exif(exif_filename):
|
|
46
|
+
if not os.path.isfile(exif_filename):
|
|
47
|
+
raise Exception("File does not exist: " + exif_filename)
|
|
48
|
+
ext = exif_filename.split(".")[-1]
|
|
49
|
+
image = Image.open(exif_filename)
|
|
50
|
+
if ext == 'tif' or ext == 'tiff':
|
|
51
|
+
return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
|
|
52
|
+
elif ext == 'jpeg' or ext == 'jpg':
|
|
53
|
+
exif_dict = image.getexif()
|
|
54
|
+
with open(exif_filename, 'rb') as f:
|
|
55
|
+
data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
|
|
56
|
+
if data is not None:
|
|
57
|
+
exif_dict[XMLPACKET] = data
|
|
58
|
+
return exif_dict
|
|
59
|
+
else:
|
|
60
|
+
return image.getexif()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def exif_extra_tags_for_tif(exif):
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
|
|
66
|
+
if not (res_x is None or res_y is None):
|
|
67
|
+
resolution = ((res_x.numerator, res_x.denominator), (res_y.numerator, res_y.denominator))
|
|
68
|
+
else:
|
|
69
|
+
resolution = ((720000, 10000), (720000, 10000))
|
|
70
|
+
res_u = exif.get(RESOLUTIONUNIT)
|
|
71
|
+
resolutionunit = res_u if res_u is not None else 'inch'
|
|
72
|
+
sw = exif.get(SOFTWARE)
|
|
73
|
+
software = sw if sw is not None else "N/A"
|
|
74
|
+
phint = exif.get(PHOTOMETRICINTERPRETATION)
|
|
75
|
+
photometric = phint if phint is not None else None
|
|
76
|
+
extra = []
|
|
77
|
+
for tag_id in exif:
|
|
78
|
+
tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
|
|
79
|
+
if isinstance(data, bytes):
|
|
80
|
+
try:
|
|
81
|
+
if tag_id != IMAGERESOURCES and tag_id != INTERCOLORPROFILE:
|
|
82
|
+
if tag_id == XMLPACKET:
|
|
83
|
+
data = re.sub(b'[^\x20-\x7E]', b'', data)
|
|
84
|
+
data = data.decode()
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.warning(f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
|
|
87
|
+
data = '<<< decode error >>>'
|
|
88
|
+
if isinstance(data, IFDRational):
|
|
89
|
+
data = (data.numerator, data.denominator)
|
|
90
|
+
if tag not in NO_COPY_TIFF_TAGS and tag_id not in NO_COPY_TIFF_TAGS_ID:
|
|
91
|
+
extra.append((tag_id, *get_tiff_dtype_count(data), data, False))
|
|
92
|
+
else:
|
|
93
|
+
logger.debug(f"Skip tag {tag:25} [#{tag_id}]")
|
|
94
|
+
return extra, {'resolution': resolution, 'resolutionunit': resolutionunit,
|
|
95
|
+
'software': software, 'photometric': photometric}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_tiff_dtype_count(value):
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
return 2, len(value) + 1 # ASCII string, (dtype=2), length + null terminator
|
|
101
|
+
elif isinstance(value, (bytes, bytearray)):
|
|
102
|
+
return 1, len(value) # Binary data (dtype=1)
|
|
103
|
+
elif isinstance(value, (list, tuple, np.ndarray)):
|
|
104
|
+
if isinstance(value, np.ndarray):
|
|
105
|
+
dtype = value.dtype # Array or sequence
|
|
106
|
+
else:
|
|
107
|
+
dtype = np.array(value).dtype # Map numpy dtype to TIFF dtype
|
|
108
|
+
if dtype == np.uint8:
|
|
109
|
+
return 1, len(value)
|
|
110
|
+
elif dtype == np.uint16:
|
|
111
|
+
return 3, len(value)
|
|
112
|
+
elif dtype == np.uint32:
|
|
113
|
+
return 4, len(value)
|
|
114
|
+
elif dtype == np.float32:
|
|
115
|
+
return 11, len(value)
|
|
116
|
+
elif dtype == np.float64:
|
|
117
|
+
return 12, len(value)
|
|
118
|
+
elif isinstance(value, int):
|
|
119
|
+
if 0 <= value <= 65535:
|
|
120
|
+
return 3, 1 # uint16
|
|
121
|
+
else:
|
|
122
|
+
return 4, 1 # uint32
|
|
123
|
+
elif isinstance(value, float):
|
|
124
|
+
return 11, 1 # float64
|
|
125
|
+
return 2, len(str(value)) + 1 # Default for othre cases (ASCII string)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def add_exif_data_to_jpg_file(exif, in_filenama, out_filename, verbose=False):
|
|
129
|
+
logger = logging.getLogger(__name__)
|
|
130
|
+
if exif is None:
|
|
131
|
+
raise Exception('No exif data provided.')
|
|
132
|
+
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 = (jpeg_data[:app1_marker_pos] + b'\xFF\xE1' + len(xmp_data).to_bytes(2, 'big') + xmp_data + jpeg_data[app1_marker_pos:])
|
|
144
|
+
else:
|
|
145
|
+
logger.warning("Copy: can't find XMLPacket in JPG EXIF data")
|
|
146
|
+
updated_data = jpeg_data
|
|
147
|
+
with open(out_filename, 'wb') as f:
|
|
148
|
+
f.write(updated_data)
|
|
149
|
+
return exif
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_image_with_exif_data(exif, image, out_filename, verbose=False):
|
|
153
|
+
if exif is None:
|
|
154
|
+
write_img(out_filename, image)
|
|
155
|
+
return None
|
|
156
|
+
ext = out_filename.split(".")[-1]
|
|
157
|
+
if verbose:
|
|
158
|
+
print_exif(exif)
|
|
159
|
+
if ext == 'jpeg' or ext == 'jpg':
|
|
160
|
+
cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|
161
|
+
add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
|
|
162
|
+
elif ext == 'tiff' or ext == 'tif':
|
|
163
|
+
metadata = {"description": f"image generated with {constants.APP_STRING} package"}
|
|
164
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
|
|
165
|
+
tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
|
|
166
|
+
extratags=extra_tags, **exif_tags)
|
|
167
|
+
elif ext == 'png':
|
|
168
|
+
image.save(out_filename, 'PNG', exif=exif, quality=100)
|
|
169
|
+
return exif
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
|
|
173
|
+
ext = in_filename.split(".")[-1]
|
|
174
|
+
if out_filename is None:
|
|
175
|
+
out_filename = in_filename
|
|
176
|
+
if exif is None:
|
|
177
|
+
raise Exception('No exif data provided.')
|
|
178
|
+
if verbose:
|
|
179
|
+
print_exif(exif)
|
|
180
|
+
if ext == 'tiff' or ext == 'tif':
|
|
181
|
+
image_new = tifffile.imread(in_filename)
|
|
182
|
+
else:
|
|
183
|
+
image_new = Image.open(in_filename)
|
|
184
|
+
if ext == 'jpeg' or ext == 'jpg':
|
|
185
|
+
add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
|
|
186
|
+
elif ext == 'tiff' or ext == 'tif':
|
|
187
|
+
metadata = {"description": f"image generated with {constants.APP_STRING} package"}
|
|
188
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
|
|
189
|
+
tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
|
|
190
|
+
extratags=extra_tags, **exif_tags)
|
|
191
|
+
elif ext == 'png':
|
|
192
|
+
image_new.save(out_filename, 'PNG', exif=exif, quality=100)
|
|
193
|
+
return exif
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, verbose=False):
|
|
197
|
+
if not os.path.isfile(exif_filename):
|
|
198
|
+
raise Exception("File does not exist: " + exif_filename)
|
|
199
|
+
if not os.path.isfile(in_filename):
|
|
200
|
+
raise Exception("File does not exist: " + in_filename)
|
|
201
|
+
exif = get_exif(exif_filename)
|
|
202
|
+
return save_exif_data(exif, in_filename, out_filename, verbose)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def exif_dict(exif, hide_xml=True):
|
|
206
|
+
if exif is None:
|
|
207
|
+
return None
|
|
208
|
+
exif_data = {}
|
|
209
|
+
for tag_id in exif:
|
|
210
|
+
tag = TAGS.get(tag_id, tag_id)
|
|
211
|
+
if tag_id == XMLPACKET and hide_xml:
|
|
212
|
+
data = "<<< XML data >>>"
|
|
213
|
+
elif tag_id == IMAGERESOURCES or tag_id == INTERCOLORPROFILE:
|
|
214
|
+
data = "<<< Photoshop data >>>"
|
|
215
|
+
elif tag_id == STRIPOFFSETS:
|
|
216
|
+
data = "<<< Strip offsets >>>"
|
|
217
|
+
elif tag_id == STRIPBYTECOUNTS:
|
|
218
|
+
data = "<<< Strip byte counts >>>"
|
|
219
|
+
else:
|
|
220
|
+
data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
|
|
221
|
+
if isinstance(data, bytes):
|
|
222
|
+
try:
|
|
223
|
+
data = data.decode()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
exif_data[tag] = (tag_id, data)
|
|
227
|
+
return exif_data
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def print_exif(exif, hide_xml=True):
|
|
231
|
+
exif_data = exif_dict(exif, hide_xml)
|
|
232
|
+
if exif_data is None:
|
|
233
|
+
raise Exception('Image has no exif data.')
|
|
234
|
+
logger = logging.getLogger(__name__)
|
|
235
|
+
for tag, (tag_id, data) in exif_data.items():
|
|
236
|
+
if isinstance(data, IFDRational):
|
|
237
|
+
data = f"{data.numerator}/{data.denominator}"
|
|
238
|
+
logger.info(f"{tag:25} [#{tag_id:5d}]: {data}")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import cv2
|
|
4
|
+
import tifffile
|
|
5
|
+
import imagecodecs
|
|
6
|
+
import numpy as np
|
|
7
|
+
from psdtags import (PsdBlendMode, PsdChannel, PsdChannelId, PsdClippingType, PsdColorSpaceType,
|
|
8
|
+
PsdCompressionType, PsdEmpty, PsdFilterMask, PsdFormat, PsdKey, PsdLayer,
|
|
9
|
+
PsdLayerFlag, PsdLayerMask, PsdLayers, PsdRectangle, PsdString, PsdUserMask,
|
|
10
|
+
TiffImageSourceData, overlay)
|
|
11
|
+
from .. config.constants import constants
|
|
12
|
+
from .. config.config import config
|
|
13
|
+
from .. core.colors import color_str
|
|
14
|
+
from .. core.framework import JobBase
|
|
15
|
+
from .stack_framework import FrameMultiDirectory
|
|
16
|
+
from .exif import exif_extra_tags_for_tif, get_exif
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_multilayer_tiff(input_file):
|
|
20
|
+
return TiffImageSourceData.fromtiff(input_file)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_multilayer_tiff(input_files, output_file, labels=None, exif_path='', callbacks=None):
|
|
24
|
+
extensions = list(set([file.split(".")[-1] for file in input_files]))
|
|
25
|
+
if len(extensions) > 1:
|
|
26
|
+
msg = ", ".join(extensions)
|
|
27
|
+
raise Exception(f"All input files must have the same extension. Input list has the following extensions: {msg}.")
|
|
28
|
+
extension = extensions[0]
|
|
29
|
+
if extension == 'tif' or extension == 'tiff':
|
|
30
|
+
images = [tifffile.imread(p) for p in input_files]
|
|
31
|
+
elif extension == 'jpg' or extension == 'jpeg':
|
|
32
|
+
images = [cv2.imread(p) for p in input_files]
|
|
33
|
+
images = [cv2.cvtColor(i, cv2.COLOR_BGR2RGB) for i in images]
|
|
34
|
+
elif extension == 'png':
|
|
35
|
+
images = [cv2.imread(p, cv2.IMREAD_UNCHANGED) for p in input_files]
|
|
36
|
+
images = [cv2.cvtColor(i, cv2.COLOR_BGR2RGB) for i in images]
|
|
37
|
+
if labels is None:
|
|
38
|
+
image_dict = {file.split('/')[-1].split('.')[0]: image for file, image in zip(input_files, images)}
|
|
39
|
+
else:
|
|
40
|
+
if len(labels) != len(input_files):
|
|
41
|
+
raise Exception("input_files and labels must have the same length if labels are provided.")
|
|
42
|
+
image_dict = {label: image for label, image in zip(labels, images)}
|
|
43
|
+
write_multilayer_tiff_from_images(image_dict, output_file, exif_path=exif_path, callbacks=callbacks)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_multilayer_tiff_from_images(image_dict, output_file, exif_path='', callbacks=None):
|
|
47
|
+
if isinstance(image_dict, (list, tuple, np.ndarray)):
|
|
48
|
+
fmt = 'Layer {:03d}'
|
|
49
|
+
image_dict = {fmt.format(i + 1): img for i, img in enumerate(image_dict)}
|
|
50
|
+
shapes = list(set([image.shape[:2] for image in image_dict.values()]))
|
|
51
|
+
if len(shapes) > 1:
|
|
52
|
+
raise Exception("All input files must have the same dimensions.")
|
|
53
|
+
shape = shapes[0]
|
|
54
|
+
dtypes = list(set([image.dtype for image in image_dict.values()]))
|
|
55
|
+
if len(dtypes) > 1:
|
|
56
|
+
raise Exception("All input files must all have 8 bit or 16 bit depth.")
|
|
57
|
+
dtype = dtypes[0]
|
|
58
|
+
max_pixel_value = constants.MAX_UINT16 if dtype == np.uint16 else constants.MAX_UINT8
|
|
59
|
+
transp = np.full_like(list(image_dict.values())[0][..., 0], max_pixel_value)
|
|
60
|
+
compression_type = PsdCompressionType.ZIP_PREDICTED
|
|
61
|
+
psdformat = PsdFormat.LE32BIT
|
|
62
|
+
key = PsdKey.LAYER_16 if dtype == np.uint16 else PsdKey.LAYER
|
|
63
|
+
layers = [PsdLayer(
|
|
64
|
+
name=label,
|
|
65
|
+
rectangle=PsdRectangle(0, 0, *shape),
|
|
66
|
+
channels=[
|
|
67
|
+
PsdChannel(
|
|
68
|
+
channelid=PsdChannelId.TRANSPARENCY_MASK,
|
|
69
|
+
compression=compression_type,
|
|
70
|
+
data=transp,
|
|
71
|
+
),
|
|
72
|
+
PsdChannel(
|
|
73
|
+
channelid=PsdChannelId.CHANNEL0,
|
|
74
|
+
compression=compression_type,
|
|
75
|
+
data=image[..., 0],
|
|
76
|
+
),
|
|
77
|
+
PsdChannel(
|
|
78
|
+
channelid=PsdChannelId.CHANNEL1,
|
|
79
|
+
compression=compression_type,
|
|
80
|
+
data=image[..., 1],
|
|
81
|
+
),
|
|
82
|
+
PsdChannel(
|
|
83
|
+
channelid=PsdChannelId.CHANNEL2,
|
|
84
|
+
compression=compression_type,
|
|
85
|
+
data=image[..., 2],
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
mask=PsdLayerMask(), opacity=255,
|
|
89
|
+
blendmode=PsdBlendMode.NORMAL, blending_ranges=(),
|
|
90
|
+
clipping=PsdClippingType.BASE, flags=PsdLayerFlag.PHOTOSHOP5,
|
|
91
|
+
info=[PsdString(PsdKey.UNICODE_LAYER_NAME, label)],
|
|
92
|
+
) for label, image in reversed(list(image_dict.items()))]
|
|
93
|
+
image_source_data = TiffImageSourceData(
|
|
94
|
+
name='Layered TIFF',
|
|
95
|
+
psdformat=psdformat,
|
|
96
|
+
layers=PsdLayers(
|
|
97
|
+
key=key,
|
|
98
|
+
has_transparency=False,
|
|
99
|
+
layers=layers,
|
|
100
|
+
),
|
|
101
|
+
usermask=PsdUserMask(
|
|
102
|
+
colorspace=PsdColorSpaceType.RGB,
|
|
103
|
+
components=(65535, 0, 0, 0),
|
|
104
|
+
opacity=50,
|
|
105
|
+
),
|
|
106
|
+
info=[
|
|
107
|
+
PsdEmpty(PsdKey.PATTERNS),
|
|
108
|
+
PsdFilterMask(
|
|
109
|
+
colorspace=PsdColorSpaceType.RGB,
|
|
110
|
+
components=(65535, 0, 0, 0),
|
|
111
|
+
opacity=50,
|
|
112
|
+
),
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
tiff_tags = {
|
|
116
|
+
'photometric': 'rgb',
|
|
117
|
+
'resolution': ((720000, 10000), (720000, 10000)),
|
|
118
|
+
'resolutionunit': 'inch',
|
|
119
|
+
'extratags': [image_source_data.tifftag(maxworkers=4),
|
|
120
|
+
(34675, 7, None, imagecodecs.cms_profile('srgb'), True)]
|
|
121
|
+
}
|
|
122
|
+
if exif_path != '':
|
|
123
|
+
if callbacks:
|
|
124
|
+
callback = callbacks.get('exif_msg', None)
|
|
125
|
+
if callback:
|
|
126
|
+
callback(exif_path)
|
|
127
|
+
if os.path.isfile(exif_path):
|
|
128
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(get_exif(exif_path))
|
|
129
|
+
elif os.path.isdir(exif_path):
|
|
130
|
+
dirpath, _, fnames = next(os.walk(exif_path))
|
|
131
|
+
fnames = [name for name in fnames if os.path.splitext(name)[-1][1:].lower() in constants.EXTENSIONS]
|
|
132
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(get_exif(exif_path + '/' + fnames[0]))
|
|
133
|
+
tiff_tags['extratags'] += extra_tags
|
|
134
|
+
tiff_tags = {**tiff_tags, **exif_tags}
|
|
135
|
+
if callbacks:
|
|
136
|
+
callback = callbacks.get('write_msg', None)
|
|
137
|
+
if callback:
|
|
138
|
+
callback(output_file.split('/')[-1])
|
|
139
|
+
compression = 'adobe_deflate'
|
|
140
|
+
overlayed_images = overlay(*((np.concatenate((image, np.expand_dims(transp, axis=-1)), axis=-1), (0, 0)) for image in image_dict.values()), shape=shape)
|
|
141
|
+
tifffile.imwrite(output_file, overlayed_images, compression=compression, metadata=None, **tiff_tags)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MultiLayer(FrameMultiDirectory, JobBase):
|
|
145
|
+
def __init__(self, name, enabled=True, **kwargs):
|
|
146
|
+
FrameMultiDirectory.__init__(self, name, **kwargs)
|
|
147
|
+
JobBase.__init__(self, name, enabled)
|
|
148
|
+
self.exif_path = kwargs.get('exif_path', '')
|
|
149
|
+
self.reverse_order = kwargs.get('reverse_order', constants.DEFAULT_MULTILAYER_FILE_REVERSE_ORDER)
|
|
150
|
+
|
|
151
|
+
def init(self, job):
|
|
152
|
+
FrameMultiDirectory.init(self, job)
|
|
153
|
+
if self.exif_path == '':
|
|
154
|
+
self.exif_path = job.paths[0]
|
|
155
|
+
if self.exif_path != '':
|
|
156
|
+
self.exif_path = self.working_path + "/" + self.exif_path
|
|
157
|
+
|
|
158
|
+
def run_core(self):
|
|
159
|
+
if isinstance(self.input_full_path, str):
|
|
160
|
+
paths = [self.input_path]
|
|
161
|
+
elif hasattr(self.input_full_path, "__len__"):
|
|
162
|
+
paths = self.input_path
|
|
163
|
+
else:
|
|
164
|
+
raise Exception("input_path option must contain a path or an array of paths")
|
|
165
|
+
if len(paths) == 0:
|
|
166
|
+
self.print_message(color_str("no input paths specified", "red"), level=logging.WARNING)
|
|
167
|
+
return
|
|
168
|
+
files = self.folder_filelist()
|
|
169
|
+
if len(files) == 0:
|
|
170
|
+
self.print_message(color_str("no input in {} specified path{}:"
|
|
171
|
+
" ".format(len(paths),
|
|
172
|
+
's' if len(paths) > 1 else '') + ", ".join([f"'{p}'" for p in paths]), "red"),
|
|
173
|
+
level=logging.WARNING)
|
|
174
|
+
return
|
|
175
|
+
self.print_message(color_str("merging frames in " + self.folder_list_str(), "blue"))
|
|
176
|
+
input_files = [f"{self.working_path}/{f}" for f in files]
|
|
177
|
+
self.print_message(color_str("frames: " + ", ".join([i.split("/")[-1] for i in files]), "blue"))
|
|
178
|
+
self.print_message(color_str("reading files", "blue"))
|
|
179
|
+
filename = ".".join(files[0].split("/")[-1].split(".")[:-1])
|
|
180
|
+
output_file = f"{self.working_path}/{self.output_path}/{filename}.tif"
|
|
181
|
+
callbacks = {
|
|
182
|
+
'exif_msg': lambda path: self.print_message(color_str(f"copying exif data from path: {path}", "blue")),
|
|
183
|
+
'write_msg': lambda path: self.print_message(color_str(f"writing multilayer tiff file: {path}", "blue"))
|
|
184
|
+
}
|
|
185
|
+
write_multilayer_tiff(input_files, output_file, labels=None, exif_path=self.exif_path, callbacks=callbacks)
|
|
186
|
+
app = 'internal_retouch_app' if config.COMBINED_APP else f'{constants.RETOUCH_APP}'
|
|
187
|
+
self.callback('open_app', self.id, self.name, app, output_file)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import errno
|
|
7
|
+
from .. config.config import config
|
|
8
|
+
from .. config.constants import constants
|
|
9
|
+
from .. core.colors import color_str
|
|
10
|
+
from .. core.exceptions import ImageLoadError
|
|
11
|
+
from .. core.framework import JobBase
|
|
12
|
+
from .. core.core_utils import make_tqdm_bar
|
|
13
|
+
from .. core.exceptions import RunStopException
|
|
14
|
+
from .stack_framework import FrameMultiDirectory, SubAction
|
|
15
|
+
from .utils import read_img, save_plot, get_img_metadata, validate_image
|
|
16
|
+
|
|
17
|
+
MAX_NOISY_PIXELS = 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mean_image(file_paths, max_frames=-1, message_callback=None, progress_callback=None):
|
|
21
|
+
mean_img = None
|
|
22
|
+
counter = 0
|
|
23
|
+
for i, path in enumerate(file_paths):
|
|
24
|
+
if max_frames >= 1 and i > max_frames:
|
|
25
|
+
break
|
|
26
|
+
if message_callback:
|
|
27
|
+
message_callback(path)
|
|
28
|
+
if not os.path.exists(path):
|
|
29
|
+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
|
|
30
|
+
try:
|
|
31
|
+
img = read_img(path)
|
|
32
|
+
except Exception:
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
logger.error("Can't open file: " + path)
|
|
35
|
+
if mean_img is None:
|
|
36
|
+
metadata = get_img_metadata(img)
|
|
37
|
+
mean_img = img.astype(np.float64)
|
|
38
|
+
else:
|
|
39
|
+
validate_image(img, *metadata)
|
|
40
|
+
mean_img += img.astype(np.float64)
|
|
41
|
+
counter += 1
|
|
42
|
+
if progress_callback:
|
|
43
|
+
progress_callback(i)
|
|
44
|
+
return None if mean_img is None else (mean_img / counter).astype(np.uint8)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NoiseDetection(FrameMultiDirectory, JobBase):
|
|
48
|
+
def __init__(self, name="noise-map", enabled=True, **kwargs):
|
|
49
|
+
FrameMultiDirectory.__init__(self, name, **kwargs)
|
|
50
|
+
JobBase.__init__(self, name, enabled)
|
|
51
|
+
self.max_frames = kwargs.get('max_frames', -1)
|
|
52
|
+
self.blur_size = kwargs.get('blur_size', constants.DEFAULT_BLUR_SIZE)
|
|
53
|
+
self.file_name = kwargs.get('file_name', constants.DEFAULT_NOISE_MAP_FILENAME)
|
|
54
|
+
if self.file_name == '':
|
|
55
|
+
self.file_name = constants.DEFAULT_NOISE_MAP_FILENAME
|
|
56
|
+
self.channel_thresholds = kwargs.get('channel_thresholds', constants.DEFAULT_CHANNEL_THRESHOLDS)
|
|
57
|
+
self.plot_range = kwargs.get('plot_range', constants.DEFAULT_NOISE_PLOT_RANGE)
|
|
58
|
+
self.plot_histograms = kwargs.get('plot_histograms', False)
|
|
59
|
+
|
|
60
|
+
def hot_map(self, ch, th):
|
|
61
|
+
return cv2.threshold(ch, th, 255, cv2.THRESH_BINARY)[1]
|
|
62
|
+
|
|
63
|
+
def progress(self, i):
|
|
64
|
+
self.callback('after_step', self.id, self.name, i)
|
|
65
|
+
if not config.DISABLE_TQDM:
|
|
66
|
+
self.bar.update(1)
|
|
67
|
+
if self.callback('check_running', self.id, self.name) is False:
|
|
68
|
+
raise RunStopException(self.name)
|
|
69
|
+
|
|
70
|
+
def run_core(self):
|
|
71
|
+
self.print_message(color_str("map noisy pixels from frames in " + self.folder_list_str(), "blue"))
|
|
72
|
+
files = self.folder_filelist()
|
|
73
|
+
in_paths = [self.working_path + "/" + f for f in files]
|
|
74
|
+
n_frames = min(len(in_paths), self.max_frames) if self.max_frames > 0 else len(in_paths)
|
|
75
|
+
self.callback('step_counts', self.id, self.name, n_frames)
|
|
76
|
+
if not config.DISABLE_TQDM:
|
|
77
|
+
self.bar = make_tqdm_bar(self.name, n_frames)
|
|
78
|
+
|
|
79
|
+
def progress_callback(i):
|
|
80
|
+
self.progress(i)
|
|
81
|
+
if self.callback('check_running', self.id, self.name) is False:
|
|
82
|
+
raise RunStopException(self.name)
|
|
83
|
+
mean_img = mean_image(
|
|
84
|
+
file_paths=in_paths, max_frames=self.max_frames,
|
|
85
|
+
message_callback=lambda path: self.print_message_r(color_str(f"reading frame: {path.split('/')[-1]}", "blue")),
|
|
86
|
+
progress_callback=progress_callback)
|
|
87
|
+
if not config.DISABLE_TQDM:
|
|
88
|
+
self.bar.close()
|
|
89
|
+
blurred = cv2.GaussianBlur(mean_img, (self.blur_size, self.blur_size), 0)
|
|
90
|
+
diff = cv2.absdiff(mean_img, blurred)
|
|
91
|
+
channels = cv2.split(diff)
|
|
92
|
+
hot_px = [self.hot_map(ch, self.channel_thresholds[i]) for i, ch in enumerate(channels)]
|
|
93
|
+
hot_rgb = cv2.bitwise_or(hot_px[0], cv2.bitwise_or(hot_px[1], hot_px[2]))
|
|
94
|
+
msg = []
|
|
95
|
+
for ch, hot in zip(['rgb', *constants.RGB_LABELS], [hot_rgb] + hot_px):
|
|
96
|
+
msg.append("{}: {}".format(ch, np.count_nonzero(hot > 0)))
|
|
97
|
+
self.print_message("hot pixels: " + ", ".join(msg))
|
|
98
|
+
path = "/".join(self.file_name.split("/")[:-1])
|
|
99
|
+
if not os.path.exists(self.working_path + '/' + path):
|
|
100
|
+
self.print_message("create directory: " + path)
|
|
101
|
+
os.mkdir(self.working_path + '/' + path)
|
|
102
|
+
|
|
103
|
+
self.print_message("writing hot pixels map file: " + self.file_name)
|
|
104
|
+
cv2.imwrite(self.working_path + '/' + self.file_name, hot)
|
|
105
|
+
plot_range = self.plot_range
|
|
106
|
+
min_th, max_th = min(self.channel_thresholds), max(self.channel_thresholds)
|
|
107
|
+
if min_th < plot_range[0]:
|
|
108
|
+
plot_range[0] = min_th - 1
|
|
109
|
+
if max_th > plot_range[1]:
|
|
110
|
+
plot_range[1] = max_th + 1
|
|
111
|
+
th_range = np.arange(self.plot_range[0], self.plot_range[1] + 1)
|
|
112
|
+
if self.plot_histograms:
|
|
113
|
+
plt.figure(figsize=(10, 5))
|
|
114
|
+
x = np.array(list(th_range))
|
|
115
|
+
ys = [[np.count_nonzero(self.hot_map(ch, th) > 0) for th in th_range] for ch in channels]
|
|
116
|
+
for i, ch, y in zip(range(3), constants.RGB_LABELS, ys):
|
|
117
|
+
plt.plot(x, y, c=ch, label=ch)
|
|
118
|
+
plt.plot([self.channel_thresholds[i], self.channel_thresholds[i]],
|
|
119
|
+
[0, y[self.channel_thresholds[i] - int(x[0])]], c=ch, linestyle="--")
|
|
120
|
+
plt.xlabel('threshold')
|
|
121
|
+
plt.ylabel('# of hot pixels')
|
|
122
|
+
plt.legend()
|
|
123
|
+
plt.xlim(x[0], x[-1])
|
|
124
|
+
plt.ylim(0)
|
|
125
|
+
plot_path = self.working_path + "/" + self.plot_path + "/" + self.name + "-hot-pixels.pdf"
|
|
126
|
+
save_plot(plot_path)
|
|
127
|
+
self.callback('save_plot', self.id, f"{self.name}: noise", plot_path)
|
|
128
|
+
plt.close('all')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MaskNoise(SubAction):
|
|
132
|
+
def __init__(self, noise_mask=constants.DEFAULT_NOISE_MAP_FILENAME,
|
|
133
|
+
kernel_size=constants.DEFAULT_MN_KERNEL_SIZE, method=constants.INTERPOLATE_MEAN, **kwargs):
|
|
134
|
+
super().__init__(**kwargs)
|
|
135
|
+
self.noise_mask = noise_mask if noise_mask != '' else constants.DEFAULT_NOISE_MAP_FILENAME
|
|
136
|
+
self.kernel_size = kernel_size
|
|
137
|
+
self.ks2 = self.kernel_size // 2
|
|
138
|
+
self.ks2_1 = self.ks2 + 1
|
|
139
|
+
self.method = method
|
|
140
|
+
|
|
141
|
+
def begin(self, process):
|
|
142
|
+
self.process = process
|
|
143
|
+
path = f"{process.working_path}/{self.noise_mask}"
|
|
144
|
+
if os.path.exists(path):
|
|
145
|
+
self.process.sub_message_r(f': reading noisy pixel mask file: {self.noise_mask}')
|
|
146
|
+
self.noise_mask_img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
|
147
|
+
if self.noise_mask_img is None:
|
|
148
|
+
raise ImageLoadError(path, f"failed to load image file {self.noise_mask}.")
|
|
149
|
+
else:
|
|
150
|
+
raise ImageLoadError(path, "file not found.")
|
|
151
|
+
|
|
152
|
+
def end(self):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def run_frame(self, idx, ref_idx, image):
|
|
156
|
+
self.process.sub_message_r(': mask noisy pixels')
|
|
157
|
+
if len(image.shape) == 3:
|
|
158
|
+
corrected = image.copy()
|
|
159
|
+
for c in range(3):
|
|
160
|
+
corrected[:, :, c] = self.correct_channel(image[:, :, c])
|
|
161
|
+
else:
|
|
162
|
+
corrected = self.correct_channel(image)
|
|
163
|
+
return corrected
|
|
164
|
+
|
|
165
|
+
def correct_channel(self, channel):
|
|
166
|
+
corrected = channel.copy()
|
|
167
|
+
noise_coords = np.argwhere(self.noise_mask_img > 0)
|
|
168
|
+
n_noisy_pixels = noise_coords.shape[0]
|
|
169
|
+
if n_noisy_pixels > MAX_NOISY_PIXELS:
|
|
170
|
+
raise RuntimeError(f"Noise map contains too many hot pixels: {n_noisy_pixels}")
|
|
171
|
+
for y, x in noise_coords:
|
|
172
|
+
neighborhood = channel[
|
|
173
|
+
max(0, y - self.ks2):min(channel.shape[0], y + self.ks2_1),
|
|
174
|
+
max(0, x - self.ks2):min(channel.shape[1], x + self.ks2_1)
|
|
175
|
+
]
|
|
176
|
+
valid_pixels = neighborhood[neighborhood != 0]
|
|
177
|
+
if len(valid_pixels) > 0:
|
|
178
|
+
if self.method == constants.INTERPOLATE_MEAN:
|
|
179
|
+
corrected[y, x] = np.mean(valid_pixels)
|
|
180
|
+
elif self.method == constants.INTERPOLATE_MEDIAN:
|
|
181
|
+
corrected[y, x] = np.median(valid_pixels)
|
|
182
|
+
return corrected
|