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,176 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import cv2
|
|
3
|
+
from .. config.constants import constants
|
|
4
|
+
from .. core.colors import color_str
|
|
5
|
+
from .. core.exceptions import RunStopException, ImageLoadError, InvalidOptionError
|
|
6
|
+
from .utils import read_img, get_img_metadata, validate_image
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PyramidBase:
|
|
10
|
+
def __init__(self, min_size=constants.DEFAULT_PY_MIN_SIZE, kernel_size=constants.DEFAULT_PY_KERNEL_SIZE,
|
|
11
|
+
gen_kernel=constants.DEFAULT_PY_GEN_KERNEL, float_type=constants.DEFAULT_PY_FLOAT):
|
|
12
|
+
self.min_size = min_size
|
|
13
|
+
self.kernel_size = kernel_size
|
|
14
|
+
self.pad_amount = (kernel_size - 1) // 2
|
|
15
|
+
self.do_step_callback = False
|
|
16
|
+
kernel = np.array([0.25 - gen_kernel / 2.0, 0.25, gen_kernel, 0.25, 0.25 - gen_kernel / 2.0])
|
|
17
|
+
self.gen_kernel = np.outer(kernel, kernel)
|
|
18
|
+
if float_type == constants.FLOAT_32:
|
|
19
|
+
self.float_type = np.float32
|
|
20
|
+
elif float_type == constants.FLOAT_64:
|
|
21
|
+
self.float_type = np.float64
|
|
22
|
+
else:
|
|
23
|
+
raise InvalidOptionError("float_type", float_type, details=" valid values are FLOAT_32 and FLOAT_64")
|
|
24
|
+
|
|
25
|
+
def print_message(self, msg):
|
|
26
|
+
self.process.sub_message_r(color_str(msg, "light_blue"))
|
|
27
|
+
|
|
28
|
+
def steps_per_frame(self):
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
def convolve(self, image):
|
|
32
|
+
return cv2.filter2D(image, -1, self.gen_kernel, borderType=cv2.BORDER_REFLECT101)
|
|
33
|
+
|
|
34
|
+
def reduce_layer(self, layer):
|
|
35
|
+
if len(layer.shape) == 2:
|
|
36
|
+
return self.convolve(layer)[::2, ::2]
|
|
37
|
+
reduced_channels = [self.reduce_layer(layer[:, :, channel]) for channel in range(layer.shape[2])]
|
|
38
|
+
return np.stack(reduced_channels, axis=-1)
|
|
39
|
+
|
|
40
|
+
def expand_layer(self, layer):
|
|
41
|
+
if len(layer.shape) == 2:
|
|
42
|
+
expand = np.empty((2 * layer.shape[0], 2 * layer.shape[1]), dtype=layer.dtype)
|
|
43
|
+
expand[::2, ::2] = layer
|
|
44
|
+
expand[1::2, :] = 0
|
|
45
|
+
expand[:, 1::2] = 0
|
|
46
|
+
return 4. * self.convolve(expand)
|
|
47
|
+
ch_layer = self.expand_layer(layer[:, :, 0])
|
|
48
|
+
next_layer = np.zeros(list(ch_layer.shape) + [layer.shape[2]], dtype=layer.dtype)
|
|
49
|
+
next_layer[:, :, 0] = ch_layer
|
|
50
|
+
for channel in range(1, layer.shape[2]):
|
|
51
|
+
next_layer[:, :, channel] = self.expand_layer(layer[:, :, channel])
|
|
52
|
+
return next_layer
|
|
53
|
+
|
|
54
|
+
def fuse_laplacian(self, laplacians):
|
|
55
|
+
gray_laps = [cv2.cvtColor(lap.astype(np.float32), cv2.COLOR_BGR2GRAY) for lap in laplacians]
|
|
56
|
+
energies = [self.convolve(np.square(gray_lap)) for gray_lap in gray_laps]
|
|
57
|
+
best = np.argmax(energies, axis=0)
|
|
58
|
+
fused = np.zeros_like(laplacians[0])
|
|
59
|
+
for i, lap in enumerate(laplacians):
|
|
60
|
+
fused += np.where(best[:, :, np.newaxis] == i, lap, 0)
|
|
61
|
+
return fused
|
|
62
|
+
|
|
63
|
+
def collapse(self, pyramid):
|
|
64
|
+
img = pyramid[-1]
|
|
65
|
+
for layer in pyramid[-2::-1]:
|
|
66
|
+
expanded = self.expand_layer(img)
|
|
67
|
+
if expanded.shape != layer.shape:
|
|
68
|
+
expanded = expanded[:layer.shape[0], :layer.shape[1]]
|
|
69
|
+
img = expanded + layer
|
|
70
|
+
return np.clip(np.abs(img), 0, self.max_pixel_value)
|
|
71
|
+
|
|
72
|
+
def entropy(self, image):
|
|
73
|
+
levels, counts = np.unique(image.astype(self.dtype), return_counts=True)
|
|
74
|
+
probabilities = np.zeros((self.num_pixel_values), dtype=self.float_type)
|
|
75
|
+
probabilities[levels] = counts.astype(self.float_type) / counts.sum()
|
|
76
|
+
padded_image = cv2.copyMakeBorder(image, self.pad_amount, self.pad_amount, self.pad_amount,
|
|
77
|
+
self.pad_amount, cv2.BORDER_REFLECT101)
|
|
78
|
+
return np.fromfunction(np.vectorize(lambda row, column: self.area_entropy(
|
|
79
|
+
self.get_pad(padded_image, row, column), probabilities)), image.shape[:2], dtype=int)
|
|
80
|
+
|
|
81
|
+
def area_entropy(self, area, probabilities):
|
|
82
|
+
levels = area.flatten()
|
|
83
|
+
return self.float_type(-1. * (levels * np.log(probabilities[levels])).sum())
|
|
84
|
+
|
|
85
|
+
def get_pad(self, padded_image, row, column):
|
|
86
|
+
return padded_image[row + self.pad_amount + self.offset[:, np.newaxis], column + self.pad_amount + self.offset]
|
|
87
|
+
|
|
88
|
+
def area_deviation(self, area):
|
|
89
|
+
return np.square(area - np.average(area).astype(self.float_type)).sum() / area.size
|
|
90
|
+
|
|
91
|
+
def deviation(self, image):
|
|
92
|
+
padded_image = cv2.copyMakeBorder(image, self.pad_amount, self.pad_amount, self.pad_amount,
|
|
93
|
+
self.pad_amount, cv2.BORDER_REFLECT101)
|
|
94
|
+
return np.fromfunction(
|
|
95
|
+
np.vectorize(lambda row, column: self.area_deviation(self.get_pad(padded_image, row, column))),
|
|
96
|
+
image.shape[:2], dtype=int)
|
|
97
|
+
|
|
98
|
+
def get_fused_base(self, images):
|
|
99
|
+
layers, height, width, _ = images.shape
|
|
100
|
+
entropies = np.zeros(images.shape[:3], dtype=self.float_type)
|
|
101
|
+
deviations = np.copy(entropies)
|
|
102
|
+
gray_images = np.array([cv2.cvtColor(images[layer].astype(np.float32),
|
|
103
|
+
cv2.COLOR_BGR2GRAY).astype(self.dtype) for layer in range(layers)])
|
|
104
|
+
entropies = np.array([self.entropy(img) for img in gray_images])
|
|
105
|
+
deviations = np.array([self.deviation(img) for img in gray_images])
|
|
106
|
+
best_e = np.argmax(entropies, axis=0)
|
|
107
|
+
best_d = np.argmax(deviations, axis=0)
|
|
108
|
+
fused = np.zeros(images.shape[1:], dtype=self.float_type)
|
|
109
|
+
for layer in range(layers):
|
|
110
|
+
img = images[layer]
|
|
111
|
+
fused += np.where(best_e[:, :, np.newaxis] == layer, img, 0)
|
|
112
|
+
fused += np.where(best_d[:, :, np.newaxis] == layer, img, 0)
|
|
113
|
+
return (fused / 2).astype(images.dtype)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PyramidStack(PyramidBase):
|
|
117
|
+
def __init__(self, min_size=constants.DEFAULT_PY_MIN_SIZE, kernel_size=constants.DEFAULT_PY_KERNEL_SIZE,
|
|
118
|
+
gen_kernel=constants.DEFAULT_PY_GEN_KERNEL, float_type=constants.DEFAULT_PY_FLOAT):
|
|
119
|
+
super().__init__(min_size, kernel_size, gen_kernel, float_type)
|
|
120
|
+
self.offset = np.arange(-self.pad_amount, self.pad_amount + 1)
|
|
121
|
+
|
|
122
|
+
def name(self):
|
|
123
|
+
return "pyramid"
|
|
124
|
+
|
|
125
|
+
def process_single_image(self, img, levels):
|
|
126
|
+
pyramid = [img.astype(self.float_type)]
|
|
127
|
+
for _ in range(levels):
|
|
128
|
+
next_layer = self.reduce_layer(pyramid[-1])
|
|
129
|
+
if min(next_layer.shape[:2]) < 4:
|
|
130
|
+
break
|
|
131
|
+
pyramid.append(next_layer)
|
|
132
|
+
laplacian = [pyramid[-1]]
|
|
133
|
+
for level in range(len(pyramid) - 1, 0, -1):
|
|
134
|
+
expanded = self.expand_layer(pyramid[level])
|
|
135
|
+
pyr = pyramid[level - 1]
|
|
136
|
+
h, w = pyr.shape[:2]
|
|
137
|
+
expanded = expanded[:h, :w]
|
|
138
|
+
laplacian.append(pyr - expanded)
|
|
139
|
+
return laplacian[::-1]
|
|
140
|
+
|
|
141
|
+
def fuse_pyramids(self, all_laplacians):
|
|
142
|
+
fused = [self.get_fused_base(np.stack([p[-1] for p in all_laplacians], axis=0))]
|
|
143
|
+
for layer in range(len(all_laplacians[0]) - 2, -1, -1):
|
|
144
|
+
self.print_message(': fusing pyramids, layer: {}'.format(layer + 1))
|
|
145
|
+
laplacians = np.stack([p[layer] for p in all_laplacians], axis=0)
|
|
146
|
+
fused.append(self.fuse_laplacian(laplacians))
|
|
147
|
+
self.print_message(': pyramids fusion completed')
|
|
148
|
+
return fused[::-1]
|
|
149
|
+
|
|
150
|
+
def focus_stack(self, filenames):
|
|
151
|
+
metadata = None
|
|
152
|
+
all_laplacians = []
|
|
153
|
+
levels = None
|
|
154
|
+
for i, img_path in enumerate(filenames):
|
|
155
|
+
self.print_message(': validating file {}'.format(img_path.split('/')[-1]))
|
|
156
|
+
img = read_img(img_path)
|
|
157
|
+
if img is None:
|
|
158
|
+
raise ImageLoadError(img_path)
|
|
159
|
+
if metadata is None:
|
|
160
|
+
metadata = get_img_metadata(img)
|
|
161
|
+
self.dtype = metadata[1]
|
|
162
|
+
self.num_pixel_values = constants.NUM_UINT8 if self.dtype == np.uint8 else constants.NUM_UINT16
|
|
163
|
+
self.max_pixel_value = constants.MAX_UINT8 if self.dtype == np.uint8 else constants.MAX_UINT16
|
|
164
|
+
levels = int(np.log2(min(img.shape[:2]) / self.min_size))
|
|
165
|
+
else:
|
|
166
|
+
validate_image(img, *metadata)
|
|
167
|
+
if self.do_step_callback:
|
|
168
|
+
self.process.callback('after_step', self.process.id, self.process.name, i)
|
|
169
|
+
if self.process.callback('check_running', self.process.id, self.process.name) is False:
|
|
170
|
+
raise RunStopException(self.name)
|
|
171
|
+
for img_path in filenames:
|
|
172
|
+
self.print_message(': processing file {}'.format(img_path.split('/')[-1]))
|
|
173
|
+
img = read_img(img_path)
|
|
174
|
+
all_laplacians.append(self.process_single_image(img, levels))
|
|
175
|
+
stacked_image = self.collapse(self.fuse_pyramids(all_laplacians))
|
|
176
|
+
return stacked_image.astype(self.dtype)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import cv2
|
|
3
|
+
import os
|
|
4
|
+
from .. config.constants import constants
|
|
5
|
+
from .. core.colors import color_str
|
|
6
|
+
from .. core.framework import JobBase
|
|
7
|
+
from .. core.exceptions import InvalidOptionError
|
|
8
|
+
from .utils import write_img
|
|
9
|
+
from .stack_framework import FrameDirectory, ActionList
|
|
10
|
+
from .exif import copy_exif_from_file_to_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FocusStackBase:
|
|
14
|
+
def __init__(self, stack_algo, exif_path='',
|
|
15
|
+
prefix=constants.DEFAULT_STACK_PREFIX,
|
|
16
|
+
denoise=0, plot_stack=False):
|
|
17
|
+
self.stack_algo = stack_algo
|
|
18
|
+
self.exif_path = exif_path
|
|
19
|
+
self.prefix = prefix if prefix != '' else constants.DEFAULT_STACK_PREFIX
|
|
20
|
+
self.denoise = denoise
|
|
21
|
+
self.plot_stack = plot_stack
|
|
22
|
+
self.stack_algo.process = self
|
|
23
|
+
self.frame_count = -1
|
|
24
|
+
|
|
25
|
+
def focus_stack(self, filenames):
|
|
26
|
+
self.sub_message_r(': reading input files')
|
|
27
|
+
img_files = sorted([os.path.join(self.input_full_path, name) for name in filenames])
|
|
28
|
+
stacked_img = self.stack_algo.focus_stack(img_files)
|
|
29
|
+
in_filename = filenames[0].split(".")
|
|
30
|
+
out_filename = f"{self.output_dir}/{self.prefix}{in_filename[0]}." + '.'.join(in_filename[1:])
|
|
31
|
+
if self.denoise > 0:
|
|
32
|
+
self.sub_message_r(': denoise image')
|
|
33
|
+
stacked_img = cv2.fastNlMeansDenoisingColored(stacked_img, None, self.denoise, self.denoise, 7, 21)
|
|
34
|
+
write_img(out_filename, stacked_img)
|
|
35
|
+
if self.exif_path != '' and stacked_img.dtype == np.uint8:
|
|
36
|
+
self.sub_message_r(': copy exif data')
|
|
37
|
+
dirpath, _, fnames = next(os.walk(self.exif_path))
|
|
38
|
+
fnames = [name for name in fnames if os.path.splitext(name)[-1][1:].lower() in constants.EXTENSIONS]
|
|
39
|
+
exif_filename = f"{self.exif_path}/{fnames[0]}"
|
|
40
|
+
copy_exif_from_file_to_file(exif_filename, out_filename)
|
|
41
|
+
self.sub_message_r(' ' * 60)
|
|
42
|
+
if self.plot_stack:
|
|
43
|
+
idx_str = "{:04d}".format(self.frame_count) if self.frame_count >= 0 else ''
|
|
44
|
+
name = f"{self.name}: {self.stack_algo.name()}"
|
|
45
|
+
if idx_str != '':
|
|
46
|
+
name += f"\nbunch: {idx_str}"
|
|
47
|
+
self.callback('save_plot', self.id, name, out_filename)
|
|
48
|
+
if self.frame_count >= 0:
|
|
49
|
+
self.frame_count += 1
|
|
50
|
+
|
|
51
|
+
def init(self, job, working_path):
|
|
52
|
+
if self.exif_path is None:
|
|
53
|
+
self.exif_path = job.paths[0]
|
|
54
|
+
if self.exif_path != '':
|
|
55
|
+
self.exif_path = working_path + "/" + self.exif_path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FocusStackBunch(FocusStackBase, FrameDirectory, ActionList):
|
|
59
|
+
def __init__(self, name, stack_algo, enabled=True, **kwargs):
|
|
60
|
+
FocusStackBase.__init__(self, stack_algo,
|
|
61
|
+
exif_path=kwargs.pop('exif_path', ''),
|
|
62
|
+
prefix=kwargs.pop('prefix', constants.DEFAULT_STACK_PREFIX),
|
|
63
|
+
denoise=kwargs.pop('denoise', 0),
|
|
64
|
+
plot_stack=kwargs.pop('plot_stack', constants.DEFAULT_PLOT_STACK_BUNCH))
|
|
65
|
+
FrameDirectory.__init__(self, name, **kwargs)
|
|
66
|
+
ActionList.__init__(self, name, enabled)
|
|
67
|
+
self.frame_count = 0
|
|
68
|
+
self.frames = kwargs.get('frames', constants.DEFAULT_FRAMES)
|
|
69
|
+
self.overlap = kwargs.get('overlap', constants.DEFAULT_OVERLAP)
|
|
70
|
+
self.denoise = kwargs.get('denoise', 0)
|
|
71
|
+
self.stack_algo.do_step_callback = False
|
|
72
|
+
if self.overlap >= self.frames:
|
|
73
|
+
raise InvalidOptionError("overlap", self.overlap, "overlap must be smaller than batch size")
|
|
74
|
+
|
|
75
|
+
def init(self, job):
|
|
76
|
+
FrameDirectory.init(self, job)
|
|
77
|
+
FocusStackBase.init(self, job, self.working_path)
|
|
78
|
+
|
|
79
|
+
def begin(self):
|
|
80
|
+
ActionList.begin(self)
|
|
81
|
+
fnames = self.folder_filelist(self.input_full_path)
|
|
82
|
+
self.__chunks = [fnames[x:x + self.frames] for x in range(0, len(fnames) - self.overlap, self.frames - self.overlap)]
|
|
83
|
+
self.set_counts(len(self.__chunks))
|
|
84
|
+
|
|
85
|
+
def end(self):
|
|
86
|
+
ActionList.end(self)
|
|
87
|
+
|
|
88
|
+
def run_step(self):
|
|
89
|
+
self.print_message_r(color_str("fusing bunch: {}".format(self.count), "blue"))
|
|
90
|
+
self.focus_stack(self.__chunks[self.count - 1])
|
|
91
|
+
self.callback('after_step', self.id, self.name, self.count)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FocusStack(FocusStackBase, FrameDirectory, JobBase):
|
|
95
|
+
def __init__(self, name, stack_algo, enabled=True, **kwargs):
|
|
96
|
+
FocusStackBase.__init__(self, stack_algo,
|
|
97
|
+
exif_path=kwargs.pop('exif_path', ''),
|
|
98
|
+
prefix=kwargs.pop('prefix', constants.DEFAULT_STACK_PREFIX),
|
|
99
|
+
denoise=kwargs.pop('denoise', 0),
|
|
100
|
+
plot_stack=kwargs.pop('plot_stack', constants.DEFAULT_PLOT_STACK))
|
|
101
|
+
FrameDirectory.__init__(self, name, **kwargs)
|
|
102
|
+
JobBase.__init__(self, name, enabled)
|
|
103
|
+
self.stack_algo.do_step_callback = True
|
|
104
|
+
|
|
105
|
+
def run_core(self):
|
|
106
|
+
self.set_filelist()
|
|
107
|
+
self.callback('step_counts', self.id, self.name, self.stack_algo.steps_per_frame() * len(self.filenames))
|
|
108
|
+
self.focus_stack(self.filenames)
|
|
109
|
+
|
|
110
|
+
def init(self, job):
|
|
111
|
+
FrameDirectory.init(self, job)
|
|
112
|
+
FocusStackBase.init(self, job, self.working_path)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from .. config.constants import constants
|
|
4
|
+
from .. core.colors import color_str
|
|
5
|
+
from .. core.framework import Job, ActionList
|
|
6
|
+
from .. core.core_utils import check_path_exists
|
|
7
|
+
from .. core.exceptions import ShapeError, BitDepthError, RunStopException
|
|
8
|
+
from .utils import read_img, write_img
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StackJob(Job):
|
|
12
|
+
def __init__(self, name, working_path, input_path='', **kwargs):
|
|
13
|
+
check_path_exists(working_path)
|
|
14
|
+
self.working_path = working_path
|
|
15
|
+
if input_path == '':
|
|
16
|
+
self.paths = []
|
|
17
|
+
else:
|
|
18
|
+
self.paths = [input_path]
|
|
19
|
+
Job.__init__(self, name, **kwargs)
|
|
20
|
+
|
|
21
|
+
def init(self, a):
|
|
22
|
+
a.init(self)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FramePaths:
|
|
26
|
+
def __init__(self, name, input_path='', output_path='', working_path='', plot_path=constants.DEFAULT_PLOTS_PATH,
|
|
27
|
+
scratch_output_dir=True, resample=1, reverse_order=constants.DEFAULT_FILE_REVERSE_ORDER, **kwargs):
|
|
28
|
+
self.name = name
|
|
29
|
+
self.working_path = working_path
|
|
30
|
+
self.plot_path = plot_path
|
|
31
|
+
self.input_path = input_path
|
|
32
|
+
self.output_path = output_path
|
|
33
|
+
self.resample = resample
|
|
34
|
+
self.reverse_order = reverse_order
|
|
35
|
+
self.scratch_output_dir = scratch_output_dir
|
|
36
|
+
|
|
37
|
+
def set_filelist(self):
|
|
38
|
+
self.filenames = self.folder_filelist(self.input_full_path)
|
|
39
|
+
file_list = self.input_full_path.replace(self.working_path, '').lstrip('/')
|
|
40
|
+
self.print_message(color_str(": {} files ".format(len(self.filenames)) + "in folder: " + file_list, 'blue'))
|
|
41
|
+
self.print_message(color_str("focus stacking", 'blue'))
|
|
42
|
+
|
|
43
|
+
def init(self, job):
|
|
44
|
+
if self.working_path == '':
|
|
45
|
+
self.working_path = job.working_path
|
|
46
|
+
check_path_exists(self.working_path)
|
|
47
|
+
if self.output_path == '':
|
|
48
|
+
self.output_path = self.name
|
|
49
|
+
self.output_dir = self.working_path + ('' if self.working_path[-1] == '/' else '/') + self.output_path
|
|
50
|
+
if not os.path.exists(self.output_dir):
|
|
51
|
+
os.makedirs(self.output_dir)
|
|
52
|
+
else:
|
|
53
|
+
list_dir = os.listdir(self.output_dir)
|
|
54
|
+
if len(list_dir) > 0:
|
|
55
|
+
if self.scratch_output_dir:
|
|
56
|
+
if self.enabled:
|
|
57
|
+
for filename in list_dir:
|
|
58
|
+
file_path = os.path.join(self.output_dir, filename)
|
|
59
|
+
if os.path.isfile(file_path):
|
|
60
|
+
os.remove(file_path)
|
|
61
|
+
self.print_message(color_str(f": output directory {self.output_path} content erased", 'yellow'))
|
|
62
|
+
else:
|
|
63
|
+
self.print_message(color_str(f": module disabled, output directory {self.output_path} not scratched", 'yellow'))
|
|
64
|
+
else:
|
|
65
|
+
self.print_message(color_str(f": output directory {self.output_path} not empty, "
|
|
66
|
+
"files may be overwritten or merged with existing ones.", 'yellow'), level=logging.WARNING) # noqa
|
|
67
|
+
if self.plot_path == '':
|
|
68
|
+
self.plot_path = self.working_path + ('' if self.working_path[-1] == '/' else '/') + self.plot_path
|
|
69
|
+
if not os.path.exists(self.plot_path):
|
|
70
|
+
os.makedirs(self.plot_path)
|
|
71
|
+
if self.input_path == '':
|
|
72
|
+
if len(job.paths) == 0:
|
|
73
|
+
raise Exception(f"Job {job.name} does not have any configured path")
|
|
74
|
+
self.input_path = job.paths[-1]
|
|
75
|
+
job.paths.append(self.output_path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FrameDirectory(FramePaths):
|
|
79
|
+
def __init__(self, name, **kwargs):
|
|
80
|
+
FramePaths.__init__(self, name, **kwargs)
|
|
81
|
+
|
|
82
|
+
def folder_list_str(self):
|
|
83
|
+
if isinstance(self.input_full_path, list):
|
|
84
|
+
file_list = ", ".join([i for i in self.input_full_path.replace(self.working_path, '').lstrip('/')])
|
|
85
|
+
return "folder{}: ".format('s' if len(self.input_full_path) > 1 else '') + file_list
|
|
86
|
+
else:
|
|
87
|
+
return "folder: " + self.input_full_path.replace(self.working_path, '').lstrip('/')
|
|
88
|
+
|
|
89
|
+
def folder_filelist(self, path):
|
|
90
|
+
src_contents = os.walk(self.input_full_path)
|
|
91
|
+
dirpath, _, filenames = next(src_contents)
|
|
92
|
+
filelist = [name for name in filenames if os.path.splitext(name)[-1][1:].lower() in constants.EXTENSIONS]
|
|
93
|
+
filelist.sort()
|
|
94
|
+
if self.reverse_order:
|
|
95
|
+
filelist.reverse()
|
|
96
|
+
if self.resample > 1:
|
|
97
|
+
filelist = filelist[0::self.resample]
|
|
98
|
+
return filelist
|
|
99
|
+
|
|
100
|
+
def init(self, job):
|
|
101
|
+
FramePaths.init(self, job)
|
|
102
|
+
self.input_full_path = self.working_path + ('' if self.working_path[-1] == '/' else '/') + self.input_path
|
|
103
|
+
check_path_exists(self.input_full_path)
|
|
104
|
+
job.paths.append(self.output_path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FrameMultiDirectory:
|
|
108
|
+
def __init__(self, name, input_path='', output_path='', working_path='', plot_path=constants.DEFAULT_PLOTS_PATH,
|
|
109
|
+
scratch_output_dir=True, resample=1, reverse_order=constants.DEFAULT_FILE_REVERSE_ORDER, **kwargs):
|
|
110
|
+
FramePaths.__init__(self, name, input_path, output_path, working_path, plot_path, scratch_output_dir, resample, reverse_order, **kwargs)
|
|
111
|
+
|
|
112
|
+
def folder_list_str(self):
|
|
113
|
+
if isinstance(self.input_full_path, list):
|
|
114
|
+
file_list = ", ".join([d.replace(self.working_path, '').lstrip('/') for d in self.input_full_path])
|
|
115
|
+
return "folder{}: ".format('s' if len(self.input_full_path) > 1 else '') + file_list
|
|
116
|
+
else:
|
|
117
|
+
return "folder: " + self.input_full_path.replace(self.working_path, '').lstrip('/')
|
|
118
|
+
|
|
119
|
+
def folder_filelist(self):
|
|
120
|
+
if isinstance(self.input_full_path, str):
|
|
121
|
+
dirs = [self.input_full_path]
|
|
122
|
+
paths = [self.input_path]
|
|
123
|
+
elif hasattr(self.input_full_path, "__len__"):
|
|
124
|
+
dirs = self.input_full_path
|
|
125
|
+
paths = self.input_path
|
|
126
|
+
else:
|
|
127
|
+
raise Exception("input_full_path option must contain a path or an array of paths")
|
|
128
|
+
files = []
|
|
129
|
+
for d, p in zip(dirs, paths):
|
|
130
|
+
filelist = []
|
|
131
|
+
for dirpath, _, filenames in os.walk(d):
|
|
132
|
+
filelist = [p + "/" + name for name in filenames if os.path.splitext(name)[-1][1:].lower() in constants.EXTENSIONS]
|
|
133
|
+
if self.reverse_order:
|
|
134
|
+
filelist.reverse()
|
|
135
|
+
if self.resample > 1:
|
|
136
|
+
filelist = filelist[0::self.resample]
|
|
137
|
+
files += filelist
|
|
138
|
+
if len(files) == 0:
|
|
139
|
+
self.print_message(color_str(f"input folder {p} does not contain any image", "red"), level=logging.WARNING)
|
|
140
|
+
return files
|
|
141
|
+
|
|
142
|
+
def init(self, job):
|
|
143
|
+
FramePaths.init(self, job)
|
|
144
|
+
if isinstance(self.input_path, str):
|
|
145
|
+
self.input_full_path = self.working_path + ('' if self.working_path[-1] == '/' else '/') + self.input_path
|
|
146
|
+
check_path_exists(self.input_full_path)
|
|
147
|
+
elif hasattr(self.input_path, "__len__"):
|
|
148
|
+
self.input_full_path = []
|
|
149
|
+
for path in self.input_path:
|
|
150
|
+
self.input_full_path.append(self.working_path + ('' if self.working_path[-1] == '/' else '/') + path)
|
|
151
|
+
job.paths.append(self.output_path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FramesRefActions(FrameDirectory, ActionList):
|
|
155
|
+
def __init__(self, name, enabled=True, ref_idx=-1, step_process=False, **kwargs):
|
|
156
|
+
FrameDirectory.__init__(self, name, **kwargs)
|
|
157
|
+
ActionList.__init__(self, name, enabled)
|
|
158
|
+
self.ref_idx = ref_idx
|
|
159
|
+
self.step_process = step_process
|
|
160
|
+
|
|
161
|
+
def begin(self):
|
|
162
|
+
ActionList.begin(self)
|
|
163
|
+
self.set_filelist()
|
|
164
|
+
self.set_counts(len(self.filenames))
|
|
165
|
+
if self.ref_idx == -1:
|
|
166
|
+
self.ref_idx = len(self.filenames) // 2
|
|
167
|
+
|
|
168
|
+
def end(self):
|
|
169
|
+
ActionList.end(self)
|
|
170
|
+
|
|
171
|
+
def run_frame(self, idx, ref_idx):
|
|
172
|
+
assert False, 'abstract method'
|
|
173
|
+
|
|
174
|
+
def run_step(self):
|
|
175
|
+
if self.count == 1:
|
|
176
|
+
self.__idx = self.ref_idx if self.step_process else 0
|
|
177
|
+
self.__ref_idx = self.ref_idx
|
|
178
|
+
self.__idx_step = +1
|
|
179
|
+
ll = len(self.filenames)
|
|
180
|
+
self.print_message_r(
|
|
181
|
+
color_str("step {}/{}: process file: {}, reference: {}".format(self.count, ll, self.filenames[self.__idx],
|
|
182
|
+
self.filenames[self.__ref_idx]), "blue"))
|
|
183
|
+
self.run_frame(self.__idx, self.__ref_idx)
|
|
184
|
+
if self.__idx < ll:
|
|
185
|
+
if self.step_process:
|
|
186
|
+
self.__ref_idx = self.__idx
|
|
187
|
+
self.__idx += self.__idx_step
|
|
188
|
+
if self.__idx == ll:
|
|
189
|
+
self.__idx = self.ref_idx - 1
|
|
190
|
+
if self.step_process:
|
|
191
|
+
self.__ref_idx = self.ref_idx
|
|
192
|
+
self.__idx_step = -1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SubAction:
|
|
196
|
+
def __init__(self, enabled=True):
|
|
197
|
+
self.enabled = enabled
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CombinedActions(FramesRefActions):
|
|
201
|
+
def __init__(self, name, actions=[], enabled=True, **kwargs):
|
|
202
|
+
FramesRefActions.__init__(self, name, enabled, **kwargs)
|
|
203
|
+
self.__actions = actions
|
|
204
|
+
|
|
205
|
+
def begin(self):
|
|
206
|
+
FramesRefActions.begin(self)
|
|
207
|
+
for a in self.__actions:
|
|
208
|
+
if a.enabled:
|
|
209
|
+
a.begin(self)
|
|
210
|
+
|
|
211
|
+
def img_ref(self, idx):
|
|
212
|
+
filename = self.filenames[idx]
|
|
213
|
+
img = read_img((self.output_dir if self.step_process else self.input_full_path) + "/" + filename)
|
|
214
|
+
self.dtype = img.dtype
|
|
215
|
+
self.shape = img.shape
|
|
216
|
+
if img is None:
|
|
217
|
+
raise Exception("Invalid file: " + self.input_full_path + "/" + filename)
|
|
218
|
+
return img
|
|
219
|
+
|
|
220
|
+
def run_frame(self, idx, ref_idx):
|
|
221
|
+
filename = self.filenames[idx]
|
|
222
|
+
self.sub_message_r(': read input image')
|
|
223
|
+
img = read_img(self.input_full_path + "/" + filename)
|
|
224
|
+
if hasattr(self, 'dtype') and img.dtype != self.dtype:
|
|
225
|
+
raise BitDepthError(img.dtype, self.dtype)
|
|
226
|
+
if hasattr(self, 'shape') and img.shape != self.shape:
|
|
227
|
+
raise ShapeError(img.shape, self.shape)
|
|
228
|
+
if img is None:
|
|
229
|
+
raise Exception("Invalid file: " + self.input_full_path + "/" + filename)
|
|
230
|
+
if len(self.__actions) == 0:
|
|
231
|
+
self.sub_message(color_str(": no actions specified.", "red"), level=logging.WARNING)
|
|
232
|
+
for a in self.__actions:
|
|
233
|
+
if not a.enabled:
|
|
234
|
+
self.get_logger().warning(color_str(self.base_message + ": sub-action disabled", 'red'))
|
|
235
|
+
else:
|
|
236
|
+
if self.callback('check_running', self.id, self.name) is False:
|
|
237
|
+
raise RunStopException(self.name)
|
|
238
|
+
img = a.run_frame(idx, ref_idx, img)
|
|
239
|
+
self.sub_message_r(': write output image')
|
|
240
|
+
if img is not None:
|
|
241
|
+
write_img(self.output_dir + "/" + filename, img)
|
|
242
|
+
else:
|
|
243
|
+
self.print_message("No output file resulted from processing input file: " + self.input_full_path + "/" + filename, level=logging.WARNING)
|
|
244
|
+
|
|
245
|
+
def end(self):
|
|
246
|
+
for a in self.__actions:
|
|
247
|
+
if a.enabled:
|
|
248
|
+
a.end()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import os
|
|
3
|
+
import numpy as np
|
|
4
|
+
import logging
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
from .. config.config import config
|
|
7
|
+
from .. core.exceptions import ShapeError, BitDepthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_img(file_path):
|
|
11
|
+
if not os.path.isfile(file_path):
|
|
12
|
+
raise Exception("File does not exist: " + file_path)
|
|
13
|
+
ext = file_path.split(".")[-1]
|
|
14
|
+
if ext == 'jpeg' or ext == 'jpg':
|
|
15
|
+
img = cv2.imread(file_path)
|
|
16
|
+
elif ext == 'tiff' or ext == 'tif' or ext == 'png':
|
|
17
|
+
img = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
|
|
18
|
+
return img
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_img(file_path, img):
|
|
22
|
+
ext = file_path.split(".")[-1]
|
|
23
|
+
if ext == 'jpeg' or ext == 'jpg':
|
|
24
|
+
cv2.imwrite(file_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|
25
|
+
elif ext == 'tiff' or ext == 'tif':
|
|
26
|
+
cv2.imwrite(file_path, img, [int(cv2.IMWRITE_TIFF_COMPRESSION), 1])
|
|
27
|
+
elif ext == 'png':
|
|
28
|
+
cv2.imwrite(file_path, img)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def img_8bit(img):
|
|
32
|
+
return (img >> 8).astype('uint8') if img.dtype == np.uint16 else img
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def img_bw_8bit(img):
|
|
36
|
+
img = img_8bit(img)
|
|
37
|
+
if len(img.shape) == 3:
|
|
38
|
+
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
39
|
+
elif len(img.shape) == 2:
|
|
40
|
+
return img
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Unsupported image format: {img.shape}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def img_bw(img):
|
|
46
|
+
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_img_metadata(img):
|
|
50
|
+
return img.shape[:2], img.dtype
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_image(img, expected_shape=None, expected_dtype=None):
|
|
54
|
+
shape, dtype = get_img_metadata(img)
|
|
55
|
+
if expected_shape and shape[:2] != expected_shape[:2]:
|
|
56
|
+
raise ShapeError(expected_shape, shape)
|
|
57
|
+
if expected_dtype and dtype != expected_dtype:
|
|
58
|
+
raise BitDepthError(expected_dtype, dtype)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_plot(filename):
|
|
62
|
+
logging.getLogger(__name__).debug("save plot file: " + filename)
|
|
63
|
+
dir_path = os.path.dirname(filename)
|
|
64
|
+
if not dir_path:
|
|
65
|
+
dir_path = '.'
|
|
66
|
+
if not os.path.isdir(dir_path):
|
|
67
|
+
os.makedirs(dir_path)
|
|
68
|
+
plt.savefig(filename, dpi=150)
|
|
69
|
+
if config.JUPYTER_NOTEBOOK:
|
|
70
|
+
plt.show()
|
|
71
|
+
plt.close('all')
|