setiastrosuitepro 1.6.2__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.
Files changed (72) hide show
  1. setiastro/images/rotatearbitrary.png +0 -0
  2. setiastro/saspro/_generated/build_info.py +2 -2
  3. setiastro/saspro/backgroundneutral.py +10 -1
  4. setiastro/saspro/blink_comparator_pro.py +474 -251
  5. setiastro/saspro/crop_dialog_pro.py +11 -1
  6. setiastro/saspro/doc_manager.py +1 -1
  7. setiastro/saspro/function_bundle.py +16 -16
  8. setiastro/saspro/gui/main_window.py +93 -64
  9. setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
  10. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  11. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  12. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  13. setiastro/saspro/multiscale_decomp.py +710 -256
  14. setiastro/saspro/remove_stars_preset.py +55 -13
  15. setiastro/saspro/resources.py +30 -11
  16. setiastro/saspro/selective_color.py +79 -20
  17. setiastro/saspro/shortcuts.py +94 -21
  18. setiastro/saspro/stacking_suite.py +296 -107
  19. setiastro/saspro/star_alignment.py +275 -330
  20. setiastro/saspro/status_log_dock.py +1 -1
  21. setiastro/saspro/swap_manager.py +77 -42
  22. setiastro/saspro/translations/all_source_strings.json +1588 -516
  23. setiastro/saspro/translations/ar_translations.py +915 -684
  24. setiastro/saspro/translations/de_translations.py +442 -463
  25. setiastro/saspro/translations/es_translations.py +277 -47
  26. setiastro/saspro/translations/fr_translations.py +279 -47
  27. setiastro/saspro/translations/hi_translations.py +253 -21
  28. setiastro/saspro/translations/integrate_translations.py +3 -2
  29. setiastro/saspro/translations/it_translations.py +1211 -161
  30. setiastro/saspro/translations/ja_translations.py +3340 -3107
  31. setiastro/saspro/translations/pt_translations.py +3315 -3337
  32. setiastro/saspro/translations/ru_translations.py +351 -117
  33. setiastro/saspro/translations/saspro_ar.qm +0 -0
  34. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  35. setiastro/saspro/translations/saspro_de.qm +0 -0
  36. setiastro/saspro/translations/saspro_de.ts +14428 -133
  37. setiastro/saspro/translations/saspro_es.qm +0 -0
  38. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  39. setiastro/saspro/translations/saspro_fr.qm +0 -0
  40. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  41. setiastro/saspro/translations/saspro_hi.qm +0 -0
  42. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  43. setiastro/saspro/translations/saspro_it.qm +0 -0
  44. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  45. setiastro/saspro/translations/saspro_ja.qm +0 -0
  46. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  47. setiastro/saspro/translations/saspro_pt.qm +0 -0
  48. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  49. setiastro/saspro/translations/saspro_ru.qm +0 -0
  50. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  51. setiastro/saspro/translations/saspro_sw.qm +0 -0
  52. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  53. setiastro/saspro/translations/saspro_uk.qm +0 -0
  54. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  55. setiastro/saspro/translations/saspro_zh.qm +0 -0
  56. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  57. setiastro/saspro/translations/sw_translations.py +282 -56
  58. setiastro/saspro/translations/uk_translations.py +264 -35
  59. setiastro/saspro/translations/zh_translations.py +282 -47
  60. setiastro/saspro/view_bundle.py +17 -17
  61. setiastro/saspro/widgets/minigame/game.js +11 -6
  62. setiastro/saspro/widgets/resource_monitor.py +26 -0
  63. setiastro/saspro/widgets/spinboxes.py +18 -0
  64. setiastro/saspro/wimi.py +65 -65
  65. setiastro/saspro/wims.py +33 -33
  66. setiastro/saspro/window_shelf.py +2 -2
  67. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
  68. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
  69. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  70. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  71. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  72. {setiastrosuitepro-1.6.2.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
  # ─────────────────────────────────────────────
@@ -202,17 +222,20 @@ class MultiscaleDecompDialog(QDialog):
202
222
  self.setMinimumSize(1050, 700)
203
223
  self.residual_enabled = True
204
224
  self._layer_noise = None # list[float] per detail layer
205
-
225
+ self._cached_coarse = None
226
+ self._cached_img_id = None
206
227
  self._doc = doc
207
228
  base = getattr(doc, "image", None)
208
229
  if base is None:
209
230
  raise RuntimeError("Document has no image.")
210
231
 
211
232
  # 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
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
216
239
  img = img / max(1.0, maxv)
217
240
  img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
218
241
 
@@ -230,6 +253,7 @@ class MultiscaleDecompDialog(QDialog):
230
253
  self._image = img3.copy() # working linear image (edited on Apply only)
231
254
  self._preview_img = img3.copy()
232
255
 
256
+
233
257
  # decomposition cache
234
258
  self._cached_layers = None
235
259
  self._cached_residual = None
@@ -246,7 +270,8 @@ class MultiscaleDecompDialog(QDialog):
246
270
  self._preview_timer.timeout.connect(self._rebuild_preview)
247
271
 
248
272
  self._build_ui()
249
-
273
+ H, W = self._image.shape[:2]
274
+ self.scene.setSceneRect(QRectF(0, 0, W, H))
250
275
  # ───── NEW: initialization busy dialog ─────
251
276
  prog = QProgressDialog("Initializing multiscale decomposition…", "", 0, 0, self)
252
277
  prog.setWindowTitle("Multiscale Decomposition")
@@ -270,7 +295,6 @@ class MultiscaleDecompDialog(QDialog):
270
295
  def _build_ui(self):
271
296
  root = QHBoxLayout(self)
272
297
 
273
- # Splitter between preview (left) and controls (right)
274
298
  splitter = QSplitter(Qt.Orientation.Horizontal)
275
299
  root.addWidget(splitter)
276
300
 
@@ -279,13 +303,51 @@ class MultiscaleDecompDialog(QDialog):
279
303
  left = QVBoxLayout(left_widget)
280
304
 
281
305
  self.scene = QGraphicsScene(self)
282
- self.view = _ZoomPanView(self.scene)
306
+
307
+ self.view = _ZoomPanView(self.scene, on_view_changed=self._schedule_roi_preview)
283
308
  self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
284
- self.pix = QGraphicsPixmapItem()
285
- self.scene.addItem(self.pix)
286
309
 
287
- 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)
288
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
289
351
  zoom_row = QHBoxLayout()
290
352
 
291
353
  self.zoom_out_btn = QToolButton()
@@ -304,8 +366,8 @@ class MultiscaleDecompDialog(QDialog):
304
366
  self.one_to_one_btn.setIcon(QIcon.fromTheme("zoom-original"))
305
367
  self.one_to_one_btn.setToolTip("1:1")
306
368
 
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))
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()))
309
371
  self.fit_btn.clicked.connect(self._fit_view)
310
372
  self.one_to_one_btn.clicked.connect(self._one_to_one)
311
373
 
@@ -315,6 +377,8 @@ class MultiscaleDecompDialog(QDialog):
315
377
  zoom_row.addSpacing(10)
316
378
  zoom_row.addWidget(self.fit_btn)
317
379
  zoom_row.addWidget(self.one_to_one_btn)
380
+ zoom_row.addSpacing(10)
381
+ zoom_row.addWidget(self.busy_spinner) # <-- add here
318
382
  zoom_row.addStretch(1)
319
383
 
320
384
  left.addLayout(zoom_row)
@@ -338,7 +402,14 @@ class MultiscaleDecompDialog(QDialog):
338
402
  self.cb_linked_rgb = QCheckBox("Linked RGB (apply same params to all channels)")
339
403
  self.cb_linked_rgb.setChecked(True)
340
404
 
341
- # 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
+
342
413
  self.combo_mode = QComboBox()
343
414
  self.combo_mode.addItems(["μ–σ Thresholding", "Linear"])
344
415
  self.combo_mode.setCurrentText("μ–σ Thresholding")
@@ -354,7 +425,8 @@ class MultiscaleDecompDialog(QDialog):
354
425
  form.addRow("Layers:", self.spin_layers)
355
426
  form.addRow("Base sigma:", self.spin_sigma)
356
427
  form.addRow(self.cb_linked_rgb)
357
- form.addRow("Mode:", self.combo_mode) # <── NEW ROW
428
+ form.addRow(self.cb_fast_roi_preview)
429
+ form.addRow("Mode:", self.combo_mode)
358
430
  form.addRow("Layer preview:", self.combo_preview)
359
431
 
360
432
  right.addWidget(gb_global)
@@ -366,14 +438,13 @@ class MultiscaleDecompDialog(QDialog):
366
438
  self.table.setHorizontalHeaderLabels(
367
439
  ["On", "Layer", "Scale", "Gain", "Thr (σ)", "Amt", "NR", "Type"]
368
440
  )
369
-
370
441
  self.table.verticalHeader().setVisible(False)
371
442
  self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
372
443
  self.table.setSelectionMode(self.table.SelectionMode.SingleSelection)
373
444
  v.addWidget(self.table)
374
445
  right.addWidget(gb_layers, stretch=1)
375
446
 
376
- # Per-layer editor (now with sliders)
447
+ # Per-layer editor...
377
448
  gb_edit = QGroupBox("Selected Layer")
378
449
  ef = QFormLayout(gb_edit)
379
450
  self.lbl_sel = QLabel("Layer: —")
@@ -456,7 +527,7 @@ class MultiscaleDecompDialog(QDialog):
456
527
 
457
528
  right.addWidget(gb_edit)
458
529
 
459
- # Buttons
530
+ # Buttons...
460
531
  btn_row = QHBoxLayout()
461
532
  self.btn_apply = QPushButton("Apply to Document")
462
533
  self.btn_detail_new = QPushButton("Send to New Document")
@@ -470,7 +541,6 @@ class MultiscaleDecompDialog(QDialog):
470
541
  btn_row.addWidget(self.btn_close)
471
542
  right.addLayout(btn_row)
472
543
 
473
- # Add widgets to splitter
474
544
  splitter.addWidget(left_widget)
475
545
  splitter.addWidget(right_widget)
476
546
  splitter.setStretchFactor(0, 2)
@@ -479,18 +549,17 @@ class MultiscaleDecompDialog(QDialog):
479
549
  # ----- Signals -----
480
550
  self.spin_layers.valueChanged.connect(self._on_layers_changed)
481
551
  self.spin_sigma.valueChanged.connect(self._on_global_changed)
482
- self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
552
+ self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
483
553
  self.combo_preview.currentIndexChanged.connect(self._schedule_preview)
554
+ self.cb_fast_roi_preview.toggled.connect(self._schedule_roi_preview)
484
555
 
485
556
  self.table.itemSelectionChanged.connect(self._on_table_select)
486
557
 
487
- # spinboxes -> layer cfg
488
558
  self.spin_gain.valueChanged.connect(self._on_layer_editor_changed)
489
559
  self.spin_thr.valueChanged.connect(self._on_layer_editor_changed)
490
560
  self.spin_amt.valueChanged.connect(self._on_layer_editor_changed)
491
561
  self.spin_denoise.valueChanged.connect(self._on_layer_editor_changed)
492
562
 
493
- # sliders -> spinboxes
494
563
  self.slider_gain.valueChanged.connect(self._on_gain_slider_changed)
495
564
  self.slider_thr.valueChanged.connect(self._on_thr_slider_changed)
496
565
  self.slider_amt.valueChanged.connect(self._on_amt_slider_changed)
@@ -501,37 +570,144 @@ class MultiscaleDecompDialog(QDialog):
501
570
  self.btn_split_layers.clicked.connect(self._split_layers_to_docs)
502
571
  self.btn_close.clicked.connect(self.reject)
503
572
 
573
+ # Connect viewport scroll changes
574
+ self._connect_viewport_signals()
575
+
504
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
+
505
616
  def _on_mode_changed(self, idx: int):
506
617
  # Re-enable/disable controls as needed
507
618
  self._update_param_widgets_for_mode()
508
619
  self._schedule_preview()
509
620
 
510
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
511
627
  self._preview_timer.start(60)
512
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
+
513
639
  def _recompute_decomp(self, force: bool = False):
514
640
  layers = int(self.spin_layers.value())
515
641
  base_sigma = float(self.spin_sigma.value())
516
- key = (layers, base_sigma)
517
642
 
518
- 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()
519
669
  return
520
670
 
671
+ # reuse existing pyramid, adjust layer count
672
+ old_layers = len(self._cached_layers)
521
673
  self.layers = layers
522
674
  self.base_sigma = base_sigma
523
675
 
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
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)
528
689
 
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)
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()
533
708
 
534
- # 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)
535
711
  if len(self.cfgs) != self.layers:
536
712
  old = self.cfgs[:]
537
713
  self.cfgs = [LayerCfg() for _ in range(self.layers)]
@@ -542,12 +718,6 @@ class MultiscaleDecompDialog(QDialog):
542
718
  self._refresh_preview_combo()
543
719
 
544
720
  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
721
  self._recompute_decomp(force=False)
552
722
 
553
723
  details = self._cached_layers
@@ -555,62 +725,88 @@ class MultiscaleDecompDialog(QDialog):
555
725
  if details is None or residual is None:
556
726
  return None, None
557
727
 
558
- mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
728
+ mode = self.combo_mode.currentText()
559
729
 
560
- tuned = []
561
- for i, w in enumerate(details):
730
+ def do_one(i_w):
731
+ i, w = i_w
562
732
  cfg = self.cfgs[i]
563
733
  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
- )
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
580
746
 
581
- 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)
582
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
583
761
 
584
762
  def _rebuild_preview(self):
585
- tuned, residual = self._build_tuned_layers()
586
- if tuned is None or residual is None:
587
- 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
+ )
588
773
 
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)
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
593
780
 
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
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
603
785
 
604
- elif sel == "residual":
605
- 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)
606
789
 
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)
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()
612
806
 
613
- self._refresh_pix()
807
+ finally:
808
+ #self._end_busy()
809
+ self._spinner_off()
614
810
 
615
811
  def _update_param_widgets_for_mode(self):
616
812
  linear = (self.combo_mode.currentText() == "Linear")
@@ -648,17 +844,38 @@ class MultiscaleDecompDialog(QDialog):
648
844
  return QPixmap.fromImage(qimg)
649
845
 
650
846
  def _refresh_pix(self):
651
- self.pix.setPixmap(self._np_to_qpix(self._preview_img))
652
- 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
+
653
868
 
654
869
  def _fit_view(self):
655
- if self.pix.pixmap().isNull():
870
+ if self.pix_base.pixmap().isNull():
656
871
  return
657
872
  self.view.resetTransform()
658
- self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
873
+ self.view.fitInView(self.pix_base, Qt.AspectRatioMode.KeepAspectRatio)
874
+ self._schedule_roi_preview()
659
875
 
660
876
  def _one_to_one(self):
661
877
  self.view.resetTransform()
878
+ self._schedule_roi_preview()
662
879
 
663
880
  # ---------- Table / layer editing ----------
664
881
  def _on_gain_slider_changed(self, v: int):
@@ -796,7 +1013,29 @@ class MultiscaleDecompDialog(QDialog):
796
1013
 
797
1014
  self._schedule_preview()
798
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()
799
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()
800
1039
 
801
1040
  def _on_table_select(self):
802
1041
  rows = {it.row() for it in self.table.selectedItems()}
@@ -879,10 +1118,34 @@ class MultiscaleDecompDialog(QDialog):
879
1118
  self._schedule_preview()
880
1119
 
881
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
882
1134
  self._recompute_decomp(force=True)
883
1135
  self._schedule_preview()
884
1136
 
1137
+
885
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
+
886
1149
  self._recompute_decomp(force=True)
887
1150
  self._schedule_preview()
888
1151
 
@@ -897,193 +1160,311 @@ class MultiscaleDecompDialog(QDialog):
897
1160
  finally:
898
1161
  self.combo_preview.blockSignals(False)
899
1162
 
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
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
905
1254
 
906
- # --- Reconstruction (match preview behavior) ---
907
1255
  res = residual if self.residual_enabled else np.zeros_like(residual)
908
1256
  out_raw = multiscale_reconstruct(tuned, res)
909
1257
 
1258
+ # Match preview rules
910
1259
  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)
1260
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
914
1261
  else:
915
1262
  out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
916
1263
 
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
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)
925
1269
 
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
1270
+ roi = out[cy0:cy1, cx0:cx1]
1271
+ return roi, (x0, y0, x1, y1)
936
1272
 
937
- if hasattr(self.parent(), "_refresh_active_view"):
938
- try:
939
- self.parent()._refresh_active_view()
940
- except Exception:
941
- 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)
942
1281
 
943
- 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
944
1285
 
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.
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)
949
1289
 
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)
1290
+ self.pix_roi.setPixmap(pm)
1291
+ self.pix_roi.setOffset(x0, y0)
955
1292
 
956
- details = self._cached_layers
957
- residual = self._cached_residual
958
- if details is None or residual is None:
959
- 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))
960
1296
 
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
1297
 
970
- # --- Same tuned-layer logic as _commit_to_doc -------------------
971
- 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)
972
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()
973
1327
  tuned = []
974
- for i, w in enumerate(details):
1328
+ for i,w in enumerate(details):
975
1329
  cfg = self.cfgs[i]
976
1330
  if not cfg.enabled:
977
1331
  tuned.append(np.zeros_like(w))
978
1332
  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
- )
1333
+ tuned.append(apply_layer_ops(w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
1334
+ layer_noise[i], mode=mode))
993
1335
 
994
- # --- Reconstruction (match Apply-to-Document behavior) ----------
995
1336
  res = residual if self.residual_enabled else np.zeros_like(residual)
996
1337
  out_raw = multiscale_reconstruct(tuned, res)
997
1338
 
1339
+ # Match your preview rules
998
1340
  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)
1341
+ out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
1002
1342
  else:
1003
1343
  out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
1004
1344
 
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
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)
1013
1349
 
1014
- title = "Multiscale Result"
1015
- meta = self._build_new_doc_metadata(title, out_final)
1016
1350
 
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
- )
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
1025
1357
 
1026
- 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):
1027
1398
  """
1028
- 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.
1029
1401
 
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).
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.
1033
1405
  """
1034
- self._recompute_decomp(force=False)
1406
+ with self._busy_popup("Creating new document from multiscale result…"):
1407
+ self._recompute_decomp(force=False)
1035
1408
 
1036
- details = self._cached_layers
1037
- residual = self._cached_residual
1038
- if details is None or residual is None:
1039
- return
1409
+ details = self._cached_layers
1410
+ residual = self._cached_residual
1411
+ if details is None or residual is None:
1412
+ return
1040
1413
 
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
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
1049
1422
 
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,
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
+ )
1070
1445
  )
1071
- )
1072
1446
 
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)
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)
1077
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 -------------------------
1078
1459
  if self._orig_mono:
1079
- mono = vis[..., 0]
1460
+ mono = out[..., 0]
1080
1461
  if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
1081
1462
  mono = mono[:, :, None]
1082
1463
  out_final = mono.astype(np.float32, copy=False)
1083
1464
  else:
1084
- out_final = vis
1465
+ out_final = out
1085
1466
 
1086
- title = f"Multiscale Detail Layer {i+1}"
1467
+ title = "Multiscale Result"
1087
1468
  meta = self._build_new_doc_metadata(title, out_final)
1088
1469
 
1089
1470
  try:
@@ -1092,35 +1473,108 @@ class MultiscaleDecompDialog(QDialog):
1092
1473
  QMessageBox.critical(
1093
1474
  self,
1094
1475
  "Multiscale Decomposition",
1095
- f"Failed to create document for layer {i+1}:\n{e}"
1476
+ f"Failed to create new document:\n{e}"
1096
1477
  )
1097
- # Don’t bail entirely on first error if you’d rather continue;
1098
- # 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:
1099
1493
  return
1100
1494
 
1101
- # ---- 2) Residual layer -----------------------------------------
1102
- try:
1103
- res = residual.astype(np.float32, copy=False)
1104
- 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
1105
1503
 
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
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
+ )
1113
1526
 
1114
- r_title = "Multiscale Residual Layer"
1115
- 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
1116
1554
 
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
- )
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
+ )
1124
1578
 
1125
1579
 
1126
1580