setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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 setiastrosuitepro might be problematic. Click here for more details.

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -2,10 +2,11 @@
2
2
  from __future__ import annotations
3
3
  import numpy as np
4
4
  import cv2
5
-
5
+ import os
6
+ from concurrent.futures import ThreadPoolExecutor
6
7
  from dataclasses import dataclass
7
- from PyQt6.QtCore import Qt, QTimer
8
- from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon
8
+ from PyQt6.QtCore import Qt, QTimer, QRect, QRectF
9
+ from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon, QMovie
9
10
  from PyQt6.QtWidgets import (
10
11
  QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
11
12
  QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox,
@@ -13,11 +14,20 @@ from PyQt6.QtWidgets import (
13
14
  QGraphicsScene, QGraphicsPixmapItem, QMessageBox, QToolButton, QSlider, QSplitter,
14
15
  QProgressDialog, QApplication
15
16
  )
16
-
17
-
17
+ from contextlib import contextmanager
18
+ from setiastro.saspro.resources import get_resources
19
+ try:
20
+ cv2.setUseOptimized(True)
21
+ cv2.setNumThreads(0) # 0 = let OpenCV decide
22
+ except Exception:
23
+ pass
18
24
 
19
25
  class _ZoomPanView(QGraphicsView):
20
- def __init__(self, *args, **kwargs):
26
+ """
27
+ QGraphicsView that supports wheel-zoom and click-drag panning.
28
+ Calls on_view_changed() whenever viewport position/scale changes.
29
+ """
30
+ def __init__(self, *args, on_view_changed=None, **kwargs):
21
31
  super().__init__(*args, **kwargs)
22
32
  self.setDragMode(QGraphicsView.DragMode.NoDrag)
23
33
  self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
@@ -25,15 +35,21 @@ class _ZoomPanView(QGraphicsView):
25
35
 
26
36
  self._panning = False
27
37
  self._pan_start = None
38
+ self._on_view_changed = on_view_changed # callable or None
39
+
40
+ def _notify(self):
41
+ cb = self._on_view_changed
42
+ if callable(cb):
43
+ cb()
28
44
 
29
45
  def wheelEvent(self, ev):
30
- # Ctrl+wheel optional – but I’ll make plain wheel zoom since you asked
31
46
  delta = ev.angleDelta().y()
32
47
  if delta == 0:
33
48
  return
34
49
  factor = 1.25 if delta > 0 else 0.8
35
50
  self.scale(factor, factor)
36
51
  ev.accept()
52
+ self._notify()
37
53
 
38
54
  def mousePressEvent(self, ev):
39
55
  if ev.button() == Qt.MouseButton.LeftButton:
@@ -54,7 +70,10 @@ class _ZoomPanView(QGraphicsView):
54
70
  h.setValue(h.value() - delta.x())
55
71
  v.setValue(v.value() - delta.y())
56
72
  ev.accept()
73
+ # scrollbars will trigger _notify via their signals too, but harmless:
74
+ self._notify()
57
75
  return
76
+
58
77
  super().mouseMoveEvent(ev)
59
78
 
60
79
  def mouseReleaseEvent(self, ev):
@@ -67,6 +86,7 @@ class _ZoomPanView(QGraphicsView):
67
86
  super().mouseReleaseEvent(ev)
68
87
 
69
88
 
89
+
70
90
  # ─────────────────────────────────────────────
71
91
  # Core math (your backbone)
72
92
  # ─────────────────────────────────────────────
@@ -199,20 +219,27 @@ class MultiscaleDecompDialog(QDialog):
199
219
  self.setWindowFlag(Qt.WindowType.Window, True)
200
220
  self.setWindowModality(Qt.WindowModality.NonModal)
201
221
  self.setModal(False)
222
+ try:
223
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
224
+ except Exception:
225
+ pass # older PyQt6 versions
202
226
  self.setMinimumSize(1050, 700)
203
227
  self.residual_enabled = True
204
228
  self._layer_noise = None # list[float] per detail layer
205
-
229
+ self._cached_coarse = None
230
+ self._cached_img_id = None
206
231
  self._doc = doc
207
232
  base = getattr(doc, "image", None)
208
233
  if base is None:
209
234
  raise RuntimeError("Document has no image.")
210
235
 
211
236
  # normalize to float32 [0..1] ...
212
- img = np.asarray(base)
213
- img = img.astype(np.float32, copy=False)
214
- if img.dtype.kind in "ui":
215
- maxv = float(np.nanmax(img)) or 1.0
237
+ img0 = np.asarray(base)
238
+ is_int = (img0.dtype.kind in "ui")
239
+
240
+ img = img0.astype(np.float32, copy=False)
241
+ if is_int:
242
+ maxv = float(np.nanmax(img0)) or 1.0
216
243
  img = img / max(1.0, maxv)
217
244
  img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
218
245
 
@@ -230,6 +257,7 @@ class MultiscaleDecompDialog(QDialog):
230
257
  self._image = img3.copy() # working linear image (edited on Apply only)
231
258
  self._preview_img = img3.copy()
232
259
 
260
+
233
261
  # decomposition cache
234
262
  self._cached_layers = None
235
263
  self._cached_residual = None
@@ -246,7 +274,8 @@ class MultiscaleDecompDialog(QDialog):
246
274
  self._preview_timer.timeout.connect(self._rebuild_preview)
247
275
 
248
276
  self._build_ui()
249
-
277
+ H, W = self._image.shape[:2]
278
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
250
279
  # ───── NEW: initialization busy dialog ─────
251
280
  prog = QProgressDialog("Initializing multiscale decomposition…", "", 0, 0, self)
252
281
  prog.setWindowTitle("Multiscale Decomposition")
@@ -270,7 +299,6 @@ class MultiscaleDecompDialog(QDialog):
270
299
  def _build_ui(self):
271
300
  root = QHBoxLayout(self)
272
301
 
273
- # Splitter between preview (left) and controls (right)
274
302
  splitter = QSplitter(Qt.Orientation.Horizontal)
275
303
  root.addWidget(splitter)
276
304
 
@@ -279,13 +307,51 @@ class MultiscaleDecompDialog(QDialog):
279
307
  left = QVBoxLayout(left_widget)
280
308
 
281
309
  self.scene = QGraphicsScene(self)
282
- self.view = _ZoomPanView(self.scene)
310
+
311
+ self.view = _ZoomPanView(self.scene, on_view_changed=self._schedule_roi_preview)
283
312
  self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
284
- self.pix = QGraphicsPixmapItem()
285
- self.scene.addItem(self.pix)
286
313
 
287
- left.addWidget(self.view)
314
+ # Base full-image item (keeps zoom/pan working)
315
+ self.pix_base = QGraphicsPixmapItem()
316
+ self.pix_base.setOffset(0, 0)
317
+ self.scene.addItem(self.pix_base)
288
318
 
319
+ # ROI overlay item (updates fast)
320
+ self.pix_roi = QGraphicsPixmapItem()
321
+ self.pix_roi.setZValue(10) # draw above base
322
+ self.scene.addItem(self.pix_roi)
323
+
324
+ left.addWidget(self.view)
325
+ # Busy overlay (shown during recompute)
326
+ self.busy_label = QLabel("Computing…", self.view.viewport())
327
+ self.busy_label.setStyleSheet("""
328
+ QLabel {
329
+ background: rgba(0,0,0,140);
330
+ color: white;
331
+ padding: 6px 10px;
332
+ border-radius: 8px;
333
+ font-weight: 600;
334
+ }
335
+ """)
336
+ self.busy_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
337
+ self.busy_label.hide()
338
+ # --- Spinner (animated) ---
339
+ self.busy_spinner = QLabel()
340
+ self.busy_spinner.setFixedSize(20, 20)
341
+ self.busy_spinner.setToolTip("Computing…")
342
+ self.busy_spinner.setVisible(False)
343
+
344
+ gif_path = get_resources().SPINNER_GIF # <- canonical, works frozen/dev
345
+ gif_path = os.path.normpath(gif_path)
346
+
347
+ self._busy_movie = QMovie(gif_path)
348
+ self._busy_movie.setScaledSize(self.busy_spinner.size())
349
+ self.busy_spinner.setMovie(self._busy_movie)
350
+
351
+ self._busy_show_timer = QTimer(self)
352
+ self._busy_show_timer.setSingleShot(True)
353
+ self._busy_show_timer.timeout.connect(self._show_busy_overlay)
354
+ self._busy_depth = 0
289
355
  zoom_row = QHBoxLayout()
290
356
 
291
357
  self.zoom_out_btn = QToolButton()
@@ -304,8 +370,8 @@ class MultiscaleDecompDialog(QDialog):
304
370
  self.one_to_one_btn.setIcon(QIcon.fromTheme("zoom-original"))
305
371
  self.one_to_one_btn.setToolTip("1:1")
306
372
 
307
- self.zoom_out_btn.clicked.connect(lambda: self.view.scale(0.8, 0.8))
308
- self.zoom_in_btn.clicked.connect(lambda: self.view.scale(1.25, 1.25))
373
+ self.zoom_out_btn.clicked.connect(lambda: (self.view.scale(0.8, 0.8), self._schedule_roi_preview()))
374
+ self.zoom_in_btn.clicked.connect(lambda: (self.view.scale(1.25, 1.25), self._schedule_roi_preview()))
309
375
  self.fit_btn.clicked.connect(self._fit_view)
310
376
  self.one_to_one_btn.clicked.connect(self._one_to_one)
311
377
 
@@ -315,6 +381,8 @@ class MultiscaleDecompDialog(QDialog):
315
381
  zoom_row.addSpacing(10)
316
382
  zoom_row.addWidget(self.fit_btn)
317
383
  zoom_row.addWidget(self.one_to_one_btn)
384
+ zoom_row.addSpacing(10)
385
+ zoom_row.addWidget(self.busy_spinner) # <-- add here
318
386
  zoom_row.addStretch(1)
319
387
 
320
388
  left.addLayout(zoom_row)
@@ -338,7 +406,14 @@ class MultiscaleDecompDialog(QDialog):
338
406
  self.cb_linked_rgb = QCheckBox("Linked RGB (apply same params to all channels)")
339
407
  self.cb_linked_rgb.setChecked(True)
340
408
 
341
- # New: Mode combo (Mean vs Linear)
409
+ # NEW: Fast ROI preview
410
+ self.cb_fast_roi_preview = QCheckBox("Fast ROI preview (compute visible area only)")
411
+ self.cb_fast_roi_preview.setChecked(True)
412
+ self.cb_fast_roi_preview.setToolTip(
413
+ "When enabled, preview only computes the currently visible region (with padding for blur).\n"
414
+ "Apply/Send-to-Doc always computes the full image."
415
+ )
416
+
342
417
  self.combo_mode = QComboBox()
343
418
  self.combo_mode.addItems(["μ–σ Thresholding", "Linear"])
344
419
  self.combo_mode.setCurrentText("μ–σ Thresholding")
@@ -354,7 +429,8 @@ class MultiscaleDecompDialog(QDialog):
354
429
  form.addRow("Layers:", self.spin_layers)
355
430
  form.addRow("Base sigma:", self.spin_sigma)
356
431
  form.addRow(self.cb_linked_rgb)
357
- form.addRow("Mode:", self.combo_mode) # <── NEW ROW
432
+ form.addRow(self.cb_fast_roi_preview)
433
+ form.addRow("Mode:", self.combo_mode)
358
434
  form.addRow("Layer preview:", self.combo_preview)
359
435
 
360
436
  right.addWidget(gb_global)
@@ -366,14 +442,13 @@ class MultiscaleDecompDialog(QDialog):
366
442
  self.table.setHorizontalHeaderLabels(
367
443
  ["On", "Layer", "Scale", "Gain", "Thr (σ)", "Amt", "NR", "Type"]
368
444
  )
369
-
370
445
  self.table.verticalHeader().setVisible(False)
371
446
  self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
372
447
  self.table.setSelectionMode(self.table.SelectionMode.SingleSelection)
373
448
  v.addWidget(self.table)
374
449
  right.addWidget(gb_layers, stretch=1)
375
450
 
376
- # Per-layer editor (now with sliders)
451
+ # Per-layer editor...
377
452
  gb_edit = QGroupBox("Selected Layer")
378
453
  ef = QFormLayout(gb_edit)
379
454
  self.lbl_sel = QLabel("Layer: —")
@@ -456,7 +531,7 @@ class MultiscaleDecompDialog(QDialog):
456
531
 
457
532
  right.addWidget(gb_edit)
458
533
 
459
- # Buttons
534
+ # Buttons...
460
535
  btn_row = QHBoxLayout()
461
536
  self.btn_apply = QPushButton("Apply to Document")
462
537
  self.btn_detail_new = QPushButton("Send to New Document")
@@ -470,7 +545,6 @@ class MultiscaleDecompDialog(QDialog):
470
545
  btn_row.addWidget(self.btn_close)
471
546
  right.addLayout(btn_row)
472
547
 
473
- # Add widgets to splitter
474
548
  splitter.addWidget(left_widget)
475
549
  splitter.addWidget(right_widget)
476
550
  splitter.setStretchFactor(0, 2)
@@ -479,18 +553,17 @@ class MultiscaleDecompDialog(QDialog):
479
553
  # ----- Signals -----
480
554
  self.spin_layers.valueChanged.connect(self._on_layers_changed)
481
555
  self.spin_sigma.valueChanged.connect(self._on_global_changed)
482
- self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
556
+ self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
483
557
  self.combo_preview.currentIndexChanged.connect(self._schedule_preview)
558
+ self.cb_fast_roi_preview.toggled.connect(self._schedule_roi_preview)
484
559
 
485
560
  self.table.itemSelectionChanged.connect(self._on_table_select)
486
561
 
487
- # spinboxes -> layer cfg
488
562
  self.spin_gain.valueChanged.connect(self._on_layer_editor_changed)
489
563
  self.spin_thr.valueChanged.connect(self._on_layer_editor_changed)
490
564
  self.spin_amt.valueChanged.connect(self._on_layer_editor_changed)
491
565
  self.spin_denoise.valueChanged.connect(self._on_layer_editor_changed)
492
566
 
493
- # sliders -> spinboxes
494
567
  self.slider_gain.valueChanged.connect(self._on_gain_slider_changed)
495
568
  self.slider_thr.valueChanged.connect(self._on_thr_slider_changed)
496
569
  self.slider_amt.valueChanged.connect(self._on_amt_slider_changed)
@@ -501,37 +574,156 @@ class MultiscaleDecompDialog(QDialog):
501
574
  self.btn_split_layers.clicked.connect(self._split_layers_to_docs)
502
575
  self.btn_close.clicked.connect(self.reject)
503
576
 
577
+ # Connect viewport scroll changes
578
+ self._connect_viewport_signals()
579
+
504
580
  # ---------- Preview plumbing ----------
581
+ def _spinner_on(self):
582
+ if getattr(self, "_closing", False):
583
+ return
584
+ try:
585
+ sp = getattr(self, "busy_spinner", None)
586
+ if sp is None:
587
+ return
588
+ sp.setVisible(True)
589
+ mv = getattr(self, "_busy_movie", None)
590
+ if mv is not None and mv.state() != QMovie.MovieState.Running:
591
+ mv.start()
592
+ except RuntimeError:
593
+ return
594
+
595
+ def _spinner_off(self):
596
+ try:
597
+ sp = getattr(self, "busy_spinner", None)
598
+ mv = getattr(self, "_busy_movie", None)
599
+ if mv is not None:
600
+ mv.stop()
601
+ if sp is not None:
602
+ sp.setVisible(False)
603
+ except RuntimeError:
604
+ return
605
+
606
+
607
+ def _show_busy_overlay(self):
608
+ try:
609
+ self.busy_label.adjustSize()
610
+ self.busy_label.move(12, 12)
611
+ self.busy_label.show()
612
+ except Exception:
613
+ pass
614
+
615
+ def _begin_busy(self):
616
+ self._busy_depth += 1
617
+ if self._busy_depth == 1:
618
+ # show only if compute isn't instant
619
+ self._busy_show_timer.start(120)
620
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
621
+
622
+ def _end_busy(self):
623
+ self._busy_depth = max(0, self._busy_depth - 1)
624
+ if self._busy_depth == 0:
625
+ self._busy_show_timer.stop()
626
+ self.busy_label.hide()
627
+ QApplication.restoreOverrideCursor()
628
+
629
+
505
630
  def _on_mode_changed(self, idx: int):
506
631
  # Re-enable/disable controls as needed
507
632
  self._update_param_widgets_for_mode()
508
633
  self._schedule_preview()
509
634
 
510
635
  def _schedule_preview(self):
636
+ if getattr(self, "_closing", False):
637
+ return
511
638
  self._preview_timer.start(60)
512
639
 
640
+ def _schedule_roi_preview(self):
641
+ if getattr(self, "_closing", False):
642
+ return
643
+ self._preview_timer.start(60)
644
+
645
+ def _connect_viewport_signals(self):
646
+ """
647
+ Any pan/scroll should schedule ROI preview recompute.
648
+ """
649
+ try:
650
+ self.view.horizontalScrollBar().valueChanged.connect(self._schedule_roi_preview)
651
+ self.view.verticalScrollBar().valueChanged.connect(self._schedule_roi_preview)
652
+ except Exception:
653
+ pass
654
+
513
655
  def _recompute_decomp(self, force: bool = False):
514
656
  layers = int(self.spin_layers.value())
515
657
  base_sigma = float(self.spin_sigma.value())
516
- key = (layers, base_sigma)
517
658
 
518
- if (not force) and self._cached_key == key and self._cached_layers is not None:
659
+ # cache identity: sigma + the actual ndarray buffer identity
660
+ img_id = id(self._image)
661
+ key = (base_sigma, img_id)
662
+
663
+ if force or self._cached_key != key or self._cached_layers is None or self._cached_coarse is None:
664
+ self.layers = layers
665
+ self.base_sigma = base_sigma
666
+
667
+ c = self._image.astype(np.float32, copy=False)
668
+ details = []
669
+ coarse = []
670
+
671
+ for k in range(layers):
672
+ sigma = base_sigma * (2 ** k)
673
+ c_next = _blur_gaussian(c, sigma)
674
+ details.append(c - c_next)
675
+ c = c_next
676
+ coarse.append(c)
677
+
678
+ self._cached_layers = details
679
+ self._cached_coarse = coarse
680
+ self._cached_residual = c
681
+ self._cached_key = key
682
+
683
+ self._layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in self._cached_layers]
684
+ self._sync_cfgs_and_ui()
519
685
  return
520
686
 
687
+ # reuse existing pyramid, adjust layer count
688
+ old_layers = len(self._cached_layers)
521
689
  self.layers = layers
522
690
  self.base_sigma = base_sigma
523
691
 
524
- self._cached_layers, self._cached_residual = multiscale_decompose(
525
- self._image, layers=self.layers, base_sigma=self.base_sigma
526
- )
527
- self._cached_key = key
692
+ if layers == old_layers:
693
+ self._sync_cfgs_and_ui()
694
+ return
695
+
696
+ if layers < old_layers:
697
+ self._cached_layers = self._cached_layers[:layers]
698
+ self._cached_coarse = self._cached_coarse[:layers]
699
+ self._layer_noise = self._layer_noise[:layers]
700
+
701
+ if layers > 0:
702
+ self._cached_residual = self._cached_coarse[layers - 1]
703
+ else:
704
+ self._cached_residual = self._image.astype(np.float32, copy=False)
705
+
706
+ self._sync_cfgs_and_ui()
707
+ return
708
+
709
+ # Grow: compute only missing layers from current residual
710
+ c = self._cached_residual
711
+ for k in range(old_layers, layers):
712
+ sigma = base_sigma * (2 ** k)
713
+ c_next = _blur_gaussian(c, sigma)
714
+ w = c - c_next
715
+
716
+ self._cached_layers.append(w)
717
+ self._cached_coarse.append(c_next)
718
+ self._layer_noise.append(_robust_sigma(w) if w.size else 1e-6)
528
719
 
529
- self._layer_noise = []
530
- for w in self._cached_layers:
531
- sigma = _robust_sigma(w) if w.size else 1e-6
532
- self._layer_noise.append(sigma)
720
+ c = c_next
533
721
 
534
- # ensure cfg list matches layer count
722
+ self._cached_residual = c
723
+ self._sync_cfgs_and_ui()
724
+
725
+ def _sync_cfgs_and_ui(self):
726
+ # ensure cfg list matches layer count (your existing logic, just moved)
535
727
  if len(self.cfgs) != self.layers:
536
728
  old = self.cfgs[:]
537
729
  self.cfgs = [LayerCfg() for _ in range(self.layers)]
@@ -542,12 +734,6 @@ class MultiscaleDecompDialog(QDialog):
542
734
  self._refresh_preview_combo()
543
735
 
544
736
  def _build_tuned_layers(self):
545
- """
546
- Ensure decomposition is current and apply per-layer ops
547
- using the current mode and layer configs.
548
-
549
- Returns (tuned_layers, residual) or (None, None) on failure.
550
- """
551
737
  self._recompute_decomp(force=False)
552
738
 
553
739
  details = self._cached_layers
@@ -555,62 +741,95 @@ class MultiscaleDecompDialog(QDialog):
555
741
  if details is None or residual is None:
556
742
  return None, None
557
743
 
558
- mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
744
+ mode = self.combo_mode.currentText()
559
745
 
560
- tuned = []
561
- for i, w in enumerate(details):
746
+ def do_one(i_w):
747
+ i, w = i_w
562
748
  cfg = self.cfgs[i]
563
749
  if not cfg.enabled:
564
- tuned.append(np.zeros_like(w))
565
- else:
566
- sigma = None
567
- if self._layer_noise is not None and i < len(self._layer_noise):
568
- sigma = self._layer_noise[i]
569
- tuned.append(
570
- apply_layer_ops(
571
- w,
572
- cfg.bias_gain,
573
- cfg.thr,
574
- cfg.amount,
575
- cfg.denoise,
576
- sigma,
577
- mode=mode,
578
- )
579
- )
750
+ return i, np.zeros_like(w)
751
+ sigma = self._layer_noise[i] if self._layer_noise and i < len(self._layer_noise) else None
752
+ out = apply_layer_ops(
753
+ w,
754
+ cfg.bias_gain,
755
+ cfg.thr,
756
+ cfg.amount,
757
+ cfg.denoise,
758
+ sigma,
759
+ mode=mode,
760
+ )
761
+ return i, out
580
762
 
581
- return tuned, residual
763
+ n = len(details)
764
+ if n == 0:
765
+ return [], residual
766
+
767
+ max_workers = min(os.cpu_count() or 4, n)
582
768
 
769
+ tuned = [None] * n
770
+ # ThreadPoolExecutor is fine here because apply_layer_ops is numpy-heavy
771
+ # (but real speed-up depends on GIL/OpenCV/BLAS behavior).
772
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
773
+ for i, out in ex.map(do_one, enumerate(details)):
774
+ tuned[i] = out
775
+
776
+ return tuned, residual
583
777
 
584
778
  def _rebuild_preview(self):
585
- tuned, residual = self._build_tuned_layers()
586
- if tuned is None or residual is None:
779
+ if getattr(self, "_closing", False):
587
780
  return
781
+ self._spinner_on()
782
+ QTimer.singleShot(0, self._rebuild_preview_impl)
588
783
 
589
- # reconstruction (keep raw version for visualization)
590
- res = residual if self.residual_enabled else np.zeros_like(residual)
591
- out_raw = multiscale_reconstruct(tuned, res)
592
- out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
784
+ def _rebuild_preview_impl(self):
785
+ if getattr(self, "_closing", False):
786
+ return
593
787
 
594
- sel = self.combo_preview.currentData()
595
- if sel is None or sel == "final":
596
- if not self.residual_enabled:
597
- # Detail-only visualization: SAME style as detail-layer preview
598
- d = out_raw.astype(np.float32, copy=False)
599
- vis = 0.5 + d * 4.0 # same gain as single-layer view
600
- self._preview_img = np.clip(vis, 0.0, 1.0).astype(np.float32, copy=False)
601
- else:
602
- self._preview_img = out
788
+ #self._begin_busy()
789
+ try:
790
+ # ROI preview can't work until we have *some* pixmap in the scene to derive visible rects from.
791
+ roi_ok = (
792
+ getattr(self, "cb_fast_roi_preview", None) is not None
793
+ and self.cb_fast_roi_preview.isChecked()
794
+ and not self.pix_base.pixmap().isNull()
795
+ )
603
796
 
604
- elif sel == "residual":
605
- self._preview_img = np.clip(residual, 0, 1)
797
+ if roi_ok:
798
+ roi_img, roi_rect = self._compute_preview_roi()
799
+ if roi_img is None:
800
+ return
801
+ self._refresh_pix_roi(roi_img, roi_rect)
802
+ return
606
803
 
607
- else:
608
- # sel is int index of detail layer
609
- w = tuned[int(sel)]
610
- vis = np.clip(0.5 + (w * 4.0), 0.0, 1.0)
611
- self._preview_img = vis.astype(np.float32, copy=False)
804
+ # ---- Full-frame preview (bootstrap path, and when ROI disabled) ----
805
+ tuned, residual = self._build_tuned_layers()
806
+ if tuned is None or residual is None:
807
+ return
612
808
 
613
- self._refresh_pix()
809
+ res = residual if self.residual_enabled else np.zeros_like(residual)
810
+ out_raw = multiscale_reconstruct(tuned, res)
811
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
812
+
813
+ sel = self.combo_preview.currentData()
814
+ if sel is None or sel == "final":
815
+ if not self.residual_enabled:
816
+ d = out_raw.astype(np.float32, copy=False)
817
+ vis = 0.5 + d * 4.0
818
+ self._preview_img = np.clip(vis, 0.0, 1.0).astype(np.float32, copy=False)
819
+ else:
820
+ self._preview_img = out
821
+ elif sel == "residual":
822
+ self._preview_img = np.clip(residual, 0, 1)
823
+ else:
824
+ w = tuned[int(sel)]
825
+ vis = np.clip(0.5 + (w * 4.0), 0.0, 1.0)
826
+ self._preview_img = vis.astype(np.float32, copy=False)
827
+
828
+ self._refresh_pix()
829
+
830
+ finally:
831
+ #self._end_busy()
832
+ self._spinner_off()
614
833
 
615
834
  def _update_param_widgets_for_mode(self):
616
835
  linear = (self.combo_mode.currentText() == "Linear")
@@ -648,17 +867,38 @@ class MultiscaleDecompDialog(QDialog):
648
867
  return QPixmap.fromImage(qimg)
649
868
 
650
869
  def _refresh_pix(self):
651
- self.pix.setPixmap(self._np_to_qpix(self._preview_img))
652
- self.scene.setSceneRect(self.pix.boundingRect())
870
+ pm = self._np_to_qpix(self._preview_img)
871
+ self.pix_base.setPixmap(pm)
872
+ self.pix_base.setOffset(0, 0)
873
+
874
+ # Optional: clear ROI overlay on full refresh
875
+ self.pix_roi.setPixmap(QPixmap())
876
+ self.pix_roi.setOffset(0, 0)
877
+
878
+ H, W = self._image.shape[:2]
879
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
880
+
881
+ def _fast_preview_enabled(self) -> bool:
882
+ return bool(getattr(self, "cb_fast_roi_preview", None)) and self.cb_fast_roi_preview.isChecked()
883
+
884
+ def _invalidate_full_decomp_cache(self):
885
+ self._cached_layers = None
886
+ self._cached_coarse = None
887
+ self._cached_residual = None
888
+ self._cached_key = None
889
+ self._layer_noise = None
890
+
653
891
 
654
892
  def _fit_view(self):
655
- if self.pix.pixmap().isNull():
893
+ if self.pix_base.pixmap().isNull():
656
894
  return
657
895
  self.view.resetTransform()
658
- self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
896
+ self.view.fitInView(self.pix_base, Qt.AspectRatioMode.KeepAspectRatio)
897
+ self._schedule_roi_preview()
659
898
 
660
899
  def _one_to_one(self):
661
900
  self.view.resetTransform()
901
+ self._schedule_roi_preview()
662
902
 
663
903
  # ---------- Table / layer editing ----------
664
904
  def _on_gain_slider_changed(self, v: int):
@@ -796,7 +1036,29 @@ class MultiscaleDecompDialog(QDialog):
796
1036
 
797
1037
  self._schedule_preview()
798
1038
 
1039
+ @contextmanager
1040
+ def _busy_popup(self, text: str):
1041
+ dlg = QProgressDialog(text, "", 0, 0, self)
1042
+ dlg.setWindowTitle("Multiscale Decomposition")
1043
+ dlg.setWindowModality(Qt.WindowModality.ApplicationModal)
1044
+ dlg.setCancelButton(None)
1045
+ dlg.setMinimumDuration(0)
1046
+ dlg.show()
1047
+
1048
+ self._spinner_on()
1049
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
1050
+ QApplication.processEvents()
799
1051
 
1052
+ try:
1053
+ yield dlg
1054
+ finally:
1055
+ try:
1056
+ dlg.close()
1057
+ except Exception:
1058
+ pass
1059
+ QApplication.restoreOverrideCursor()
1060
+ self._spinner_off()
1061
+ QApplication.processEvents()
800
1062
 
801
1063
  def _on_table_select(self):
802
1064
  rows = {it.row() for it in self.table.selectedItems()}
@@ -879,10 +1141,34 @@ class MultiscaleDecompDialog(QDialog):
879
1141
  self._schedule_preview()
880
1142
 
881
1143
  def _on_layers_changed(self):
1144
+ # Always update counts/UI
1145
+ self.layers = int(self.spin_layers.value())
1146
+
1147
+ # Ensure cfgs length matches new layer count and table/combos update
1148
+ self._sync_cfgs_and_ui()
1149
+
1150
+ if self._fast_preview_enabled():
1151
+ # Do NOT recompute full pyramid here; ROI preview will compute on-demand
1152
+ self._invalidate_full_decomp_cache()
1153
+ self._schedule_roi_preview()
1154
+ return
1155
+
1156
+ # Old behavior for non-ROI mode
882
1157
  self._recompute_decomp(force=True)
883
1158
  self._schedule_preview()
884
1159
 
1160
+
885
1161
  def _on_global_changed(self):
1162
+ self.base_sigma = float(self.spin_sigma.value())
1163
+
1164
+ # Update table scale column text (it uses self.base_sigma)
1165
+ self._sync_cfgs_and_ui()
1166
+
1167
+ if self._fast_preview_enabled():
1168
+ self._invalidate_full_decomp_cache()
1169
+ self._schedule_roi_preview()
1170
+ return
1171
+
886
1172
  self._recompute_decomp(force=True)
887
1173
  self._schedule_preview()
888
1174
 
@@ -897,193 +1183,311 @@ class MultiscaleDecompDialog(QDialog):
897
1183
  finally:
898
1184
  self.combo_preview.blockSignals(False)
899
1185
 
900
- # ---------- Apply to doc ----------
901
- def _commit_to_doc(self):
902
- tuned, residual = self._build_tuned_layers()
903
- if tuned is None or residual is None:
904
- return
1186
+ def _visible_image_rect(self) -> tuple[int, int, int, int] | None:
1187
+ # Use full image rect, NOT the pixmap bounds
1188
+ H, W = self._image.shape[:2]
1189
+ full_item_rect_scene = QRectF(0, 0, W, H)
1190
+
1191
+ vr = self.view.viewport().rect()
1192
+ tl = self.view.mapToScene(vr.topLeft())
1193
+ br = self.view.mapToScene(vr.bottomRight())
1194
+ scene_rect = QRectF(tl, br).normalized()
1195
+
1196
+ inter = scene_rect.intersected(full_item_rect_scene)
1197
+ if inter.isEmpty():
1198
+ return None
1199
+
1200
+ x0 = int(np.floor(inter.left()))
1201
+ y0 = int(np.floor(inter.top()))
1202
+ x1 = int(np.ceil(inter.right()))
1203
+ y1 = int(np.ceil(inter.bottom()))
1204
+
1205
+ x0 = max(0, min(W, x0))
1206
+ x1 = max(0, min(W, x1))
1207
+ y0 = max(0, min(H, y0))
1208
+ y1 = max(0, min(H, y1))
1209
+
1210
+ if x1 <= x0 or y1 <= y0:
1211
+ return None
1212
+ return (x0, y0, x1, y1)
1213
+
1214
+
1215
+ def _compute_preview_roi(self):
1216
+ """
1217
+ Computes preview only for visible ROI (plus padding), then returns:
1218
+ (roi_img_float01, (x0,y0,x1,y1)) or (None, None)
1219
+ roi_img is float32 RGB [0..1] and corresponds exactly to visible roi box.
1220
+ """
1221
+ vis = self._visible_image_rect()
1222
+ if vis is None:
1223
+ return None, None
1224
+
1225
+ x0, y0, x1, y1 = vis
1226
+
1227
+ # ROI cap to prevent enormous compute in fit-to-preview scenarios
1228
+ MAX = 1400
1229
+ w = x1 - x0
1230
+ h = y1 - y0
1231
+ if w > MAX:
1232
+ cx = (x0 + x1) // 2
1233
+ x0 = max(0, cx - MAX // 2)
1234
+ x1 = min(self._image.shape[1], x0 + MAX)
1235
+ if h > MAX:
1236
+ cy = (y0 + y1) // 2
1237
+ y0 = max(0, cy - MAX // 2)
1238
+ y1 = min(self._image.shape[0], y0 + MAX)
1239
+
1240
+ layers = int(self.spin_layers.value())
1241
+ base_sigma = float(self.spin_sigma.value())
1242
+ if layers <= 0:
1243
+ return None, None
1244
+
1245
+ sigma_max = base_sigma * (2 ** (layers - 1))
1246
+ pad = int(np.ceil(3.0 * sigma_max)) + 2
1247
+
1248
+ H, W = self._image.shape[:2]
1249
+ px0 = max(0, x0 - pad)
1250
+ py0 = max(0, y0 - pad)
1251
+ px1 = min(W, x1 + pad)
1252
+ py1 = min(H, y1 + pad)
1253
+
1254
+ crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
1255
+
1256
+ details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
1257
+ layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
1258
+
1259
+ mode = self.combo_mode.currentText()
1260
+
1261
+ # Apply per-layer ops (threaded)
1262
+ def do_one(i_w):
1263
+ i, w = i_w
1264
+ cfg = self.cfgs[i]
1265
+ if not cfg.enabled:
1266
+ return i, np.zeros_like(w)
1267
+ return i, apply_layer_ops(
1268
+ w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
1269
+ layer_noise[i], mode=mode
1270
+ )
1271
+
1272
+ tuned = [None] * len(details)
1273
+ max_workers = min(os.cpu_count() or 4, len(details) or 1)
1274
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
1275
+ for i, out in ex.map(do_one, enumerate(details)):
1276
+ tuned[i] = out
905
1277
 
906
- # --- Reconstruction (match preview behavior) ---
907
1278
  res = residual if self.residual_enabled else np.zeros_like(residual)
908
1279
  out_raw = multiscale_reconstruct(tuned, res)
909
1280
 
1281
+ # Match preview rules
910
1282
  if not self.residual_enabled:
911
- # Detail-only result: same “mid-gray + gain” hack as preview
912
- d = out_raw.astype(np.float32, copy=False)
913
- out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1283
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
914
1284
  else:
915
1285
  out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
916
1286
 
917
- # convert back to mono if original was mono
918
- if self._orig_mono:
919
- mono = out[..., 0]
920
- if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
921
- mono = mono[:, :, None]
922
- out_final = mono.astype(np.float32, copy=False)
923
- else:
924
- out_final = out
1287
+ # Crop back to visible ROI coordinates
1288
+ cx0 = x0 - px0
1289
+ cy0 = y0 - py0
1290
+ cx1 = cx0 + (x1 - x0)
1291
+ cy1 = cy0 + (y1 - y0)
925
1292
 
926
- try:
927
- if hasattr(self._doc, "set_image"):
928
- self._doc.set_image(out_final, step_name="Multiscale Decomposition")
929
- elif hasattr(self._doc, "apply_numpy"):
930
- self._doc.apply_numpy(out_final, step_name="Multiscale Decomposition")
931
- else:
932
- self._doc.image = out_final
933
- except Exception as e:
934
- QMessageBox.critical(self, "Multiscale Decomposition", f"Failed to write to document:\n{e}")
935
- return
1293
+ roi = out[cy0:cy1, cx0:cx1]
1294
+ return roi, (x0, y0, x1, y1)
936
1295
 
937
- if hasattr(self.parent(), "_refresh_active_view"):
938
- try:
939
- self.parent()._refresh_active_view()
940
- except Exception:
941
- pass
1296
+ def _np_to_qpix_roi_comp(self, img_rgb01: np.ndarray) -> QPixmap:
1297
+ """
1298
+ img_rgb01 is float32 RGB [0..1]
1299
+ """
1300
+ arr = np.ascontiguousarray(np.clip(img_rgb01 * 255.0, 0, 255).astype(np.uint8))
1301
+ h, w = arr.shape[:2]
1302
+ if arr.ndim == 2:
1303
+ arr = np.repeat(arr[:, :, None], 3, axis=2)
942
1304
 
943
- self.accept()
1305
+ bytes_per_line = arr.strides[0]
1306
+ qimg = QImage(arr.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
1307
+ return QPixmap.fromImage(qimg.copy()) # copy to detach from numpy buffer
944
1308
 
945
- def _send_detail_to_new_doc(self):
946
- """
947
- Send the *final* multiscale result (same as Apply to Document)
948
- to a brand-new document via DocManager.
1309
+ def _refresh_pix_roi(self, roi_img01: np.ndarray, roi_rect: tuple[int,int,int,int]):
1310
+ x0, y0, x1, y1 = roi_rect
1311
+ pm = self._np_to_qpix_roi_comp(roi_img01)
949
1312
 
950
- - If residual is enabled: standard 0..1 clipped composite.
951
- - If residual is disabled: uses the mid-gray detail-only hack
952
- (0.5 + d*4.0), just like the preview/commit path.
953
- """
954
- self._recompute_decomp(force=False)
1313
+ self.pix_roi.setPixmap(pm)
1314
+ self.pix_roi.setOffset(x0, y0)
955
1315
 
956
- details = self._cached_layers
957
- residual = self._cached_residual
958
- if details is None or residual is None:
959
- return
1316
+ # Keep scene bounds as full image, not ROI
1317
+ H, W = self._image.shape[:2]
1318
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
960
1319
 
961
- dm = self._get_doc_manager()
962
- if dm is None:
963
- QMessageBox.warning(
964
- self,
965
- "Multiscale Decomposition",
966
- "No DocManager available to create a new document."
967
- )
968
- return
969
1320
 
970
- # --- Same tuned-layer logic as _commit_to_doc -------------------
971
- mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
1321
+ def _build_preview_roi(self):
1322
+ vis = self._visible_image_rect()
1323
+ if vis is None:
1324
+ return None
972
1325
 
1326
+ x0,y0,x1,y1 = vis
1327
+ layers = int(self.spin_layers.value())
1328
+ base_sigma = float(self.spin_sigma.value())
1329
+
1330
+ if layers <= 0:
1331
+ return None
1332
+
1333
+ sigma_max = base_sigma * (2 ** (layers - 1))
1334
+ pad = int(np.ceil(3.0 * sigma_max)) + 2
1335
+
1336
+ H, W = self._image.shape[:2]
1337
+ px0 = max(0, x0 - pad); py0 = max(0, y0 - pad)
1338
+ px1 = min(W, x1 + pad); py1 = min(H, y1 + pad)
1339
+
1340
+ crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
1341
+
1342
+ # Decompose crop
1343
+ details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
1344
+
1345
+ # noise per layer (crop-based) — good enough for preview
1346
+ layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
1347
+
1348
+ # Apply tuning per layer (can thread this like we discussed)
1349
+ mode = self.combo_mode.currentText()
973
1350
  tuned = []
974
- for i, w in enumerate(details):
1351
+ for i,w in enumerate(details):
975
1352
  cfg = self.cfgs[i]
976
1353
  if not cfg.enabled:
977
1354
  tuned.append(np.zeros_like(w))
978
1355
  else:
979
- sigma = None
980
- if self._layer_noise is not None and i < len(self._layer_noise):
981
- sigma = self._layer_noise[i]
982
- tuned.append(
983
- apply_layer_ops(
984
- w,
985
- cfg.bias_gain,
986
- cfg.thr,
987
- cfg.amount,
988
- cfg.denoise,
989
- sigma,
990
- mode=mode,
991
- )
992
- )
1356
+ tuned.append(apply_layer_ops(w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
1357
+ layer_noise[i], mode=mode))
993
1358
 
994
- # --- Reconstruction (match Apply-to-Document behavior) ----------
995
1359
  res = residual if self.residual_enabled else np.zeros_like(residual)
996
1360
  out_raw = multiscale_reconstruct(tuned, res)
997
1361
 
1362
+ # Match your preview rules
998
1363
  if not self.residual_enabled:
999
- # Detail-only flavor: mid-gray + gain hack
1000
- d = out_raw.astype(np.float32, copy=False)
1001
- out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1364
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1002
1365
  else:
1003
1366
  out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1004
1367
 
1005
- # --- Back to original mono/color layout -------------------------
1006
- if self._orig_mono:
1007
- mono = out[..., 0]
1008
- if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1009
- mono = mono[:, :, None]
1010
- out_final = mono.astype(np.float32, copy=False)
1011
- else:
1012
- out_final = out
1368
+ # Crop back from padded-crop coords to visible ROI coords
1369
+ cx0 = x0 - px0; cy0 = y0 - py0
1370
+ cx1 = cx0 + (x1 - x0); cy1 = cy0 + (y1 - y0)
1371
+ return out[cy0:cy1, cx0:cx1], (x0,y0,x1,y1)
1013
1372
 
1014
- title = "Multiscale Result"
1015
- meta = self._build_new_doc_metadata(title, out_final)
1016
1373
 
1017
- try:
1018
- dm.create_document(out_final, metadata=meta, name=title)
1019
- except Exception as e:
1020
- QMessageBox.critical(
1021
- self,
1022
- "Multiscale Decomposition",
1023
- f"Failed to create new document:\n{e}"
1024
- )
1374
+ # ---------- Apply to doc ----------
1375
+ def _commit_to_doc(self):
1376
+ with self._busy_popup("Applying multiscale result to document…"):
1377
+ tuned, residual = self._build_tuned_layers()
1378
+ if tuned is None or residual is None:
1379
+ return
1025
1380
 
1026
- def _split_layers_to_docs(self):
1381
+ # --- Reconstruction (match preview behavior) ---
1382
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1383
+ out_raw = multiscale_reconstruct(tuned, res)
1384
+
1385
+ if not self.residual_enabled:
1386
+ # Detail-only result: same “mid-gray + gain” hack as preview
1387
+ d = out_raw.astype(np.float32, copy=False)
1388
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1389
+ else:
1390
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1391
+
1392
+ # convert back to mono if original was mono
1393
+ if self._orig_mono:
1394
+ mono = out[..., 0]
1395
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1396
+ mono = mono[:, :, None]
1397
+ out_final = mono.astype(np.float32, copy=False)
1398
+ else:
1399
+ out_final = out
1400
+
1401
+ try:
1402
+ if hasattr(self._doc, "set_image"):
1403
+ self._doc.set_image(out_final, step_name="Multiscale Decomposition")
1404
+ elif hasattr(self._doc, "apply_numpy"):
1405
+ self._doc.apply_numpy(out_final, step_name="Multiscale Decomposition")
1406
+ else:
1407
+ self._doc.image = out_final
1408
+ except Exception as e:
1409
+ QMessageBox.critical(self, "Multiscale Decomposition", f"Failed to write to document:\n{e}")
1410
+ return
1411
+
1412
+ if hasattr(self.parent(), "_refresh_active_view"):
1413
+ try:
1414
+ self.parent()._refresh_active_view()
1415
+ except Exception:
1416
+ pass
1417
+
1418
+ self.accept()
1419
+
1420
+ def _send_detail_to_new_doc(self):
1027
1421
  """
1028
- Create a new document for each tuned detail layer *and* the residual.
1422
+ Send the *final* multiscale result (same as Apply to Document)
1423
+ to a brand-new document via DocManager.
1029
1424
 
1030
- - Detail layers use the same mid-gray visualization as the per-layer preview:
1031
- vis = 0.5 + layer*4.0
1032
- - Residual layer is just the residual itself (0..1 clipped).
1425
+ - If residual is enabled: standard 0..1 clipped composite.
1426
+ - If residual is disabled: uses the mid-gray detail-only hack
1427
+ (0.5 + d*4.0), just like the preview/commit path.
1033
1428
  """
1034
- self._recompute_decomp(force=False)
1429
+ with self._busy_popup("Creating new document from multiscale result…"):
1430
+ self._recompute_decomp(force=False)
1035
1431
 
1036
- details = self._cached_layers
1037
- residual = self._cached_residual
1038
- if details is None or residual is None:
1039
- return
1432
+ details = self._cached_layers
1433
+ residual = self._cached_residual
1434
+ if details is None or residual is None:
1435
+ return
1040
1436
 
1041
- dm = self._get_doc_manager()
1042
- if dm is None:
1043
- QMessageBox.warning(
1044
- self,
1045
- "Multiscale Decomposition",
1046
- "No DocManager available to create new documents."
1047
- )
1048
- return
1437
+ dm = self._get_doc_manager()
1438
+ if dm is None:
1439
+ QMessageBox.warning(
1440
+ self,
1441
+ "Multiscale Decomposition",
1442
+ "No DocManager available to create a new document."
1443
+ )
1444
+ return
1049
1445
 
1050
- mode = self.combo_mode.currentText()
1051
- # Build tuned layers just like everywhere else
1052
- tuned = []
1053
- for i, w in enumerate(details):
1054
- cfg = self.cfgs[i]
1055
- if not cfg.enabled:
1056
- tuned.append(np.zeros_like(w))
1057
- else:
1058
- sigma = None
1059
- if self._layer_noise is not None and i < len(self._layer_noise):
1060
- sigma = self._layer_noise[i]
1061
- tuned.append(
1062
- apply_layer_ops(
1063
- w,
1064
- cfg.bias_gain,
1065
- cfg.thr,
1066
- cfg.amount,
1067
- cfg.denoise,
1068
- sigma,
1069
- mode=mode,
1446
+ # --- Same tuned-layer logic as _commit_to_doc -------------------
1447
+ mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
1448
+
1449
+ tuned = []
1450
+ for i, w in enumerate(details):
1451
+ cfg = self.cfgs[i]
1452
+ if not cfg.enabled:
1453
+ tuned.append(np.zeros_like(w))
1454
+ else:
1455
+ sigma = None
1456
+ if self._layer_noise is not None and i < len(self._layer_noise):
1457
+ sigma = self._layer_noise[i]
1458
+ tuned.append(
1459
+ apply_layer_ops(
1460
+ w,
1461
+ cfg.bias_gain,
1462
+ cfg.thr,
1463
+ cfg.amount,
1464
+ cfg.denoise,
1465
+ sigma,
1466
+ mode=mode,
1467
+ )
1070
1468
  )
1071
- )
1072
1469
 
1073
- # ---- 1) Detail layers ------------------------------------------
1074
- for i, layer in enumerate(tuned):
1075
- d = layer.astype(np.float32, copy=False)
1076
- vis = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1470
+ # --- Reconstruction (match Apply-to-Document behavior) ----------
1471
+ res = residual if self.residual_enabled else np.zeros_like(residual)
1472
+ out_raw = multiscale_reconstruct(tuned, res)
1473
+
1474
+ if not self.residual_enabled:
1475
+ # Detail-only flavor: mid-gray + gain hack
1476
+ d = out_raw.astype(np.float32, copy=False)
1477
+ out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1478
+ else:
1479
+ out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1077
1480
 
1481
+ # --- Back to original mono/color layout -------------------------
1078
1482
  if self._orig_mono:
1079
- mono = vis[..., 0]
1483
+ mono = out[..., 0]
1080
1484
  if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1081
1485
  mono = mono[:, :, None]
1082
1486
  out_final = mono.astype(np.float32, copy=False)
1083
1487
  else:
1084
- out_final = vis
1488
+ out_final = out
1085
1489
 
1086
- title = f"Multiscale Detail Layer {i+1}"
1490
+ title = "Multiscale Result"
1087
1491
  meta = self._build_new_doc_metadata(title, out_final)
1088
1492
 
1089
1493
  try:
@@ -1092,35 +1496,108 @@ class MultiscaleDecompDialog(QDialog):
1092
1496
  QMessageBox.critical(
1093
1497
  self,
1094
1498
  "Multiscale Decomposition",
1095
- f"Failed to create document for layer {i+1}:\n{e}"
1499
+ f"Failed to create new document:\n{e}"
1096
1500
  )
1097
- # Don’t bail entirely on first error if you’d rather continue;
1098
- # right now we stop on first hard failure.
1501
+
1502
+ def _split_layers_to_docs(self):
1503
+ """
1504
+ Create a new document for each tuned detail layer *and* the residual.
1505
+
1506
+ - Detail layers use the same mid-gray visualization as the per-layer preview:
1507
+ vis = 0.5 + layer*4.0
1508
+ - Residual layer is just the residual itself (0..1 clipped).
1509
+ """
1510
+ with self._busy_popup("Splitting layers into documents…") as prog:
1511
+ self._recompute_decomp(force=False)
1512
+
1513
+ details = self._cached_layers
1514
+ residual = self._cached_residual
1515
+ if details is None or residual is None:
1099
1516
  return
1100
1517
 
1101
- # ---- 2) Residual layer -----------------------------------------
1102
- try:
1103
- res = residual.astype(np.float32, copy=False)
1104
- res_img = np.clip(res, 0.0, 1.0)
1518
+ dm = self._get_doc_manager()
1519
+ if dm is None:
1520
+ QMessageBox.warning(
1521
+ self,
1522
+ "Multiscale Decomposition",
1523
+ "No DocManager available to create new documents."
1524
+ )
1525
+ return
1105
1526
 
1106
- if self._orig_mono:
1107
- mono = res_img[..., 0]
1108
- if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1109
- mono = mono[:, :, None]
1110
- res_final = mono.astype(np.float32, copy=False)
1111
- else:
1112
- res_final = res_img
1527
+ mode = self.combo_mode.currentText()
1528
+ # Build tuned layers just like everywhere else
1529
+ tuned = []
1530
+ for i, w in enumerate(details):
1531
+ cfg = self.cfgs[i]
1532
+ if not cfg.enabled:
1533
+ tuned.append(np.zeros_like(w))
1534
+ else:
1535
+ sigma = None
1536
+ if self._layer_noise is not None and i < len(self._layer_noise):
1537
+ sigma = self._layer_noise[i]
1538
+ tuned.append(
1539
+ apply_layer_ops(
1540
+ w,
1541
+ cfg.bias_gain,
1542
+ cfg.thr,
1543
+ cfg.amount,
1544
+ cfg.denoise,
1545
+ sigma,
1546
+ mode=mode,
1547
+ )
1548
+ )
1113
1549
 
1114
- r_title = "Multiscale Residual Layer"
1115
- r_meta = self._build_new_doc_metadata(r_title, res_final)
1550
+ # ---- 1) Detail layers ------------------------------------------
1551
+ for i, layer in enumerate(tuned):
1552
+ d = layer.astype(np.float32, copy=False)
1553
+ vis = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1554
+
1555
+ if self._orig_mono:
1556
+ mono = vis[..., 0]
1557
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1558
+ mono = mono[:, :, None]
1559
+ out_final = mono.astype(np.float32, copy=False)
1560
+ else:
1561
+ out_final = vis
1562
+
1563
+ title = f"Multiscale Detail Layer {i+1}"
1564
+ meta = self._build_new_doc_metadata(title, out_final)
1565
+
1566
+ try:
1567
+ dm.create_document(out_final, metadata=meta, name=title)
1568
+ except Exception as e:
1569
+ QMessageBox.critical(
1570
+ self,
1571
+ "Multiscale Decomposition",
1572
+ f"Failed to create document for layer {i+1}:\n{e}"
1573
+ )
1574
+ # Don’t bail entirely on first error if you’d rather continue;
1575
+ # right now we stop on first hard failure.
1576
+ return
1116
1577
 
1117
- dm.create_document(res_final, metadata=r_meta, name=r_title)
1118
- except Exception as e:
1119
- QMessageBox.critical(
1120
- self,
1121
- "Multiscale Decomposition",
1122
- f"Failed to create residual-layer document:\n{e}"
1123
- )
1578
+ # ---- 2) Residual layer -----------------------------------------
1579
+ try:
1580
+ res = residual.astype(np.float32, copy=False)
1581
+ res_img = np.clip(res, 0.0, 1.0)
1582
+
1583
+ if self._orig_mono:
1584
+ mono = res_img[..., 0]
1585
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1586
+ mono = mono[:, :, None]
1587
+ res_final = mono.astype(np.float32, copy=False)
1588
+ else:
1589
+ res_final = res_img
1590
+
1591
+ r_title = "Multiscale Residual Layer"
1592
+ r_meta = self._build_new_doc_metadata(r_title, res_final)
1593
+
1594
+ dm.create_document(res_final, metadata=r_meta, name=r_title)
1595
+ except Exception as e:
1596
+ QMessageBox.critical(
1597
+ self,
1598
+ "Multiscale Decomposition",
1599
+ f"Failed to create residual-layer document:\n{e}"
1600
+ )
1124
1601
 
1125
1602
 
1126
1603
 
@@ -1291,3 +1768,19 @@ class _MultiScaleDecompPresetDialog(QDialog):
1291
1768
  "linked_rgb": bool(self.cb_linked.isChecked()),
1292
1769
  "layers_cfg": out_layers,
1293
1770
  }
1771
+ def closeEvent(self, ev):
1772
+ self._closing = True
1773
+ try:
1774
+ if hasattr(self, "_preview_timer"):
1775
+ self._preview_timer.stop()
1776
+ if hasattr(self, "_busy_show_timer"):
1777
+ self._busy_show_timer.stop()
1778
+ # Optional: disconnect scrollbars to stop ROI scheduling
1779
+ try:
1780
+ self.view.horizontalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
1781
+ self.view.verticalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
1782
+ except Exception:
1783
+ pass
1784
+ except Exception:
1785
+ pass
1786
+ super().closeEvent(ev)