shinestacker 1.0.4.post2__py3-none-any.whl → 1.2.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/__init__.py +4 -1
- shinestacker/algorithms/align.py +128 -14
- shinestacker/algorithms/balance.py +362 -163
- shinestacker/algorithms/base_stack_algo.py +33 -4
- shinestacker/algorithms/depth_map.py +9 -12
- shinestacker/algorithms/multilayer.py +12 -2
- shinestacker/algorithms/noise_detection.py +8 -3
- shinestacker/algorithms/pyramid.py +57 -42
- shinestacker/algorithms/pyramid_auto.py +141 -0
- shinestacker/algorithms/pyramid_tiles.py +264 -0
- shinestacker/algorithms/stack.py +14 -11
- shinestacker/algorithms/stack_framework.py +17 -11
- shinestacker/algorithms/utils.py +180 -1
- shinestacker/algorithms/vignetting.py +23 -5
- shinestacker/config/constants.py +31 -5
- shinestacker/gui/action_config.py +6 -7
- shinestacker/gui/action_config_dialog.py +425 -258
- shinestacker/gui/base_form_dialog.py +11 -6
- shinestacker/gui/flow_layout.py +105 -0
- shinestacker/gui/gui_run.py +24 -19
- shinestacker/gui/main_window.py +4 -3
- shinestacker/gui/menu_manager.py +12 -2
- shinestacker/gui/new_project.py +28 -22
- shinestacker/gui/project_controller.py +40 -23
- shinestacker/gui/project_converter.py +6 -6
- shinestacker/gui/project_editor.py +21 -7
- shinestacker/gui/time_progress_bar.py +2 -2
- shinestacker/retouch/exif_data.py +5 -5
- shinestacker/retouch/shortcuts_help.py +4 -4
- shinestacker/retouch/vignetting_filter.py +12 -8
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/METADATA +20 -1
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/RECORD +37 -34
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/WHEEL +0 -0
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/entry_points.txt +0 -0
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-1.0.4.post2.dist-info → shinestacker-1.2.0.dist-info}/top_level.txt +0 -0
shinestacker/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '1.0
|
|
1
|
+
__version__ = '1.2.0'
|
|
@@ -8,6 +8,8 @@ from .balance import BalanceFrames
|
|
|
8
8
|
from .stack import FocusStackBunch, FocusStack
|
|
9
9
|
from .depth_map import DepthMapStack
|
|
10
10
|
from .pyramid import PyramidStack
|
|
11
|
+
from .pyramid_tiles import PyramidTilesStack
|
|
12
|
+
from .pyramid_auto import PyramidAutoStack
|
|
11
13
|
from .multilayer import MultiLayer
|
|
12
14
|
from .noise_detection import NoiseDetection, MaskNoise
|
|
13
15
|
from .vignetting import Vignetting
|
|
@@ -16,5 +18,6 @@ logger.addHandler(logging.NullHandler())
|
|
|
16
18
|
|
|
17
19
|
__all__ = [
|
|
18
20
|
'StackJob', 'CombinedActions', 'AlignFrames', 'BalanceFrames', 'FocusStackBunch', 'FocusStack',
|
|
19
|
-
'DepthMapStack', 'PyramidStack', '
|
|
21
|
+
'DepthMapStack', 'PyramidStack', 'PyramidTilesStack', 'PyramidAutoStack', 'MultiLayer',
|
|
22
|
+
'NoiseDetection', 'MaskNoise', 'Vignetting'
|
|
20
23
|
]
|
shinestacker/algorithms/align.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
# pylint: disable=C0114, C0115, C0116, E1101, R0914, R0913, R0917, R0912, R0915, R0902
|
|
1
|
+
# pylint: disable=C0114, C0115, C0116, E1101, R0914, R0913, R0917, R0912, R0915, R0902, E1121, W0102
|
|
2
2
|
import logging
|
|
3
|
+
import math
|
|
3
4
|
import numpy as np
|
|
4
5
|
import matplotlib.pyplot as plt
|
|
5
6
|
import cv2
|
|
6
7
|
from .. config.constants import constants
|
|
7
8
|
from .. core.exceptions import AlignmentError, InvalidOptionError
|
|
8
9
|
from .. core.colors import color_str
|
|
9
|
-
from .utils import img_8bit, img_bw_8bit, save_plot,
|
|
10
|
+
from .utils import img_8bit, img_bw_8bit, save_plot, img_subsample
|
|
10
11
|
from .stack_framework import SubAction
|
|
11
12
|
|
|
12
13
|
_DEFAULT_FEATURE_CONFIG = {
|
|
@@ -29,6 +30,7 @@ _DEFAULT_ALIGNMENT_CONFIG = {
|
|
|
29
30
|
'refine_iters': constants.DEFAULT_REFINE_ITERS,
|
|
30
31
|
'align_confidence': constants.DEFAULT_ALIGN_CONFIDENCE,
|
|
31
32
|
'max_iters': constants.DEFAULT_ALIGN_MAX_ITERS,
|
|
33
|
+
'abort_abnormal': constants.DEFAULT_ALIGN_ABORT_ABNORMAL,
|
|
32
34
|
'border_mode': constants.DEFAULT_BORDER_MODE,
|
|
33
35
|
'border_value': constants.DEFAULT_BORDER_VALUE,
|
|
34
36
|
'border_blur': constants.DEFAULT_BORDER_BLUR,
|
|
@@ -44,6 +46,89 @@ _cv2_border_mode_map = {
|
|
|
44
46
|
constants.BORDER_REPLICATE_BLUR: cv2.BORDER_REPLICATE
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
_AFFINE_THRESHOLDS = {
|
|
50
|
+
'max_rotation': 10.0, # degrees
|
|
51
|
+
'min_scale': 0.9,
|
|
52
|
+
'max_scale': 1.1,
|
|
53
|
+
'max_shear': 5.0, # degrees
|
|
54
|
+
'max_translation_ratio': 0.1, # 10% of image dimension
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_HOMOGRAPHY_THRESHOLDS = {
|
|
58
|
+
'max_skew': 10.0, # degrees
|
|
59
|
+
'max_scale_change': 1.5, # max area change ratio
|
|
60
|
+
'max_aspect_ratio': 2.0, # max aspect ratio change
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decompose_affine_matrix(m):
|
|
65
|
+
a, b, tx = m[0, 0], m[0, 1], m[0, 2]
|
|
66
|
+
c, d, ty = m[1, 0], m[1, 1], m[1, 2]
|
|
67
|
+
scale_x = math.sqrt(a**2 + b**2)
|
|
68
|
+
scale_y = math.sqrt(c**2 + d**2)
|
|
69
|
+
rotation = math.degrees(math.atan2(b, a))
|
|
70
|
+
shear = math.degrees(math.atan2(-c, d)) - rotation
|
|
71
|
+
shear = (shear + 180) % 360 - 180
|
|
72
|
+
return (scale_x, scale_y), rotation, shear, (tx, ty)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_affine_matrix(m, img_shape, affine_thresholds=_AFFINE_THRESHOLDS):
|
|
76
|
+
if affine_thresholds is None:
|
|
77
|
+
return True, "No thresholds provided"
|
|
78
|
+
(scale_x, scale_y), rotation, shear, (tx, ty) = decompose_affine_matrix(m)
|
|
79
|
+
h, w = img_shape[:2]
|
|
80
|
+
reasons = []
|
|
81
|
+
if abs(rotation) > affine_thresholds['max_rotation']:
|
|
82
|
+
reasons.append(f"rotation too large ({rotation:.1f}°)")
|
|
83
|
+
if scale_x < affine_thresholds['min_scale'] or scale_x > affine_thresholds['max_scale']:
|
|
84
|
+
reasons.append(f"x-scale out of range ({scale_x:.2f})")
|
|
85
|
+
if scale_y < affine_thresholds['min_scale'] or scale_y > affine_thresholds['max_scale']:
|
|
86
|
+
reasons.append(f"y-scale out of range ({scale_y:.2f})")
|
|
87
|
+
if abs(shear) > affine_thresholds['max_shear']:
|
|
88
|
+
reasons.append(f"shear too large ({shear:.1f}°)")
|
|
89
|
+
max_tx = w * affine_thresholds['max_translation_ratio']
|
|
90
|
+
max_ty = h * affine_thresholds['max_translation_ratio']
|
|
91
|
+
if abs(tx) > max_tx:
|
|
92
|
+
reasons.append(f"x-translation too large (|{tx:.1f}| > {max_tx:.1f})")
|
|
93
|
+
if abs(ty) > max_ty:
|
|
94
|
+
reasons.append(f"y-translation too large (|{ty:.1f}| > {max_ty:.1f})")
|
|
95
|
+
if reasons:
|
|
96
|
+
return False, "; ".join(reasons)
|
|
97
|
+
return True, "Transformation within acceptable limits"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_homography_distortion(m, img_shape, homography_thresholds=_HOMOGRAPHY_THRESHOLDS):
|
|
101
|
+
if homography_thresholds is None:
|
|
102
|
+
return True, "No thresholds provided"
|
|
103
|
+
h, w = img_shape[:2]
|
|
104
|
+
corners = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
|
|
105
|
+
transformed = cv2.perspectiveTransform(corners.reshape(1, -1, 2), m).reshape(-1, 2)
|
|
106
|
+
reasons = []
|
|
107
|
+
area_orig = w * h
|
|
108
|
+
area_new = cv2.contourArea(transformed)
|
|
109
|
+
area_ratio = area_new / area_orig
|
|
110
|
+
if area_ratio > homography_thresholds['max_scale_change'] or \
|
|
111
|
+
area_ratio < 1.0 / homography_thresholds['max_scale_change']:
|
|
112
|
+
reasons.append(f"area change too large ({area_ratio:.2f})")
|
|
113
|
+
rect = cv2.minAreaRect(transformed.astype(np.float32))
|
|
114
|
+
(w_rect, h_rect) = rect[1]
|
|
115
|
+
aspect_ratio = max(w_rect, h_rect) / min(w_rect, h_rect)
|
|
116
|
+
if aspect_ratio > homography_thresholds['max_aspect_ratio']:
|
|
117
|
+
reasons.append(f"aspect ratio change too large ({aspect_ratio:.2f})")
|
|
118
|
+
angles = []
|
|
119
|
+
for i in range(4):
|
|
120
|
+
vec1 = transformed[(i + 1) % 4] - transformed[i]
|
|
121
|
+
vec2 = transformed[(i - 1) % 4] - transformed[i]
|
|
122
|
+
angle = np.degrees(np.arccos(np.dot(vec1, vec2) /
|
|
123
|
+
(np.linalg.norm(vec1) * np.linalg.norm(vec2))))
|
|
124
|
+
angles.append(angle)
|
|
125
|
+
max_angle_dev = max(abs(angle - 90) for angle in angles)
|
|
126
|
+
if max_angle_dev > homography_thresholds['max_skew']:
|
|
127
|
+
reasons.append(f"angle distortion too large ({max_angle_dev:.1f}°)")
|
|
128
|
+
if reasons:
|
|
129
|
+
return False, "; ".join(reasons)
|
|
130
|
+
return True, "Transformation within acceptable limits"
|
|
131
|
+
|
|
47
132
|
|
|
48
133
|
def get_good_matches(des_0, des_1, matching_config=None):
|
|
49
134
|
matching_config = {**_DEFAULT_MATCHING_CONFIG, **(matching_config or {})}
|
|
@@ -152,7 +237,9 @@ def find_transform(src_pts, dst_pts, transform=constants.DEFAULT_TRANSFORM,
|
|
|
152
237
|
|
|
153
238
|
|
|
154
239
|
def align_images(img_1, img_0, feature_config=None, matching_config=None, alignment_config=None,
|
|
155
|
-
plot_path=None, callbacks=None
|
|
240
|
+
plot_path=None, callbacks=None,
|
|
241
|
+
affine_thresholds=_AFFINE_THRESHOLDS,
|
|
242
|
+
homography_thresholds=_HOMOGRAPHY_THRESHOLDS):
|
|
156
243
|
feature_config = {**_DEFAULT_FEATURE_CONFIG, **(feature_config or {})}
|
|
157
244
|
matching_config = {**_DEFAULT_MATCHING_CONFIG, **(matching_config or {})}
|
|
158
245
|
alignment_config = {**_DEFAULT_ALIGNMENT_CONFIG, **(alignment_config or {})}
|
|
@@ -161,10 +248,15 @@ def align_images(img_1, img_0, feature_config=None, matching_config=None, alignm
|
|
|
161
248
|
except KeyError as e:
|
|
162
249
|
raise InvalidOptionError("border_mode", alignment_config['border_mode']) from e
|
|
163
250
|
min_matches = 4 if alignment_config['transform'] == constants.ALIGN_HOMOGRAPHY else 3
|
|
164
|
-
validate_image(img_0, *get_img_metadata(img_1))
|
|
165
251
|
if callbacks and 'message' in callbacks:
|
|
166
252
|
callbacks['message']()
|
|
253
|
+
h_ref, w_ref = img_1.shape[:2]
|
|
254
|
+
h0, w0 = img_0.shape[:2]
|
|
167
255
|
subsample = alignment_config['subsample']
|
|
256
|
+
if subsample == 0:
|
|
257
|
+
img_res = (float(h0) / 1000) * (float(w0) / 1000)
|
|
258
|
+
target_res = constants.DEFAULT_ALIGN_RES_TARGET_MPX
|
|
259
|
+
subsample = int(1 + math.floor(img_res / target_res))
|
|
168
260
|
fast_subsampling = alignment_config['fast_subsampling']
|
|
169
261
|
min_good_matches = alignment_config['min_good_matches']
|
|
170
262
|
while True:
|
|
@@ -204,15 +296,14 @@ def align_images(img_1, img_0, feature_config=None, matching_config=None, alignm
|
|
|
204
296
|
flags=2), cv2.COLOR_BGR2RGB)
|
|
205
297
|
plt.figure(figsize=(10, 5))
|
|
206
298
|
plt.imshow(img_match, 'gray')
|
|
207
|
-
|
|
299
|
+
save_plot(plot_path)
|
|
208
300
|
if callbacks and 'save_plot' in callbacks:
|
|
209
301
|
callbacks['save_plot'](plot_path)
|
|
210
|
-
h, w = img_0.shape[:2]
|
|
211
302
|
h_sub, w_sub = img_0_sub.shape[:2]
|
|
212
303
|
if subsample > 1:
|
|
213
304
|
if transform == constants.ALIGN_HOMOGRAPHY:
|
|
214
305
|
low_size = np.float32([[0, 0], [0, h_sub], [w_sub, h_sub], [w_sub, 0]])
|
|
215
|
-
high_size = np.float32([[0, 0], [0,
|
|
306
|
+
high_size = np.float32([[0, 0], [0, h0], [w0, h0], [w0, 0]])
|
|
216
307
|
scale_up = cv2.getPerspectiveTransform(low_size, high_size)
|
|
217
308
|
scale_down = cv2.getPerspectiveTransform(high_size, low_size)
|
|
218
309
|
m = scale_up @ m @ scale_down
|
|
@@ -225,22 +316,37 @@ def align_images(img_1, img_0, feature_config=None, matching_config=None, alignm
|
|
|
225
316
|
m[:, 2] = translation_fullres
|
|
226
317
|
else:
|
|
227
318
|
raise InvalidOptionError("transform", transform)
|
|
319
|
+
|
|
320
|
+
transform_type = alignment_config['transform']
|
|
321
|
+
is_valid = True
|
|
322
|
+
reason = ""
|
|
323
|
+
if transform_type == constants.ALIGN_RIGID:
|
|
324
|
+
is_valid, reason = check_affine_matrix(
|
|
325
|
+
m, img_0.shape, affine_thresholds)
|
|
326
|
+
elif transform_type == constants.ALIGN_HOMOGRAPHY:
|
|
327
|
+
is_valid, reason = check_homography_distortion(
|
|
328
|
+
m, img_0.shape, homography_thresholds)
|
|
329
|
+
if not is_valid:
|
|
330
|
+
if callbacks and 'warning' in callbacks:
|
|
331
|
+
callbacks['warning'](f"invalid transformation: {reason}")
|
|
332
|
+
return n_good_matches, None, None
|
|
333
|
+
|
|
228
334
|
if callbacks and 'align_message' in callbacks:
|
|
229
335
|
callbacks['align_message']()
|
|
230
336
|
img_mask = np.ones_like(img_0, dtype=np.uint8)
|
|
231
337
|
if alignment_config['transform'] == constants.ALIGN_HOMOGRAPHY:
|
|
232
338
|
img_warp = cv2.warpPerspective(
|
|
233
|
-
img_0, m, (
|
|
339
|
+
img_0, m, (w_ref, h_ref),
|
|
234
340
|
borderMode=cv2_border_mode, borderValue=alignment_config['border_value'])
|
|
235
341
|
if alignment_config['border_mode'] == constants.BORDER_REPLICATE_BLUR:
|
|
236
|
-
mask = cv2.warpPerspective(img_mask, m, (
|
|
342
|
+
mask = cv2.warpPerspective(img_mask, m, (w_ref, h_ref),
|
|
237
343
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
|
238
344
|
elif alignment_config['transform'] == constants.ALIGN_RIGID:
|
|
239
345
|
img_warp = cv2.warpAffine(
|
|
240
|
-
img_0, m, (
|
|
346
|
+
img_0, m, (w_ref, h_ref),
|
|
241
347
|
borderMode=cv2_border_mode, borderValue=alignment_config['border_value'])
|
|
242
348
|
if alignment_config['border_mode'] == constants.BORDER_REPLICATE_BLUR:
|
|
243
|
-
mask = cv2.warpAffine(img_mask, m, (
|
|
349
|
+
mask = cv2.warpAffine(img_mask, m, (w_ref, h_ref),
|
|
244
350
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
|
245
351
|
if alignment_config['border_mode'] == constants.BORDER_REPLICATE_BLUR:
|
|
246
352
|
if callbacks and 'blur_message' in callbacks:
|
|
@@ -293,7 +399,7 @@ class AlignFrames(SubAction):
|
|
|
293
399
|
'ecc_message': lambda: self.sub_msg(": ecc refinement"),
|
|
294
400
|
'blur_message': lambda: self.sub_msg(': blur borders'),
|
|
295
401
|
'warning': lambda msg: self.sub_msg(
|
|
296
|
-
f': {msg}', constants.
|
|
402
|
+
f': {msg}', constants.LOG_COLOR_WARNING),
|
|
297
403
|
'save_plot': lambda plot_path: self.process.callback(
|
|
298
404
|
'save_plot', self.process.id,
|
|
299
405
|
f"{self.process.name}: matches\nframe {idx_str}", plot_path)
|
|
@@ -303,19 +409,27 @@ class AlignFrames(SubAction):
|
|
|
303
409
|
f"{self.process.name}-matches-{idx_str}.pdf"
|
|
304
410
|
else:
|
|
305
411
|
plot_path = None
|
|
412
|
+
if self.alignment_config['abort_abnormal']:
|
|
413
|
+
affine_thresholds = _AFFINE_THRESHOLDS
|
|
414
|
+
homography_thresholds = _HOMOGRAPHY_THRESHOLDS
|
|
415
|
+
else:
|
|
416
|
+
affine_thresholds = None
|
|
417
|
+
homography_thresholds = None
|
|
306
418
|
n_good_matches, _m, img = align_images(
|
|
307
419
|
img_1, img_0,
|
|
308
420
|
feature_config=self.feature_config,
|
|
309
421
|
matching_config=self.matching_config,
|
|
310
422
|
alignment_config=self.alignment_config,
|
|
311
423
|
plot_path=plot_path,
|
|
312
|
-
callbacks=callbacks
|
|
424
|
+
callbacks=callbacks,
|
|
425
|
+
affine_thresholds=affine_thresholds,
|
|
426
|
+
homography_thresholds=homography_thresholds
|
|
313
427
|
)
|
|
314
428
|
self.n_matches[idx] = n_good_matches
|
|
315
429
|
if n_good_matches < self.min_matches:
|
|
316
430
|
self.process.sub_message(f": image not aligned, too few matches found: "
|
|
317
431
|
f"{n_good_matches}", level=logging.CRITICAL)
|
|
318
|
-
raise AlignmentError(idx, f"too few matches found: "
|
|
432
|
+
raise AlignmentError(idx, f"Image not aligned, too few matches found: "
|
|
319
433
|
f"{n_good_matches} < {self.min_matches}")
|
|
320
434
|
return img
|
|
321
435
|
|