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.
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/backgroundneutral.py +10 -1
- setiastro/saspro/blink_comparator_pro.py +474 -251
- setiastro/saspro/crop_dialog_pro.py +11 -1
- setiastro/saspro/doc_manager.py +1 -1
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/gui/main_window.py +93 -64
- setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
- setiastro/saspro/multiscale_decomp.py +710 -256
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +30 -11
- setiastro/saspro/selective_color.py +79 -20
- setiastro/saspro/shortcuts.py +94 -21
- setiastro/saspro/stacking_suite.py +296 -107
- setiastro/saspro/star_alignment.py +275 -330
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +1588 -516
- setiastro/saspro/translations/ar_translations.py +915 -684
- setiastro/saspro/translations/de_translations.py +442 -463
- setiastro/saspro/translations/es_translations.py +277 -47
- setiastro/saspro/translations/fr_translations.py +279 -47
- setiastro/saspro/translations/hi_translations.py +253 -21
- setiastro/saspro/translations/integrate_translations.py +3 -2
- setiastro/saspro/translations/it_translations.py +1211 -161
- setiastro/saspro/translations/ja_translations.py +3340 -3107
- setiastro/saspro/translations/pt_translations.py +3315 -3337
- setiastro/saspro/translations/ru_translations.py +351 -117
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +15902 -138
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14428 -133
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +11503 -7821
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +11168 -7812
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14733 -135
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +14347 -7821
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14860 -137
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +14904 -137
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11766 -168
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15115 -135
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +11206 -6729
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +282 -56
- setiastro/saspro/translations/uk_translations.py +264 -35
- setiastro/saspro/translations/zh_translations.py +282 -47
- setiastro/saspro/view_bundle.py +17 -17
- setiastro/saspro/widgets/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +26 -0
- setiastro/saspro/widgets/spinboxes.py +18 -0
- setiastro/saspro/wimi.py +65 -65
- setiastro/saspro/wims.py +33 -33
- setiastro/saspro/window_shelf.py +2 -2
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
self.
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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()
|
|
728
|
+
mode = self.combo_mode.currentText()
|
|
559
729
|
|
|
560
|
-
|
|
561
|
-
|
|
730
|
+
def do_one(i_w):
|
|
731
|
+
i, w = i_w
|
|
562
732
|
cfg = self.cfgs[i]
|
|
563
733
|
if not cfg.enabled:
|
|
564
|
-
|
|
565
|
-
else
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
if
|
|
597
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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.
|
|
652
|
-
self.
|
|
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.
|
|
870
|
+
if self.pix_base.pixmap().isNull():
|
|
656
871
|
return
|
|
657
872
|
self.view.resetTransform()
|
|
658
|
-
self.view.fitInView(self.
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
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
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
951
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
971
|
-
|
|
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,
|
|
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
|
-
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1399
|
+
Send the *final* multiscale result (same as Apply to Document)
|
|
1400
|
+
to a brand-new document via DocManager.
|
|
1029
1401
|
|
|
1030
|
-
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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.
|
|
1406
|
+
with self._busy_popup("Creating new document from multiscale result…"):
|
|
1407
|
+
self._recompute_decomp(force=False)
|
|
1035
1408
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1409
|
+
details = self._cached_layers
|
|
1410
|
+
residual = self._cached_residual
|
|
1411
|
+
if details is None or residual is None:
|
|
1412
|
+
return
|
|
1040
1413
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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 =
|
|
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 =
|
|
1465
|
+
out_final = out
|
|
1085
1466
|
|
|
1086
|
-
title =
|
|
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
|
|
1476
|
+
f"Failed to create new document:\n{e}"
|
|
1096
1477
|
)
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
|