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