shinestacker 0.3.6__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shinestacker might be problematic. Click here for more details.
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/align.py +37 -20
- shinestacker/algorithms/balance.py +2 -1
- shinestacker/algorithms/base_stack_algo.py +2 -1
- shinestacker/algorithms/multilayer.py +11 -8
- shinestacker/algorithms/noise_detection.py +13 -7
- shinestacker/algorithms/stack.py +5 -4
- shinestacker/algorithms/stack_framework.py +12 -10
- shinestacker/app/about_dialog.py +69 -1
- shinestacker/app/main.py +1 -1
- shinestacker/config/config.py +1 -0
- shinestacker/config/constants.py +8 -1
- shinestacker/config/gui_constants.py +7 -5
- shinestacker/core/framework.py +15 -10
- shinestacker/gui/action_config.py +11 -7
- shinestacker/gui/actions_window.py +8 -0
- shinestacker/gui/gui_logging.py +8 -7
- shinestacker/gui/gui_run.py +8 -8
- shinestacker/gui/main_window.py +17 -12
- shinestacker/gui/new_project.py +31 -17
- shinestacker/gui/project_converter.py +0 -1
- shinestacker/gui/select_path_widget.py +3 -1
- shinestacker/retouch/brush_tool.py +23 -6
- shinestacker/retouch/display_manager.py +57 -20
- shinestacker/retouch/image_editor.py +5 -9
- shinestacker/retouch/image_editor_ui.py +55 -16
- shinestacker/retouch/image_viewer.py +104 -20
- shinestacker/retouch/io_gui_handler.py +74 -24
- shinestacker/retouch/io_manager.py +23 -8
- shinestacker/retouch/layer_collection.py +2 -1
- {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/METADATA +5 -4
- {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/RECORD +36 -36
- shinestacker-0.5.0.dist-info/licenses/LICENSE +165 -0
- shinestacker-0.3.6.dist-info/licenses/LICENSE +0 -1
- {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/WHEEL +0 -0
- {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/entry_points.txt +0 -0
- {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/top_level.txt +0 -0
shinestacker/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.
|
|
1
|
+
__version__ = '0.5.0'
|
shinestacker/algorithms/align.py
CHANGED
|
@@ -5,6 +5,7 @@ import matplotlib.pyplot as plt
|
|
|
5
5
|
import cv2
|
|
6
6
|
from .. config.constants import constants
|
|
7
7
|
from .. core.exceptions import AlignmentError, InvalidOptionError
|
|
8
|
+
from .. core.colors import color_str
|
|
8
9
|
from .utils import img_8bit, img_bw_8bit, save_plot
|
|
9
10
|
from .utils import get_img_metadata, validate_image
|
|
10
11
|
from .stack_framework import SubAction
|
|
@@ -33,7 +34,8 @@ _DEFAULT_ALIGNMENT_CONFIG = {
|
|
|
33
34
|
'border_value': constants.DEFAULT_BORDER_VALUE,
|
|
34
35
|
'border_blur': constants.DEFAULT_BORDER_BLUR,
|
|
35
36
|
'subsample': constants.DEFAULT_ALIGN_SUBSAMPLE,
|
|
36
|
-
'fast_subsampling': constants.DEFAULT_ALIGN_FAST_SUBSAMPLING
|
|
37
|
+
'fast_subsampling': constants.DEFAULT_ALIGN_FAST_SUBSAMPLING,
|
|
38
|
+
'min_good_matches': constants.DEFAULT_ALIGN_MIN_GOOD_MATCHES
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
|
|
@@ -164,21 +166,31 @@ def align_images(img_1, img_0, feature_config=None, matching_config=None, alignm
|
|
|
164
166
|
if callbacks and 'message' in callbacks:
|
|
165
167
|
callbacks['message']()
|
|
166
168
|
subsample = alignment_config['subsample']
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
min_good_matches = alignment_config['min_good_matches']
|
|
170
|
+
while True:
|
|
171
|
+
if subsample > 1:
|
|
172
|
+
if alignment_config['fast_subsampling']:
|
|
173
|
+
img_0_sub, img_1_sub = \
|
|
174
|
+
img_0[::subsample, ::subsample], img_1[::subsample, ::subsample]
|
|
175
|
+
else:
|
|
176
|
+
img_0_sub = cv2.resize(img_0, (0, 0),
|
|
177
|
+
fx=1 / subsample, fy=1 / subsample,
|
|
178
|
+
interpolation=cv2.INTER_AREA)
|
|
179
|
+
img_1_sub = cv2.resize(img_1, (0, 0),
|
|
180
|
+
fx=1 / subsample, fy=1 / subsample,
|
|
181
|
+
interpolation=cv2.INTER_AREA)
|
|
170
182
|
else:
|
|
171
|
-
img_0_sub =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
img_0_sub, img_1_sub = img_0, img_1
|
|
184
|
+
kp_0, kp_1, good_matches = detect_and_compute(img_0_sub, img_1_sub,
|
|
185
|
+
feature_config, matching_config)
|
|
186
|
+
n_good_matches = len(good_matches)
|
|
187
|
+
if n_good_matches > min_good_matches or subsample == 1:
|
|
188
|
+
break
|
|
189
|
+
subsample = 1
|
|
190
|
+
if callbacks and 'warning' in callbacks:
|
|
191
|
+
callbacks['warning'](
|
|
192
|
+
f"only {n_good_matches} < {min_good_matches} matches found, "
|
|
193
|
+
"retrying without subsampling")
|
|
182
194
|
if callbacks and 'matches_message' in callbacks:
|
|
183
195
|
callbacks['matches_message'](n_good_matches)
|
|
184
196
|
img_warp = None
|
|
@@ -277,14 +289,19 @@ class AlignFrames(SubAction):
|
|
|
277
289
|
img_ref = self.process.img_ref(ref_idx)
|
|
278
290
|
return self.align_images(idx, img_ref, img_0)
|
|
279
291
|
|
|
292
|
+
def sub_msg(self, msg, color=constants.LOG_COLOR_LEVEL_3):
|
|
293
|
+
self.process.sub_message_r(color_str(msg, color))
|
|
294
|
+
|
|
280
295
|
def align_images(self, idx, img_1, img_0):
|
|
281
296
|
idx_str = f"{idx:04d}"
|
|
282
297
|
callbacks = {
|
|
283
|
-
'message': lambda: self.
|
|
284
|
-
'matches_message': lambda n: self.
|
|
285
|
-
'align_message': lambda: self.
|
|
286
|
-
'ecc_message': lambda: self.
|
|
287
|
-
'blur_message': lambda: self.
|
|
298
|
+
'message': lambda: self.sub_msg(': find matches'),
|
|
299
|
+
'matches_message': lambda n: self.sub_msg(f": good matches: {n}"),
|
|
300
|
+
'align_message': lambda: self.sub_msg(': align images'),
|
|
301
|
+
'ecc_message': lambda: self.sub_msg(": ecc refinement"),
|
|
302
|
+
'blur_message': lambda: self.sub_msg(': blur borders'),
|
|
303
|
+
'warning': lambda msg: self.sub_msg(
|
|
304
|
+
f': {msg}', constants.LOG_COLOR_ALERT),
|
|
288
305
|
'save_plot': lambda plot_path: self.process.callback(
|
|
289
306
|
'save_plot', self.process.id,
|
|
290
307
|
f"{self.process.name}: matches\nframe {idx_str}", plot_path)
|
|
@@ -6,6 +6,7 @@ from scipy.optimize import bisect
|
|
|
6
6
|
from scipy.interpolate import interp1d
|
|
7
7
|
from .. config.constants import constants
|
|
8
8
|
from .. core.exceptions import InvalidOptionError
|
|
9
|
+
from .. core.colors import color_str
|
|
9
10
|
from .utils import read_img, save_plot
|
|
10
11
|
from .stack_framework import SubAction
|
|
11
12
|
|
|
@@ -408,6 +409,6 @@ class BalanceFrames(SubAction):
|
|
|
408
409
|
|
|
409
410
|
def run_frame(self, idx, _ref_idx, image):
|
|
410
411
|
if idx != self.process.ref_idx:
|
|
411
|
-
self.process.sub_message_r(': balance image')
|
|
412
|
+
self.process.sub_message_r(color_str(': balance image', constants.LOG_COLOR_LEVEL_3))
|
|
412
413
|
image = self.correction.apply_correction(idx, image)
|
|
413
414
|
return image
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import numpy as np
|
|
3
3
|
from .. core.exceptions import InvalidOptionError, ImageLoadError
|
|
4
4
|
from .. config.constants import constants
|
|
5
|
+
from .. core.colors import color_str
|
|
5
6
|
from .utils import read_img, get_img_metadata, validate_image
|
|
6
7
|
|
|
7
8
|
|
|
@@ -27,7 +28,7 @@ class BaseStackAlgo:
|
|
|
27
28
|
return self._steps_per_frame
|
|
28
29
|
|
|
29
30
|
def print_message(self, msg):
|
|
30
|
-
self.process.sub_message_r(msg)
|
|
31
|
+
self.process.sub_message_r(color_str(msg, constants.LOG_COLOR_LEVEL_3))
|
|
31
32
|
|
|
32
33
|
def read_image_and_update_metadata(self, img_path, metadata):
|
|
33
34
|
img = read_img(img_path)
|
|
@@ -176,7 +176,9 @@ class MultiLayer(JobBase, FrameMultiDirectory):
|
|
|
176
176
|
else:
|
|
177
177
|
raise RuntimeError("input_path option must contain a path or an array of paths")
|
|
178
178
|
if len(paths) == 0:
|
|
179
|
-
self.print_message(color_str("no input paths specified",
|
|
179
|
+
self.print_message(color_str("no input paths specified",
|
|
180
|
+
constants.LOG_COLOR_LEVEL_ALERT),
|
|
181
|
+
level=logging.WARNING)
|
|
180
182
|
return
|
|
181
183
|
files = self.folder_filelist()
|
|
182
184
|
if len(files) == 0:
|
|
@@ -184,22 +186,23 @@ class MultiLayer(JobBase, FrameMultiDirectory):
|
|
|
184
186
|
color_str(f"no input in {len(paths)} specified path" +
|
|
185
187
|
('s' if len(paths) > 1 else '') + ": "
|
|
186
188
|
", ".join([f"'{p}'" for p in paths]),
|
|
187
|
-
|
|
189
|
+
constants.LOG_COLOR_LEVEL_ALERT),
|
|
188
190
|
level=logging.WARNING)
|
|
189
191
|
return
|
|
190
|
-
self.print_message(color_str("merging frames in " + self.folder_list_str(),
|
|
192
|
+
self.print_message(color_str("merging frames in " + self.folder_list_str(),
|
|
193
|
+
constants.LOG_COLOR_LEVEL_2))
|
|
191
194
|
input_files = [f"{self.working_path}/{f}" for f in files]
|
|
192
195
|
self.print_message(
|
|
193
|
-
color_str("frames: " + ", ".join([i.split("/")[-1] for i in files]),
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
color_str("frames: " + ", ".join([i.split("/")[-1] for i in files]),
|
|
197
|
+
constants.LOG_COLOR_LEVEL_2))
|
|
198
|
+
self.print_message(color_str("reading files", constants.LOG_COLOR_LEVEL_2))
|
|
196
199
|
filename = ".".join(files[0].split("/")[-1].split(".")[:-1])
|
|
197
200
|
output_file = f"{self.working_path}/{self.output_path}/{filename}.tif"
|
|
198
201
|
callbacks = {
|
|
199
202
|
'exif_msg': lambda path: self.print_message(
|
|
200
|
-
color_str(f"copying exif data from path: {path}",
|
|
203
|
+
color_str(f"copying exif data from path: {path}", constants.LOG_COLOR_LEVEL_2)),
|
|
201
204
|
'write_msg': lambda path: self.print_message(
|
|
202
|
-
color_str(f"writing multilayer tiff file: {path}",
|
|
205
|
+
color_str(f"writing multilayer tiff file: {path}", constants.LOG_COLOR_LEVEL_2))
|
|
203
206
|
}
|
|
204
207
|
write_multilayer_tiff(input_files, output_file, labels=None, exif_path=self.exif_path,
|
|
205
208
|
callbacks=callbacks)
|
|
@@ -73,7 +73,8 @@ class NoiseDetection(JobBase, FrameMultiDirectory):
|
|
|
73
73
|
|
|
74
74
|
def run_core(self):
|
|
75
75
|
self.print_message(color_str(
|
|
76
|
-
f"map noisy pixels from frames in {self.folder_list_str()}",
|
|
76
|
+
f"map noisy pixels from frames in {self.folder_list_str()}",
|
|
77
|
+
constants.LOG_COLOR_LEVEL_2
|
|
77
78
|
))
|
|
78
79
|
files = self.folder_filelist()
|
|
79
80
|
in_paths = [self.working_path + "/" + f for f in files]
|
|
@@ -89,7 +90,7 @@ class NoiseDetection(JobBase, FrameMultiDirectory):
|
|
|
89
90
|
mean_img = mean_image(
|
|
90
91
|
file_paths=in_paths, max_frames=self.max_frames,
|
|
91
92
|
message_callback=lambda path: self.print_message_r(
|
|
92
|
-
color_str(f"reading frame: {path.split('/')[-1]}",
|
|
93
|
+
color_str(f"reading frame: {path.split('/')[-1]}", constants.LOG_COLOR_LEVEL_2)
|
|
93
94
|
),
|
|
94
95
|
progress_callback=progress_callback)
|
|
95
96
|
if not config.DISABLE_TQDM:
|
|
@@ -103,13 +104,16 @@ class NoiseDetection(JobBase, FrameMultiDirectory):
|
|
|
103
104
|
hot_rgb = cv2.bitwise_or(hot_px[0], cv2.bitwise_or(hot_px[1], hot_px[2]))
|
|
104
105
|
msg = []
|
|
105
106
|
for ch, hot in zip(['rgb', *constants.RGB_LABELS], [hot_rgb] + hot_px):
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
hpx = color_str(f"{ch}: {np.count_nonzero(hot > 0)}",
|
|
108
|
+
{'rgb': 'black', 'r': 'red', 'g': 'green', 'b': 'blue'}[ch])
|
|
109
|
+
msg.append(hpx)
|
|
110
|
+
self.print_message(color_str("hot pixels: " + ", ".join(msg), constants.LOG_COLOR_LEVEL_2))
|
|
108
111
|
path = "/".join(self.file_name.split("/")[:-1])
|
|
109
112
|
if not os.path.exists(f"{self.working_path}/{path}"):
|
|
110
113
|
self.print_message(f"create directory: {path}")
|
|
111
114
|
os.mkdir(f"{self.working_path}/{path}")
|
|
112
|
-
self.print_message(f"writing hot pixels map file: {self.file_name}"
|
|
115
|
+
self.print_message(color_str(f"writing hot pixels map file: {self.file_name}",
|
|
116
|
+
constants.LOG_COLOR_LEVEL_2))
|
|
113
117
|
cv2.imwrite(f"{self.working_path}/{self.file_name}", hot_rgb)
|
|
114
118
|
plot_range = self.plot_range
|
|
115
119
|
min_th, max_th = min(self.channel_thresholds), max(self.channel_thresholds)
|
|
@@ -155,7 +159,9 @@ class MaskNoise(SubAction):
|
|
|
155
159
|
self.process = process
|
|
156
160
|
path = f"{process.working_path}/{self.noise_mask}"
|
|
157
161
|
if os.path.exists(path):
|
|
158
|
-
self.process.sub_message_r(
|
|
162
|
+
self.process.sub_message_r(color_str(
|
|
163
|
+
f': reading noisy pixel mask file: {self.noise_mask}',
|
|
164
|
+
constants.LOG_COLOR_LEVEL_3))
|
|
159
165
|
self.noise_mask_img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
|
160
166
|
if self.noise_mask_img is None:
|
|
161
167
|
raise ImageLoadError(path, f"failed to load image file {self.noise_mask}.")
|
|
@@ -163,7 +169,7 @@ class MaskNoise(SubAction):
|
|
|
163
169
|
raise ImageLoadError(path, "file not found.")
|
|
164
170
|
|
|
165
171
|
def run_frame(self, _idx, _ref_idx, image):
|
|
166
|
-
self.process.sub_message_r(': mask noisy pixels')
|
|
172
|
+
self.process.sub_message_r(color_str(': mask noisy pixels', constants.LOG_COLOR_LEVEL_3))
|
|
167
173
|
if len(image.shape) == 3:
|
|
168
174
|
corrected = image.copy()
|
|
169
175
|
for c in range(3):
|
shinestacker/algorithms/stack.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from .. config.constants import constants
|
|
5
5
|
from .. core.framework import JobBase
|
|
6
|
+
from .. core.colors import color_str
|
|
6
7
|
from .. core.exceptions import InvalidOptionError
|
|
7
8
|
from .utils import write_img
|
|
8
9
|
from .stack_framework import FrameDirectory, ActionList
|
|
@@ -23,7 +24,7 @@ class FocusStackBase(JobBase, FrameDirectory):
|
|
|
23
24
|
self.frame_count = -1
|
|
24
25
|
|
|
25
26
|
def focus_stack(self, filenames):
|
|
26
|
-
self.sub_message_r(': reading input files')
|
|
27
|
+
self.sub_message_r(color_str(': reading input files', constants.LOG_COLOR_LEVEL_3))
|
|
27
28
|
img_files = sorted([os.path.join(self.input_full_path, name) for name in filenames])
|
|
28
29
|
stacked_img = self.stack_algo.focus_stack(img_files)
|
|
29
30
|
in_filename = filenames[0].split(".")
|
|
@@ -42,7 +43,7 @@ class FocusStackBase(JobBase, FrameDirectory):
|
|
|
42
43
|
copy_exif_from_file_to_file(exif_filename, out_filename)
|
|
43
44
|
self.sub_message_r(' ' * 60)
|
|
44
45
|
if self.plot_stack:
|
|
45
|
-
idx_str = f"{self.frame_count:04d}" if self.frame_count >= 0 else ''
|
|
46
|
+
idx_str = f"{self.frame_count + 1:04d}" if self.frame_count >= 0 else ''
|
|
46
47
|
name = f"{self.name}: {self.stack_algo.name()}"
|
|
47
48
|
if idx_str != '':
|
|
48
49
|
name += f"\nbunch: {idx_str}"
|
|
@@ -91,9 +92,9 @@ class FocusStackBunch(ActionList, FocusStackBase):
|
|
|
91
92
|
ActionList.end(self)
|
|
92
93
|
|
|
93
94
|
def run_step(self):
|
|
94
|
-
self.print_message_r(f"fusing bunch: {self.count}"
|
|
95
|
+
self.print_message_r(color_str(f"fusing bunch: {self.count + 1}/{self.counts}",
|
|
96
|
+
constants.LOG_COLOR_LEVEL_2))
|
|
95
97
|
self.focus_stack(self._chunks[self.count - 1])
|
|
96
|
-
self.callback('after_step', self.id, self.name, self.count)
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
class FocusStack(FocusStackBase):
|
|
@@ -52,8 +52,7 @@ class FramePaths:
|
|
|
52
52
|
self.filenames = self.folder_filelist()
|
|
53
53
|
file_list = self.input_full_path.replace(self.working_path, '').lstrip('/')
|
|
54
54
|
self.print_message(color_str(f": {len(self.filenames)} files in folder: {file_list}",
|
|
55
|
-
|
|
56
|
-
self.print_message(color_str("focus stacking", 'blue'))
|
|
55
|
+
constants.LOG_COLOR_LEVEL_2))
|
|
57
56
|
|
|
58
57
|
def init(self, job):
|
|
59
58
|
if self.working_path == '':
|
|
@@ -213,14 +212,14 @@ class FramesRefActions(ActionList, FrameDirectory):
|
|
|
213
212
|
pass
|
|
214
213
|
|
|
215
214
|
def run_step(self):
|
|
216
|
-
if self.count ==
|
|
215
|
+
if self.count == 0:
|
|
217
216
|
self._idx = self.ref_idx if self.step_process else 0
|
|
218
217
|
self._ref_idx = self.ref_idx
|
|
219
218
|
self._idx_step = +1
|
|
220
219
|
ll = len(self.filenames)
|
|
221
220
|
self.print_message_r(
|
|
222
|
-
color_str(f"step {self.count}/{ll}: process file: {self.filenames[self._idx]}, "
|
|
223
|
-
f"reference: {self.filenames[self._ref_idx]}",
|
|
221
|
+
color_str(f"step {self.count + 1}/{ll}: process file: {self.filenames[self._idx]}, "
|
|
222
|
+
f"reference: {self.filenames[self._ref_idx]}", constants.LOG_COLOR_LEVEL_2))
|
|
224
223
|
self.run_frame(self._idx, self._ref_idx)
|
|
225
224
|
if self._idx < ll:
|
|
226
225
|
if self.step_process:
|
|
@@ -269,7 +268,7 @@ class CombinedActions(FramesRefActions):
|
|
|
269
268
|
|
|
270
269
|
def run_frame(self, idx, ref_idx):
|
|
271
270
|
filename = self.filenames[idx]
|
|
272
|
-
self.sub_message_r(': read input image')
|
|
271
|
+
self.sub_message_r(color_str(': read input image', constants.LOG_COLOR_LEVEL_3))
|
|
273
272
|
img = read_img(f"{self.input_full_path}/{filename}")
|
|
274
273
|
if self.dtype is not None and img.dtype != self.dtype:
|
|
275
274
|
raise BitDepthError(self.dtype, img.dtype, )
|
|
@@ -278,7 +277,8 @@ class CombinedActions(FramesRefActions):
|
|
|
278
277
|
if img is None:
|
|
279
278
|
raise RuntimeError(f"Invalid file: {self.input_full_path}/{filename}")
|
|
280
279
|
if len(self._actions) == 0:
|
|
281
|
-
self.sub_message(color_str(": no actions specified.",
|
|
280
|
+
self.sub_message(color_str(": no actions specified.", constants.LOG_COLOR_ALERT),
|
|
281
|
+
level=logging.WARNING)
|
|
282
282
|
for a in self._actions:
|
|
283
283
|
if not a.enabled:
|
|
284
284
|
self.get_logger().warning(color_str(f"{self.base_message}: sub-action disabled",
|
|
@@ -287,12 +287,14 @@ class CombinedActions(FramesRefActions):
|
|
|
287
287
|
if self.callback('check_running', self.id, self.name) is False:
|
|
288
288
|
raise RunStopException(self.name)
|
|
289
289
|
img = a.run_frame(idx, ref_idx, img)
|
|
290
|
-
self.sub_message_r(': write output image')
|
|
290
|
+
self.sub_message_r(color_str(': write output image', constants.LOG_COLOR_LEVEL_3))
|
|
291
291
|
if img is not None:
|
|
292
292
|
write_img(self.output_dir + "/" + filename, img)
|
|
293
293
|
else:
|
|
294
|
-
self.print_message(
|
|
295
|
-
|
|
294
|
+
self.print_message(color_str(
|
|
295
|
+
"No output file resulted from processing input file: "
|
|
296
|
+
f"{self.input_full_path}/{filename}",
|
|
297
|
+
constants.LOG_COLOR_ALERT), level=logging.WARNING)
|
|
296
298
|
|
|
297
299
|
def end(self):
|
|
298
300
|
for a in self._actions:
|
shinestacker/app/about_dialog.py
CHANGED
|
@@ -1,15 +1,82 @@
|
|
|
1
|
-
# pylint: disable=C0114, C0116, E0611
|
|
1
|
+
# pylint: disable=C0114, C0116, E0611, W0718
|
|
2
|
+
import json
|
|
3
|
+
from urllib.request import urlopen, Request
|
|
4
|
+
from urllib.error import URLError
|
|
2
5
|
from PySide6.QtWidgets import QMessageBox
|
|
3
6
|
from PySide6.QtCore import Qt
|
|
4
7
|
from .. import __version__
|
|
5
8
|
from .. config.constants import constants
|
|
6
9
|
|
|
7
10
|
|
|
11
|
+
def compare_versions(current, latest):
|
|
12
|
+
def parse_version(v):
|
|
13
|
+
v = v.lstrip('v')
|
|
14
|
+
parts = v.split('.')
|
|
15
|
+
result = []
|
|
16
|
+
for part in parts:
|
|
17
|
+
try:
|
|
18
|
+
result.append(int(part))
|
|
19
|
+
except ValueError:
|
|
20
|
+
result.append(part)
|
|
21
|
+
return result
|
|
22
|
+
current_parts = parse_version(current)
|
|
23
|
+
latest_parts = parse_version(latest)
|
|
24
|
+
for i in range(max(len(current_parts), len(latest_parts))):
|
|
25
|
+
c = current_parts[i] if i < len(current_parts) else 0
|
|
26
|
+
l = latest_parts[i] if i < len(latest_parts) else 0 # noqa: E741
|
|
27
|
+
if isinstance(c, int) and isinstance(l, int):
|
|
28
|
+
if c < l:
|
|
29
|
+
return -1
|
|
30
|
+
if c > l:
|
|
31
|
+
return 1
|
|
32
|
+
else:
|
|
33
|
+
if str(c) < str(l):
|
|
34
|
+
return -1
|
|
35
|
+
if str(c) > str(l):
|
|
36
|
+
return 1
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_latest_version():
|
|
41
|
+
try:
|
|
42
|
+
url = "https://api.github.com/repos/lucalista/shinestacker/releases/latest"
|
|
43
|
+
headers = {'User-Agent': 'ShineStacker'}
|
|
44
|
+
req = Request(url, headers=headers)
|
|
45
|
+
with urlopen(req, timeout=5) as response:
|
|
46
|
+
data = json.loads(response.read().decode())
|
|
47
|
+
return data['tag_name']
|
|
48
|
+
except (URLError, ValueError, KeyError, TimeoutError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
8
52
|
def show_about_dialog():
|
|
9
53
|
version_clean = __version__.split("+", maxsplit=1)[0]
|
|
54
|
+
latest_version = None
|
|
55
|
+
try:
|
|
56
|
+
latest_version = get_latest_version()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
update_text = ""
|
|
60
|
+
# pyling: disable=XXX
|
|
61
|
+
if latest_version:
|
|
62
|
+
latest_clean = latest_version.lstrip('v')
|
|
63
|
+
if compare_versions(version_clean, latest_clean) < 0:
|
|
64
|
+
update_text = f"""
|
|
65
|
+
<p style="color: red; font-weight: bold;">
|
|
66
|
+
Update available! Latest version: {latest_version}
|
|
67
|
+
<br><a href="https://github.com/lucalista/shinestacker/releases/latest">Download here</a>
|
|
68
|
+
</p>
|
|
69
|
+
""" # noqa E501
|
|
70
|
+
else:
|
|
71
|
+
update_text = f"""
|
|
72
|
+
<p style="color: green; font-weight: bold;">
|
|
73
|
+
You are using the lastet version: {latest_version}.
|
|
74
|
+
</p>
|
|
75
|
+
"""
|
|
10
76
|
about_text = f"""
|
|
11
77
|
<h3>{constants.APP_TITLE}</h3>
|
|
12
78
|
<h4>version: v{version_clean}</h4>
|
|
79
|
+
{update_text}
|
|
13
80
|
<p style='font-weight: normal;'>App and framework to combine multiple images
|
|
14
81
|
into a single focused image.</p>
|
|
15
82
|
<p>Author: Luca Lista<br/>
|
|
@@ -19,6 +86,7 @@ def show_about_dialog():
|
|
|
19
86
|
<li><a href="https://github.com/lucalista/shinestacker">GitHub project repository</a></li>
|
|
20
87
|
</ul>
|
|
21
88
|
"""
|
|
89
|
+
# pyling: enable=XXX
|
|
22
90
|
msg = QMessageBox()
|
|
23
91
|
msg.setWindowTitle(f"About {constants.APP_STRING}")
|
|
24
92
|
msg.setIcon(QMessageBox.Icon.Information)
|
shinestacker/app/main.py
CHANGED
|
@@ -89,7 +89,7 @@ class MainApp(QMainWindow):
|
|
|
89
89
|
if isinstance(filename, list):
|
|
90
90
|
open_frames(self.retouch_window, None, ";".join(filename))
|
|
91
91
|
else:
|
|
92
|
-
self.retouch_window.open_file(filename)
|
|
92
|
+
self.retouch_window.io_gui_handler.open_file(filename)
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
class Application(QApplication):
|
shinestacker/config/config.py
CHANGED
shinestacker/config/constants.py
CHANGED
|
@@ -41,6 +41,12 @@ class _Constants:
|
|
|
41
41
|
|
|
42
42
|
PATH_SEPARATOR = ';'
|
|
43
43
|
|
|
44
|
+
LOG_COLOR_ALERT = 'red'
|
|
45
|
+
LOG_COLOR_LEVEL_JOB = 'green'
|
|
46
|
+
LOG_COLOR_LEVEL_1 = 'blue'
|
|
47
|
+
LOG_COLOR_LEVEL_2 = 'magenta'
|
|
48
|
+
LOG_COLOR_LEVEL_3 = 'cyan'
|
|
49
|
+
|
|
44
50
|
DEFAULT_FILE_REVERSE_ORDER = False
|
|
45
51
|
DEFAULT_MULTILAYER_FILE_REVERSE_ORDER = True
|
|
46
52
|
|
|
@@ -99,8 +105,9 @@ class _Constants:
|
|
|
99
105
|
DEFAULT_ALIGN_MAX_ITERS = 2000
|
|
100
106
|
DEFAULT_BORDER_VALUE = [0] * 4
|
|
101
107
|
DEFAULT_BORDER_BLUR = 50
|
|
102
|
-
DEFAULT_ALIGN_SUBSAMPLE =
|
|
108
|
+
DEFAULT_ALIGN_SUBSAMPLE = 2
|
|
103
109
|
DEFAULT_ALIGN_FAST_SUBSAMPLING = False
|
|
110
|
+
DEFAULT_ALIGN_MIN_GOOD_MATCHES = 100
|
|
104
111
|
|
|
105
112
|
BALANCE_LINEAR = "LINEAR"
|
|
106
113
|
BALANCE_GAMMA = "GAMMA"
|
|
@@ -36,9 +36,10 @@ class _GuiConstants:
|
|
|
36
36
|
|
|
37
37
|
THUMB_WIDTH = 120 # px
|
|
38
38
|
THUMB_HEIGHT = 80 # px
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
THUMB_HI_COLOR = '#0000FF'
|
|
40
|
+
THUMB_LO_COLOR = '#0000FF'
|
|
41
|
+
THUMB_MASTER_HI_COLOR = '#0000FF'
|
|
42
|
+
THUMB_MASTER_LO_COLOR = 'transparent'
|
|
42
43
|
|
|
43
44
|
MAX_UNDO_STEPS = 50
|
|
44
45
|
|
|
@@ -46,8 +47,9 @@ class _GuiConstants:
|
|
|
46
47
|
|
|
47
48
|
UI_SIZES = {
|
|
48
49
|
'brush_preview': (100, 80),
|
|
49
|
-
'
|
|
50
|
-
'master_thumb': (THUMB_WIDTH, THUMB_HEIGHT)
|
|
50
|
+
'thumbnail_width': 100,
|
|
51
|
+
'master_thumb': (THUMB_WIDTH, THUMB_HEIGHT),
|
|
52
|
+
'label_height': 20
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
DEFAULT_BRUSH_HARDNESS = 50
|
shinestacker/core/framework.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# pylint: disable=C0114, C0115, C0116, R0917, R0913, R0902
|
|
2
2
|
import time
|
|
3
3
|
import logging
|
|
4
|
+
from .. config.constants import constants
|
|
4
5
|
from .. config.config import config
|
|
5
6
|
from .colors import color_str
|
|
6
7
|
from .logging import setup_logging
|
|
@@ -89,13 +90,15 @@ class JobBase:
|
|
|
89
90
|
def run(self):
|
|
90
91
|
self._t0 = time.time()
|
|
91
92
|
if not self.enabled:
|
|
92
|
-
self.get_logger().warning(color_str(self.name + ": entire job disabled",
|
|
93
|
+
self.get_logger().warning(color_str(self.name + ": entire job disabled",
|
|
94
|
+
constants.LOG_COLOR_ALERT))
|
|
93
95
|
self.callback('before_action', self.id, self.name)
|
|
94
96
|
self.run_core()
|
|
95
97
|
self.callback('after_action', self.id, self.name)
|
|
96
|
-
msg_name = color_str(self.name + ":",
|
|
97
|
-
msg_time = color_str(f"elapsed time: {elapsed_time_str(self._t0)}",
|
|
98
|
-
|
|
98
|
+
msg_name = color_str(self.name + ":", constants.LOG_COLOR_LEVEL_JOB, "bold")
|
|
99
|
+
msg_time = color_str(f"elapsed time: {elapsed_time_str(self._t0)}",
|
|
100
|
+
constants.LOG_COLOR_LEVEL_JOB)
|
|
101
|
+
msg_completed = color_str("completed", constants.LOG_COLOR_LEVEL_JOB)
|
|
99
102
|
self.get_logger().info(msg=f"{msg_name} {msg_time}{TRAILING_SPACES}")
|
|
100
103
|
self.get_logger().info(msg=f"{msg_name} {msg_completed}{TRAILING_SPACES}")
|
|
101
104
|
|
|
@@ -115,13 +118,14 @@ class JobBase:
|
|
|
115
118
|
def print_message(self, msg='', level=logging.INFO, end=None, begin='', tqdm=False):
|
|
116
119
|
if config.DISABLE_TQDM:
|
|
117
120
|
tqdm = False
|
|
118
|
-
self.base_message = color_str(self.name,
|
|
121
|
+
self.base_message = color_str(self.name, constants.LOG_COLOR_LEVEL_1, "bold")
|
|
119
122
|
if msg != '':
|
|
120
123
|
self.base_message += (': ' + msg)
|
|
121
124
|
self.set_terminator(tqdm, end)
|
|
125
|
+
col_str = color_str(self.base_message, constants.LOG_COLOR_LEVEL_1, "bold")
|
|
122
126
|
self.get_logger(tqdm).log(
|
|
123
127
|
level=level,
|
|
124
|
-
msg=f"{begin}{
|
|
128
|
+
msg=f"{begin}{col_str}{TRAILING_SPACES}"
|
|
125
129
|
)
|
|
126
130
|
self.set_terminator(tqdm)
|
|
127
131
|
|
|
@@ -176,7 +180,8 @@ class Job(JobBase):
|
|
|
176
180
|
if not self.enabled:
|
|
177
181
|
z.append("job")
|
|
178
182
|
msg = " and ".join(z)
|
|
179
|
-
self.get_logger().warning(color_str(a.name + f": {msg} disabled",
|
|
183
|
+
self.get_logger().warning(color_str(a.name + f": {msg} disabled",
|
|
184
|
+
constants.LOG_COLOR_ALERT))
|
|
180
185
|
else:
|
|
181
186
|
if self.callback('check_running', self.id, self.name) is False:
|
|
182
187
|
raise RunStopException(self.name)
|
|
@@ -200,14 +205,14 @@ class ActionList(JobBase):
|
|
|
200
205
|
self.callback('end_steps', self.id, self.name)
|
|
201
206
|
|
|
202
207
|
def __iter__(self):
|
|
203
|
-
self.count =
|
|
208
|
+
self.count = 0
|
|
204
209
|
return self
|
|
205
210
|
|
|
206
211
|
def run_step(self):
|
|
207
212
|
pass
|
|
208
213
|
|
|
209
214
|
def __next__(self):
|
|
210
|
-
if self.count
|
|
215
|
+
if self.count < self.counts:
|
|
211
216
|
self.run_step()
|
|
212
217
|
x = self.count
|
|
213
218
|
self.count += 1
|
|
@@ -215,7 +220,7 @@ class ActionList(JobBase):
|
|
|
215
220
|
raise StopIteration
|
|
216
221
|
|
|
217
222
|
def run_core(self):
|
|
218
|
-
self.print_message('begin run', end='\n')
|
|
223
|
+
self.print_message(color_str('begin run', constants.LOG_COLOR_LEVEL_2), end='\n')
|
|
219
224
|
self.begin()
|
|
220
225
|
for _ in iter(self):
|
|
221
226
|
self.callback('after_step', self.id, self.name, self.count)
|
|
@@ -206,7 +206,7 @@ class FieldBuilder:
|
|
|
206
206
|
self.action.params.get(tag, ''),
|
|
207
207
|
kwargs.get('placeholder', ''),
|
|
208
208
|
tag.replace('_', ' ')
|
|
209
|
-
)
|
|
209
|
+
)[1]
|
|
210
210
|
|
|
211
211
|
def create_rel_path_field(self, tag, **kwargs):
|
|
212
212
|
value = self.action.params.get(tag, kwargs.get('default', ''))
|
|
@@ -316,7 +316,6 @@ class FieldBuilder:
|
|
|
316
316
|
QMessageBox.warning(None, "Error", "Could not compute relative path")
|
|
317
317
|
return create_layout_widget_and_connect(button, edit, browse)
|
|
318
318
|
|
|
319
|
-
|
|
320
319
|
def create_float_field(self, tag, default=0.0, min_val=0.0, max_val=1.0,
|
|
321
320
|
step=0.1, decimals=2):
|
|
322
321
|
spin = QDoubleSpinBox()
|
|
@@ -593,7 +592,8 @@ class FocusStackConfigurator(FocusStackBaseConfigurator):
|
|
|
593
592
|
self.builder.add_field('exif_path', FIELD_REL_PATH, 'Exif data path', required=False,
|
|
594
593
|
placeholder='relative to working path')
|
|
595
594
|
self.builder.add_field('prefix', FIELD_TEXT, 'Ouptut filename prefix', required=False,
|
|
596
|
-
default=constants.DEFAULT_STACK_PREFIX,
|
|
595
|
+
default=constants.DEFAULT_STACK_PREFIX,
|
|
596
|
+
placeholder=constants.DEFAULT_STACK_PREFIX)
|
|
597
597
|
self.builder.add_field('plot_stack', FIELD_BOOL, 'Plot stack', required=False,
|
|
598
598
|
default=constants.DEFAULT_PLOT_STACK)
|
|
599
599
|
super().common_fields(layout)
|
|
@@ -616,10 +616,11 @@ class MultiLayerConfigurator(DefaultActionConfigurator):
|
|
|
616
616
|
super().create_form(layout, action)
|
|
617
617
|
if self.expert:
|
|
618
618
|
self.builder.add_field('working_path', FIELD_ABS_PATH, 'Working path', required=False)
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
619
|
+
self.builder.add_field('input_path', FIELD_REL_PATH,
|
|
620
|
+
f'Input path (separate by {constants.PATH_SEPARATOR})',
|
|
621
|
+
required=False, multiple_entries=True,
|
|
622
|
+
placeholder='relative to working path')
|
|
623
|
+
if self.expert:
|
|
623
624
|
self.builder.add_field('output_path', FIELD_REL_PATH, 'Output path', required=False,
|
|
624
625
|
placeholder='relative to working path')
|
|
625
626
|
self.builder.add_field('exif_path', FIELD_REL_PATH, 'Exif data path', required=False,
|
|
@@ -805,6 +806,9 @@ class AlignFramesConfigurator(DefaultActionConfigurator):
|
|
|
805
806
|
rans_threshold = self.builder.add_field(
|
|
806
807
|
'rans_threshold', FIELD_FLOAT, 'RANSAC threshold (px)', required=False,
|
|
807
808
|
default=constants.DEFAULT_RANS_THRESHOLD, min_val=0, max_val=20, step=0.1)
|
|
809
|
+
self.builder.add_field(
|
|
810
|
+
'min_good_matches', FIELD_INT, "Min. good matches", required=False,
|
|
811
|
+
default=constants.DEFAULT_ALIGN_MIN_GOOD_MATCHES, min_val=0, max_val=500)
|
|
808
812
|
|
|
809
813
|
def change_method():
|
|
810
814
|
text = method.currentText()
|