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