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