shinestacker 1.1.0__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.

Files changed (34) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/__init__.py +4 -1
  3. shinestacker/algorithms/align.py +117 -3
  4. shinestacker/algorithms/balance.py +362 -163
  5. shinestacker/algorithms/base_stack_algo.py +6 -0
  6. shinestacker/algorithms/depth_map.py +1 -1
  7. shinestacker/algorithms/multilayer.py +12 -2
  8. shinestacker/algorithms/noise_detection.py +1 -1
  9. shinestacker/algorithms/pyramid.py +3 -2
  10. shinestacker/algorithms/pyramid_auto.py +141 -0
  11. shinestacker/algorithms/pyramid_tiles.py +199 -44
  12. shinestacker/algorithms/stack.py +3 -3
  13. shinestacker/algorithms/stack_framework.py +13 -4
  14. shinestacker/algorithms/utils.py +175 -1
  15. shinestacker/algorithms/vignetting.py +23 -5
  16. shinestacker/config/constants.py +29 -6
  17. shinestacker/gui/action_config.py +6 -7
  18. shinestacker/gui/action_config_dialog.py +425 -280
  19. shinestacker/gui/base_form_dialog.py +11 -6
  20. shinestacker/gui/main_window.py +3 -2
  21. shinestacker/gui/menu_manager.py +12 -2
  22. shinestacker/gui/new_project.py +27 -22
  23. shinestacker/gui/project_controller.py +39 -23
  24. shinestacker/gui/project_converter.py +2 -8
  25. shinestacker/gui/project_editor.py +21 -7
  26. shinestacker/retouch/exif_data.py +5 -5
  27. shinestacker/retouch/shortcuts_help.py +4 -4
  28. shinestacker/retouch/vignetting_filter.py +12 -8
  29. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/METADATA +1 -1
  30. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/RECORD +34 -33
  31. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/WHEEL +0 -0
  32. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/entry_points.txt +0 -0
  33. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/licenses/LICENSE +0 -0
  34. {shinestacker-1.1.0.dist-info → shinestacker-1.2.0.dist-info}/top_level.txt +0 -0
shinestacker/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '1.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', 'MultiLayer', 'NoiseDetection', 'MaskNoise', 'Vignetting'
21
+ 'DepthMapStack', 'PyramidStack', 'PyramidTilesStack', 'PyramidAutoStack', 'MultiLayer',
22
+ 'NoiseDetection', 'MaskNoise', 'Vignetting'
20
23
  ]
@@ -1,5 +1,6 @@
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
@@ -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 {})}
@@ -166,6 +253,10 @@ def align_images(img_1, img_0, feature_config=None, matching_config=None, alignm
166
253
  h_ref, w_ref = img_1.shape[:2]
167
254
  h0, w0 = img_0.shape[:2]
168
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))
169
260
  fast_subsampling = alignment_config['fast_subsampling']
170
261
  min_good_matches = alignment_config['min_good_matches']
171
262
  while True:
@@ -225,6 +316,21 @@ 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)
@@ -303,13 +409,21 @@ 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: