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.

Files changed (67) hide show
  1. shinestacker/__init__.py +3 -0
  2. shinestacker/_version.py +1 -0
  3. shinestacker/algorithms/__init__.py +14 -0
  4. shinestacker/algorithms/align.py +307 -0
  5. shinestacker/algorithms/balance.py +367 -0
  6. shinestacker/algorithms/core_utils.py +22 -0
  7. shinestacker/algorithms/depth_map.py +164 -0
  8. shinestacker/algorithms/exif.py +238 -0
  9. shinestacker/algorithms/multilayer.py +187 -0
  10. shinestacker/algorithms/noise_detection.py +182 -0
  11. shinestacker/algorithms/pyramid.py +176 -0
  12. shinestacker/algorithms/stack.py +112 -0
  13. shinestacker/algorithms/stack_framework.py +248 -0
  14. shinestacker/algorithms/utils.py +71 -0
  15. shinestacker/algorithms/vignetting.py +137 -0
  16. shinestacker/app/__init__.py +0 -0
  17. shinestacker/app/about_dialog.py +24 -0
  18. shinestacker/app/app_config.py +39 -0
  19. shinestacker/app/gui_utils.py +35 -0
  20. shinestacker/app/help_menu.py +16 -0
  21. shinestacker/app/main.py +176 -0
  22. shinestacker/app/open_frames.py +39 -0
  23. shinestacker/app/project.py +91 -0
  24. shinestacker/app/retouch.py +82 -0
  25. shinestacker/config/__init__.py +4 -0
  26. shinestacker/config/config.py +53 -0
  27. shinestacker/config/constants.py +174 -0
  28. shinestacker/config/gui_constants.py +85 -0
  29. shinestacker/core/__init__.py +5 -0
  30. shinestacker/core/colors.py +60 -0
  31. shinestacker/core/core_utils.py +52 -0
  32. shinestacker/core/exceptions.py +50 -0
  33. shinestacker/core/framework.py +210 -0
  34. shinestacker/core/logging.py +89 -0
  35. shinestacker/gui/__init__.py +0 -0
  36. shinestacker/gui/action_config.py +879 -0
  37. shinestacker/gui/actions_window.py +283 -0
  38. shinestacker/gui/colors.py +57 -0
  39. shinestacker/gui/gui_images.py +152 -0
  40. shinestacker/gui/gui_logging.py +213 -0
  41. shinestacker/gui/gui_run.py +393 -0
  42. shinestacker/gui/img/close-round-line-icon.png +0 -0
  43. shinestacker/gui/img/forward-button-icon.png +0 -0
  44. shinestacker/gui/img/play-button-round-icon.png +0 -0
  45. shinestacker/gui/img/plus-round-line-icon.png +0 -0
  46. shinestacker/gui/main_window.py +599 -0
  47. shinestacker/gui/new_project.py +170 -0
  48. shinestacker/gui/project_converter.py +148 -0
  49. shinestacker/gui/project_editor.py +539 -0
  50. shinestacker/gui/project_model.py +138 -0
  51. shinestacker/retouch/__init__.py +0 -0
  52. shinestacker/retouch/brush.py +9 -0
  53. shinestacker/retouch/brush_controller.py +57 -0
  54. shinestacker/retouch/brush_preview.py +126 -0
  55. shinestacker/retouch/exif_data.py +65 -0
  56. shinestacker/retouch/file_loader.py +104 -0
  57. shinestacker/retouch/image_editor.py +651 -0
  58. shinestacker/retouch/image_editor_ui.py +380 -0
  59. shinestacker/retouch/image_viewer.py +356 -0
  60. shinestacker/retouch/shortcuts_help.py +98 -0
  61. shinestacker/retouch/undo_manager.py +38 -0
  62. shinestacker-0.2.0.post1.dev1.dist-info/METADATA +55 -0
  63. shinestacker-0.2.0.post1.dev1.dist-info/RECORD +67 -0
  64. shinestacker-0.2.0.post1.dev1.dist-info/WHEEL +5 -0
  65. shinestacker-0.2.0.post1.dev1.dist-info/entry_points.txt +4 -0
  66. shinestacker-0.2.0.post1.dev1.dist-info/licenses/LICENSE +1 -0
  67. 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