setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
# src/setiastro/saspro/narrowband_normalization.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PIL import Image
|
|
7
|
+
import cv2
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import traceback
|
|
10
|
+
from PyQt6.QtCore import Qt, QSize, QEvent, QTimer, QPoint, QThread, pyqtSignal
|
|
11
|
+
from PyQt6.QtWidgets import (
|
|
12
|
+
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea,
|
|
13
|
+
QFileDialog, QInputDialog, QMessageBox, QCheckBox, QSizePolicy,
|
|
14
|
+
QComboBox, QGroupBox, QFormLayout, QDoubleSpinBox, QSlider
|
|
15
|
+
)
|
|
16
|
+
from PyQt6.QtGui import QPixmap, QImage, QCursor, QIcon
|
|
17
|
+
|
|
18
|
+
# legacy loader (same one DocManager uses)
|
|
19
|
+
from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
|
|
20
|
+
|
|
21
|
+
# your statistical stretch (mono + color) like SASv2 (for DISPLAY only)
|
|
22
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
23
|
+
|
|
24
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
25
|
+
|
|
26
|
+
from setiastro.saspro.linear_fit import linear_fit_mono_to_ref, _nanmedian
|
|
27
|
+
|
|
28
|
+
from setiastro.saspro.imageops.narrowband_normalization import normalize_narrowband, NBNParams
|
|
29
|
+
|
|
30
|
+
from setiastro.saspro.backgroundneutral import background_neutralize_rgb, auto_rect_50x50
|
|
31
|
+
from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _NBNJob:
|
|
36
|
+
ha: np.ndarray | None
|
|
37
|
+
oiii: np.ndarray | None
|
|
38
|
+
sii: np.ndarray | None
|
|
39
|
+
params: NBNParams
|
|
40
|
+
step_name: str
|
|
41
|
+
|
|
42
|
+
class _NBNWorker(QThread):
|
|
43
|
+
progress = pyqtSignal(int, str)
|
|
44
|
+
failed = pyqtSignal(str)
|
|
45
|
+
done = pyqtSignal(object, str) # (np.ndarray, step_name)
|
|
46
|
+
|
|
47
|
+
def __init__(self, job: _NBNJob):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.job = job
|
|
50
|
+
|
|
51
|
+
def run(self):
|
|
52
|
+
try:
|
|
53
|
+
def cb(pct: int, msg: str = ""):
|
|
54
|
+
self.progress.emit(int(pct), str(msg))
|
|
55
|
+
|
|
56
|
+
out = normalize_narrowband(
|
|
57
|
+
self.job.ha, self.job.oiii, self.job.sii,
|
|
58
|
+
self.job.params,
|
|
59
|
+
progress_cb=cb,
|
|
60
|
+
)
|
|
61
|
+
self.progress.emit(99, "Rendering Preview...")
|
|
62
|
+
self.done.emit(out, self.job.step_name)
|
|
63
|
+
|
|
64
|
+
except Exception:
|
|
65
|
+
self.failed.emit(traceback.format_exc())
|
|
66
|
+
|
|
67
|
+
class NarrowbandNormalization(QWidget):
|
|
68
|
+
def __init__(self, doc_manager=None, parent=None):
|
|
69
|
+
super().__init__(parent)
|
|
70
|
+
|
|
71
|
+
# Force top-level floating window behavior even if parent is main window
|
|
72
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
73
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
74
|
+
try:
|
|
75
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
self.doc_manager = doc_manager
|
|
80
|
+
self.setWindowTitle("Narrowband Normalization")
|
|
81
|
+
|
|
82
|
+
# raw channels (float32 [0..1])
|
|
83
|
+
self.ha: np.ndarray | None = None
|
|
84
|
+
self.oiii: np.ndarray | None = None
|
|
85
|
+
self.sii: np.ndarray | None = None
|
|
86
|
+
self.osc1: np.ndarray | None = None # (Ha/OIII)
|
|
87
|
+
self.osc2: np.ndarray | None = None # (SII/OIII)
|
|
88
|
+
|
|
89
|
+
self._dim_mismatch_accepted = False
|
|
90
|
+
|
|
91
|
+
# result
|
|
92
|
+
self.final: np.ndarray | None = None # RGB float32 [0..1]
|
|
93
|
+
self._base_pm: QPixmap | None = None
|
|
94
|
+
|
|
95
|
+
# preview state
|
|
96
|
+
self._zoom = 1.0
|
|
97
|
+
self._min_zoom = 0.05
|
|
98
|
+
self._max_zoom = 6.0
|
|
99
|
+
self._panning = False
|
|
100
|
+
self._pan_last: QPoint | None = None
|
|
101
|
+
|
|
102
|
+
# debounce
|
|
103
|
+
self._debounce = QTimer(self)
|
|
104
|
+
self._debounce.setInterval(250)
|
|
105
|
+
self._debounce.setSingleShot(True)
|
|
106
|
+
self._debounce.timeout.connect(self._kick_preview_compute)
|
|
107
|
+
# async compute control
|
|
108
|
+
self._calc_seq = 0 # increments on every requested recompute
|
|
109
|
+
self._active_seq = 0 # seq of currently running worker (optional)
|
|
110
|
+
self._worker = None # current worker ref
|
|
111
|
+
self._build_ui()
|
|
112
|
+
|
|
113
|
+
# ---------------- UI ----------------
|
|
114
|
+
def _build_ui(self):
|
|
115
|
+
# Create all widgets FIRST (fixes btn_ha missing, etc.)
|
|
116
|
+
self._init_widgets()
|
|
117
|
+
self._connect_signals()
|
|
118
|
+
|
|
119
|
+
outer = QVBoxLayout(self)
|
|
120
|
+
outer.setContentsMargins(8, 8, 8, 8)
|
|
121
|
+
outer.setSpacing(6)
|
|
122
|
+
|
|
123
|
+
root = QHBoxLayout()
|
|
124
|
+
root.setSpacing(10)
|
|
125
|
+
outer.addLayout(root, 1)
|
|
126
|
+
|
|
127
|
+
# ---------------- LEFT PANEL ----------------
|
|
128
|
+
left_scroll = QScrollArea(self)
|
|
129
|
+
left_scroll.setWidgetResizable(True)
|
|
130
|
+
left_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
131
|
+
left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
132
|
+
|
|
133
|
+
left_host = QWidget(self)
|
|
134
|
+
left_scroll.setWidget(left_host)
|
|
135
|
+
|
|
136
|
+
left_row = QHBoxLayout(left_host)
|
|
137
|
+
left_row.setContentsMargins(0, 0, 0, 0)
|
|
138
|
+
left_row.setSpacing(10)
|
|
139
|
+
|
|
140
|
+
colA = QVBoxLayout(); colA.setSpacing(8)
|
|
141
|
+
colB = QVBoxLayout(); colB.setSpacing(8)
|
|
142
|
+
|
|
143
|
+
# Column A: loaders
|
|
144
|
+
colA.addWidget(self.grp_import)
|
|
145
|
+
|
|
146
|
+
colA.addWidget(QLabel("<b>Load channels</b>"))
|
|
147
|
+
|
|
148
|
+
self.grp_nb = QGroupBox("Narrowband channels", self)
|
|
149
|
+
nbv = QVBoxLayout(self.grp_nb); nbv.setSpacing(4)
|
|
150
|
+
for btn, lab in (
|
|
151
|
+
(self.btn_ha, self.lbl_ha),
|
|
152
|
+
(self.btn_oiii, self.lbl_oiii),
|
|
153
|
+
(self.btn_sii, self.lbl_sii),
|
|
154
|
+
):
|
|
155
|
+
nbv.addWidget(btn)
|
|
156
|
+
nbv.addWidget(lab)
|
|
157
|
+
|
|
158
|
+
self.grp_osc = QGroupBox("OSC extractions", self)
|
|
159
|
+
oscv = QVBoxLayout(self.grp_osc); oscv.setSpacing(4)
|
|
160
|
+
for btn, lab in (
|
|
161
|
+
(self.btn_osc1, self.lbl_osc1),
|
|
162
|
+
(self.btn_osc2, self.lbl_osc2),
|
|
163
|
+
):
|
|
164
|
+
oscv.addWidget(btn)
|
|
165
|
+
oscv.addWidget(lab)
|
|
166
|
+
|
|
167
|
+
colA.addWidget(self.grp_nb)
|
|
168
|
+
colA.addWidget(self.grp_osc)
|
|
169
|
+
|
|
170
|
+
# extras sections referenced by _refresh_visibility
|
|
171
|
+
colA.addWidget(self.grp_hoo_extras)
|
|
172
|
+
colA.addWidget(self.grp_sho_extras)
|
|
173
|
+
colA.addStretch(1)
|
|
174
|
+
|
|
175
|
+
# Column B: normalization + actions
|
|
176
|
+
colB.addWidget(self.grp_norm)
|
|
177
|
+
|
|
178
|
+
actions = QGroupBox("Actions", self)
|
|
179
|
+
actv = QVBoxLayout(actions); actv.setSpacing(6)
|
|
180
|
+
for b in (self.btn_clear, self.btn_preview, self.btn_apply, self.btn_push):
|
|
181
|
+
b.setMinimumHeight(28)
|
|
182
|
+
actv.addWidget(b)
|
|
183
|
+
colB.addWidget(actions)
|
|
184
|
+
colB.addStretch(1)
|
|
185
|
+
|
|
186
|
+
left_row.addLayout(colA, 1)
|
|
187
|
+
left_row.addLayout(colB, 1)
|
|
188
|
+
|
|
189
|
+
left_scroll.setMinimumWidth(480)
|
|
190
|
+
#left_scroll.setMaximumWidth(720)
|
|
191
|
+
root.addWidget(left_scroll, 0)
|
|
192
|
+
|
|
193
|
+
# ---------------- RIGHT PANEL (Preview) ----------------
|
|
194
|
+
right = QVBoxLayout()
|
|
195
|
+
right.setSpacing(8)
|
|
196
|
+
|
|
197
|
+
tools = QHBoxLayout()
|
|
198
|
+
tools.setSpacing(6)
|
|
199
|
+
|
|
200
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
201
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
202
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
203
|
+
|
|
204
|
+
self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
|
|
205
|
+
self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
|
|
206
|
+
self.btn_fit.clicked.connect(self._fit_to_preview)
|
|
207
|
+
|
|
208
|
+
tools.addStretch(1)
|
|
209
|
+
tools.addWidget(self.btn_zoom_out)
|
|
210
|
+
tools.addWidget(self.btn_zoom_in)
|
|
211
|
+
tools.addWidget(self.btn_fit)
|
|
212
|
+
tools.addStretch(1)
|
|
213
|
+
right.addLayout(tools)
|
|
214
|
+
|
|
215
|
+
self.scroll = QScrollArea(self)
|
|
216
|
+
self.scroll.setWidgetResizable(True)
|
|
217
|
+
self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
218
|
+
|
|
219
|
+
self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
220
|
+
self.preview.setMinimumSize(240, 240)
|
|
221
|
+
self.scroll.setWidget(self.preview)
|
|
222
|
+
|
|
223
|
+
self.preview.setMouseTracking(True)
|
|
224
|
+
self.preview.installEventFilter(self)
|
|
225
|
+
self.scroll.viewport().installEventFilter(self)
|
|
226
|
+
self.scroll.installEventFilter(self)
|
|
227
|
+
self.scroll.horizontalScrollBar().installEventFilter(self)
|
|
228
|
+
self.scroll.verticalScrollBar().installEventFilter(self)
|
|
229
|
+
|
|
230
|
+
right.addWidget(self.scroll, 1)
|
|
231
|
+
|
|
232
|
+
self.status = QLabel("", self)
|
|
233
|
+
self.status.setWordWrap(True)
|
|
234
|
+
self.status.setStyleSheet("color:#888;")
|
|
235
|
+
right.addWidget(self.status, 0)
|
|
236
|
+
|
|
237
|
+
right_host = QWidget(self)
|
|
238
|
+
right_host.setLayout(right)
|
|
239
|
+
root.addWidget(right_host, 1)
|
|
240
|
+
|
|
241
|
+
# ---------------- FOOTER ----------------
|
|
242
|
+
self.lbl_credits = QLabel(
|
|
243
|
+
"""
|
|
244
|
+
<div style="text-align:center;">
|
|
245
|
+
<span style="font-size:12px; color:#b8b8b8;">
|
|
246
|
+
PixelMath narrowband normalization concept & SHO/HOS/HSO/HOO formulas by
|
|
247
|
+
<b>Bill Blanshan</b> and <b>Mike Cranfield</b><br>
|
|
248
|
+
<a style="color:#9ecbff;" href="https://www.youtube.com/@anotherastrochannel2173">Bill Blanshan (YouTube)</a>
|
|
249
|
+
|
|
|
250
|
+
<a style="color:#9ecbff;" href="https://cosmicphotons.com/">Mike Cranfield (cosmicphotons.com)</a>
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
"""
|
|
254
|
+
)
|
|
255
|
+
self.lbl_credits.setTextFormat(Qt.TextFormat.RichText)
|
|
256
|
+
self.lbl_credits.setOpenExternalLinks(True)
|
|
257
|
+
self.lbl_credits.setWordWrap(True)
|
|
258
|
+
self.lbl_credits.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
259
|
+
self.lbl_credits.setStyleSheet("margin-top:6px; padding:6px 8px;")
|
|
260
|
+
|
|
261
|
+
# Key: don’t let it be clipped—allow it to take minimum height
|
|
262
|
+
self.lbl_credits.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
|
263
|
+
|
|
264
|
+
# Wrap footer so it can scroll if the window is too short
|
|
265
|
+
outer.addWidget(self.lbl_credits, 0)
|
|
266
|
+
|
|
267
|
+
self.setLayout(outer)
|
|
268
|
+
self.setMinimumSize(1080, 720)
|
|
269
|
+
|
|
270
|
+
# Initial state
|
|
271
|
+
self._refresh_visibility()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _init_widgets(self):
|
|
275
|
+
# -------- Import mapped RGB (Perfect Palette / existing composites) --------
|
|
276
|
+
self.grp_import = QGroupBox("Import mapped RGB view", self)
|
|
277
|
+
impv = QVBoxLayout(self.grp_import)
|
|
278
|
+
impv.setSpacing(6)
|
|
279
|
+
|
|
280
|
+
# Use themed buttons for consistency
|
|
281
|
+
self.btn_imp_sho = QPushButton("Load SHO View…", self)
|
|
282
|
+
self.btn_imp_hso = QPushButton("Load HSO View…", self)
|
|
283
|
+
self.btn_imp_hos = QPushButton("Load HOS View…", self)
|
|
284
|
+
self.btn_imp_hoo = QPushButton("Load HOO View…", self)
|
|
285
|
+
|
|
286
|
+
for b in (self.btn_imp_sho, self.btn_imp_hso, self.btn_imp_hos, self.btn_imp_hoo):
|
|
287
|
+
b.setMinimumHeight(28)
|
|
288
|
+
impv.addWidget(b)
|
|
289
|
+
|
|
290
|
+
# -------- Channel load buttons + labels --------
|
|
291
|
+
self.btn_ha = QPushButton("Load Ha…", self)
|
|
292
|
+
self.btn_oiii = QPushButton("Load OIII…", self)
|
|
293
|
+
self.btn_sii = QPushButton("Load SII…", self)
|
|
294
|
+
self.btn_osc1 = QPushButton("Load OSC1 (Ha/OIII)…", self)
|
|
295
|
+
self.btn_osc2 = QPushButton("Load OSC2 (SII/OIII)…", self)
|
|
296
|
+
|
|
297
|
+
self.lbl_ha = QLabel("No Ha loaded.", self)
|
|
298
|
+
self.lbl_oiii = QLabel("No OIII loaded.", self)
|
|
299
|
+
self.lbl_sii = QLabel("No SII loaded.", self)
|
|
300
|
+
self.lbl_osc1 = QLabel("No OSC1 loaded.", self)
|
|
301
|
+
self.lbl_osc2 = QLabel("No OSC2 loaded.", self)
|
|
302
|
+
|
|
303
|
+
# -------- Actions --------
|
|
304
|
+
self.btn_clear = QPushButton("Clear", self)
|
|
305
|
+
self.btn_preview = QPushButton("Preview", self)
|
|
306
|
+
self.btn_apply = QPushButton("Apply to Current View", self)
|
|
307
|
+
self.btn_push = QPushButton("Push as New View", self)
|
|
308
|
+
|
|
309
|
+
# -------- Preview options --------
|
|
310
|
+
self.chk_preview_autostretch = QCheckBox("Autostretch preview", self)
|
|
311
|
+
self.chk_preview_autostretch.setChecked(False)
|
|
312
|
+
|
|
313
|
+
# -------- Normalization controls (built in helper) --------
|
|
314
|
+
self.grp_norm, self._norm_form = self._build_norm_group()
|
|
315
|
+
|
|
316
|
+
# -------- Extras groups referenced by _refresh_visibility --------
|
|
317
|
+
self.grp_hoo_extras = QGroupBox("HOO Extras", self)
|
|
318
|
+
self.grp_hoo_extras.setLayout(QVBoxLayout())
|
|
319
|
+
self.grp_hoo_extras.layout().addWidget(QLabel("Reserved for future HOO-specific options.", self))
|
|
320
|
+
|
|
321
|
+
self.grp_sho_extras = QGroupBox("SHO / HSO / HOS Extras", self)
|
|
322
|
+
self.grp_sho_extras.setLayout(QVBoxLayout())
|
|
323
|
+
self.grp_sho_extras.layout().addWidget(QLabel("Reserved for future SHO-family options.", self))
|
|
324
|
+
|
|
325
|
+
# Add preview toggle into norm group area (nice UX)
|
|
326
|
+
# (We’ll place it in _build_norm_group as well, but safe to keep here if you prefer)
|
|
327
|
+
|
|
328
|
+
def _build_norm_group(self) -> tuple[QGroupBox, QFormLayout]:
|
|
329
|
+
grp = QGroupBox("Normalization", self)
|
|
330
|
+
form = QFormLayout()
|
|
331
|
+
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
332
|
+
form.setFormAlignment(Qt.AlignmentFlag.AlignTop)
|
|
333
|
+
|
|
334
|
+
# Scenario / mode / lightness
|
|
335
|
+
self.cmb_scenario = QComboBox(self)
|
|
336
|
+
self.cmb_scenario.addItems(["SHO", "HSO", "HOS", "HOO"])
|
|
337
|
+
|
|
338
|
+
self.cmb_mode = QComboBox(self)
|
|
339
|
+
self.cmb_mode.addItems(["Non-linear (Mode=1)", "Linear (Mode=0)"])
|
|
340
|
+
|
|
341
|
+
self.cmb_lightness = QComboBox(self)
|
|
342
|
+
self.cmb_lightness.addItems(["Off (0)", "Original (1)", "Ha (2)", "SII (3)", "OIII (4)"])
|
|
343
|
+
|
|
344
|
+
# --- Slider rows ---
|
|
345
|
+
# Blackpoint: [-1..1] step 0.005
|
|
346
|
+
self.row_blackpoint, self.spin_blackpoint, self.sld_blackpoint = self._slider_spin_row(
|
|
347
|
+
lo=0.0, hi=1.0, step=0.01, val=0.25, decimals=3 # or val=0.0 if you want “neutral”
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# HL Recover / Reduction: [0.5..2.0] default 1.0
|
|
351
|
+
self.row_hlrecover, self.spin_hlrecover, self.sld_hlrecover = self._slider_spin_row(
|
|
352
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
353
|
+
)
|
|
354
|
+
self.row_hlreduct, self.spin_hlreduct, self.sld_hlreduct = self._slider_spin_row(
|
|
355
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Brightness: [0.5..2.0] default 1.0
|
|
359
|
+
self.row_brightness, self.spin_brightness, self.sld_brightness = self._slider_spin_row(
|
|
360
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Ha Blend (HOO only)
|
|
364
|
+
self.row_hablend, self.spin_hablend, self.sld_hablend = self._slider_spin_row(
|
|
365
|
+
lo=0.0, hi=1.0, step=0.01, val=0.6, decimals=3
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Boosts: [0.5..2.0] default 1.0
|
|
369
|
+
self.row_oiiiboost, self.spin_oiiiboost, self.sld_oiiiboost = self._slider_spin_row(
|
|
370
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
371
|
+
)
|
|
372
|
+
self.row_siiboost, self.spin_siiboost, self.sld_siiboost = self._slider_spin_row(
|
|
373
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
374
|
+
)
|
|
375
|
+
self.row_oiii_sho, self.spin_oiiiboost2, self.sld_oiiiboost2 = self._slider_spin_row(
|
|
376
|
+
lo=0.5, hi=2.0, step=0.01, val=1.0, decimals=3
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Blend Mode (HOO only)
|
|
380
|
+
self.cmb_blendmode = QComboBox(self)
|
|
381
|
+
self.cmb_blendmode.addItems(["Screen", "Add", "Linear Dodge", "Normal"])
|
|
382
|
+
|
|
383
|
+
self.chk_scnr = QCheckBox("SCNR (reduce green cast)", self)
|
|
384
|
+
self.chk_scnr.setChecked(True)
|
|
385
|
+
|
|
386
|
+
self.chk_linear_fit = QCheckBox("Linear Fit (highest signal)", self)
|
|
387
|
+
self.chk_linear_fit.setChecked(False)
|
|
388
|
+
|
|
389
|
+
self.chk_bg_neutral = QCheckBox("Background Neutralization (∇-descent)", self)
|
|
390
|
+
self.chk_bg_neutral.setChecked(True) # your call on default
|
|
391
|
+
|
|
392
|
+
# Layout (use QLabel so we can rename rows dynamically)
|
|
393
|
+
form.addRow("Scenario:", self.cmb_scenario)
|
|
394
|
+
form.addRow("Mode:", self.cmb_mode)
|
|
395
|
+
form.addRow("Lightness:", self.cmb_lightness)
|
|
396
|
+
|
|
397
|
+
self._lbl_blackpoint = QLabel("Blackpoint\n(Min → Med):", self)
|
|
398
|
+
self._lbl_blackpoint.setWordWrap(True)
|
|
399
|
+
self._lbl_blackpoint.setToolTip(
|
|
400
|
+
"Controls the blackpoint reference M used by the normalization.\n\n"
|
|
401
|
+
"M = min + Blackpoint × (median − min)\n"
|
|
402
|
+
"• 0.0 = use Min\n"
|
|
403
|
+
"• 1.0 = use Median\n\n"
|
|
404
|
+
"Higher values lift the baseline (brighter background); lower values preserve darker blacks."
|
|
405
|
+
)
|
|
406
|
+
self._lbl_hlrecover = QLabel("HL Recover:", self)
|
|
407
|
+
self._lbl_hlreduct = QLabel("HL Reduction:", self)
|
|
408
|
+
self._lbl_brightness = QLabel("Brightness:", self)
|
|
409
|
+
self._lbl_blendmode = QLabel("Blend Mode:", self)
|
|
410
|
+
self._lbl_hablend = QLabel("Ha Blend:", self)
|
|
411
|
+
self._lbl_oiiiboost = QLabel("OIII Boost:", self)
|
|
412
|
+
self._lbl_siiboost = QLabel("SII Boost:", self)
|
|
413
|
+
self._lbl_oiiiboost2 = QLabel("OIII Boost:", self) # SHO-family name (not “Boost 2”)
|
|
414
|
+
|
|
415
|
+
form.addRow(self._lbl_blackpoint, self.row_blackpoint)
|
|
416
|
+
form.addRow(self._lbl_hlrecover, self.row_hlrecover)
|
|
417
|
+
form.addRow(self._lbl_hlreduct, self.row_hlreduct)
|
|
418
|
+
form.addRow(self._lbl_brightness, self.row_brightness)
|
|
419
|
+
|
|
420
|
+
form.addRow(self._lbl_blendmode, self.cmb_blendmode)
|
|
421
|
+
form.addRow(self._lbl_hablend, self.row_hablend)
|
|
422
|
+
form.addRow(self._lbl_oiiiboost, self.row_oiiiboost)
|
|
423
|
+
form.addRow(self._lbl_siiboost, self.row_siiboost)
|
|
424
|
+
form.addRow(self._lbl_oiiiboost2, self.row_oiii_sho)
|
|
425
|
+
|
|
426
|
+
form.addRow("", self.chk_scnr)
|
|
427
|
+
form.addRow("", self.chk_linear_fit)
|
|
428
|
+
form.addRow("", self.chk_bg_neutral)
|
|
429
|
+
form.addRow("", self.chk_preview_autostretch)
|
|
430
|
+
|
|
431
|
+
grp.setLayout(form)
|
|
432
|
+
return grp, form
|
|
433
|
+
|
|
434
|
+
def _connect_signals(self):
|
|
435
|
+
# Loaders
|
|
436
|
+
self.btn_imp_sho.clicked.connect(lambda: self._import_mapped_view("SHO"))
|
|
437
|
+
self.btn_imp_hso.clicked.connect(lambda: self._import_mapped_view("HSO"))
|
|
438
|
+
self.btn_imp_hos.clicked.connect(lambda: self._import_mapped_view("HOS"))
|
|
439
|
+
self.btn_imp_hoo.clicked.connect(lambda: self._import_mapped_view("HOO"))
|
|
440
|
+
self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
|
|
441
|
+
self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
|
|
442
|
+
self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
|
|
443
|
+
self.btn_osc1.clicked.connect(lambda: self._load_channel("OSC1"))
|
|
444
|
+
self.btn_osc2.clicked.connect(lambda: self._load_channel("OSC2"))
|
|
445
|
+
|
|
446
|
+
# Actions
|
|
447
|
+
self.btn_clear.clicked.connect(self._clear_channels)
|
|
448
|
+
self.btn_preview.clicked.connect(self._schedule_preview) # debounced compute
|
|
449
|
+
self.btn_apply.clicked.connect(self._apply_to_current_view)
|
|
450
|
+
self.btn_push.clicked.connect(self._push_result)
|
|
451
|
+
|
|
452
|
+
# Any control change should schedule preview
|
|
453
|
+
self.cmb_scenario.currentIndexChanged.connect(self._refresh_visibility)
|
|
454
|
+
self.cmb_mode.currentIndexChanged.connect(self._refresh_visibility)
|
|
455
|
+
self.cmb_lightness.currentIndexChanged.connect(self._schedule_preview)
|
|
456
|
+
self.chk_bg_neutral.toggled.connect(self._schedule_preview)
|
|
457
|
+
|
|
458
|
+
for w in (
|
|
459
|
+
self.spin_blackpoint, self.spin_hlrecover, self.spin_hlreduct, self.spin_brightness,
|
|
460
|
+
self.cmb_blendmode, self.spin_hablend, self.spin_oiiiboost, self.spin_siiboost,
|
|
461
|
+
self.spin_oiiiboost2
|
|
462
|
+
):
|
|
463
|
+
if hasattr(w, "valueChanged"):
|
|
464
|
+
w.valueChanged.connect(self._schedule_preview)
|
|
465
|
+
if hasattr(w, "currentIndexChanged"):
|
|
466
|
+
w.currentIndexChanged.connect(self._schedule_preview)
|
|
467
|
+
|
|
468
|
+
# Slider releases should also schedule preview (tracking is already False)
|
|
469
|
+
for s in (
|
|
470
|
+
self.sld_blackpoint, self.sld_hlrecover, self.sld_hlreduct, self.sld_brightness,
|
|
471
|
+
self.sld_hablend, self.sld_oiiiboost, self.sld_siiboost, self.sld_oiiiboost2
|
|
472
|
+
):
|
|
473
|
+
s.valueChanged.connect(self._schedule_preview)
|
|
474
|
+
|
|
475
|
+
self.chk_scnr.toggled.connect(self._schedule_preview)
|
|
476
|
+
self.chk_linear_fit.toggled.connect(self._schedule_preview)
|
|
477
|
+
self.chk_preview_autostretch.toggled.connect(self._schedule_preview)
|
|
478
|
+
|
|
479
|
+
def _slider_spin_row(self, lo: float, hi: float, step: float, val: float, decimals: int):
|
|
480
|
+
"""
|
|
481
|
+
Returns (row_widget, spinbox, slider).
|
|
482
|
+
Slider is int-mapped: int_value = round(x / step)
|
|
483
|
+
"""
|
|
484
|
+
w = QWidget(self)
|
|
485
|
+
lay = QHBoxLayout(w)
|
|
486
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
487
|
+
lay.setSpacing(8)
|
|
488
|
+
|
|
489
|
+
sp = QDoubleSpinBox(self)
|
|
490
|
+
sp.setRange(lo, hi)
|
|
491
|
+
sp.setDecimals(decimals)
|
|
492
|
+
sp.setSingleStep(step)
|
|
493
|
+
sp.setValue(val)
|
|
494
|
+
|
|
495
|
+
s = QSlider(Qt.Orientation.Horizontal, self)
|
|
496
|
+
s.setTracking(False) # don’t spam recompute while dragging; fires on release
|
|
497
|
+
imin = int(round(lo / step))
|
|
498
|
+
imax = int(round(hi / step))
|
|
499
|
+
s.setRange(imin, imax)
|
|
500
|
+
s.setValue(int(round(val / step)))
|
|
501
|
+
|
|
502
|
+
# sync both ways (block signals to avoid loops)
|
|
503
|
+
def slider_to_spin(iv: int):
|
|
504
|
+
sp.blockSignals(True)
|
|
505
|
+
sp.setValue(iv * step)
|
|
506
|
+
sp.blockSignals(False)
|
|
507
|
+
|
|
508
|
+
def spin_to_slider(v: float):
|
|
509
|
+
s.blockSignals(True)
|
|
510
|
+
s.setValue(int(round(v / step)))
|
|
511
|
+
s.blockSignals(False)
|
|
512
|
+
|
|
513
|
+
s.valueChanged.connect(slider_to_spin)
|
|
514
|
+
sp.valueChanged.connect(spin_to_slider)
|
|
515
|
+
|
|
516
|
+
lay.addWidget(s, 1)
|
|
517
|
+
lay.addWidget(sp, 0)
|
|
518
|
+
|
|
519
|
+
return w, sp, s
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _schedule_preview(self):
|
|
523
|
+
"""Call this on ANY UI change that should recompute preview."""
|
|
524
|
+
self._calc_seq += 1
|
|
525
|
+
self.status.setText("Updating preview...")
|
|
526
|
+
self._debounce.start()
|
|
527
|
+
|
|
528
|
+
def _dbg(self, msg: str):
|
|
529
|
+
# Change this to logging if you prefer
|
|
530
|
+
print(f"[NBN] {msg}")
|
|
531
|
+
self.status.setText(msg)
|
|
532
|
+
|
|
533
|
+
def _preview_scale(self) -> float:
|
|
534
|
+
"""
|
|
535
|
+
Choose a downsample factor so preview compute stays fast.
|
|
536
|
+
Target: keep preview processing under ~2 MP and cap max dimension.
|
|
537
|
+
"""
|
|
538
|
+
# pick a reference shape from whatever is loaded
|
|
539
|
+
ha, oo, si = self._prepared_channels()
|
|
540
|
+
ref = ha if ha is not None else (oo if oo is not None else si)
|
|
541
|
+
if ref is None:
|
|
542
|
+
return 1.0
|
|
543
|
+
|
|
544
|
+
h, w = ref.shape[:2]
|
|
545
|
+
mp = (w * h) / 1e6
|
|
546
|
+
|
|
547
|
+
# Hard caps (tweak to taste)
|
|
548
|
+
max_dim = 1800 # keep longest side ~<= 1800px
|
|
549
|
+
target_mp = 2.0 # keep total pixels ~<= 2MP
|
|
550
|
+
|
|
551
|
+
s_dim = min(1.0, max_dim / float(max(h, w)))
|
|
552
|
+
s_mp = min(1.0, (target_mp / max(mp, 1e-6)) ** 0.5)
|
|
553
|
+
|
|
554
|
+
s = min(s_dim, s_mp)
|
|
555
|
+
|
|
556
|
+
# Don’t micro-scale; prefer a few stable buckets
|
|
557
|
+
if s >= 0.90: return 1.0
|
|
558
|
+
if s >= 0.65: return 0.75
|
|
559
|
+
if s >= 0.45: return 0.50
|
|
560
|
+
if s >= 0.30: return 0.33
|
|
561
|
+
return 0.25
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _downsample_mono(self, ch: np.ndarray | None, s: float) -> np.ndarray | None:
|
|
565
|
+
if ch is None or s >= 0.999:
|
|
566
|
+
return ch
|
|
567
|
+
h, w = ch.shape[:2]
|
|
568
|
+
nw = max(1, int(round(w * s)))
|
|
569
|
+
nh = max(1, int(round(h * s)))
|
|
570
|
+
return cv2.resize(ch, (nw, nh), interpolation=cv2.INTER_AREA)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _downsample_rgb(self, img: np.ndarray | None, s: float) -> np.ndarray | None:
|
|
574
|
+
if img is None or s >= 0.999:
|
|
575
|
+
return img
|
|
576
|
+
h, w = img.shape[:2]
|
|
577
|
+
nw = max(1, int(round(w * s)))
|
|
578
|
+
nh = max(1, int(round(h * s)))
|
|
579
|
+
return cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
|
|
580
|
+
|
|
581
|
+
def _kick_preview_compute(self):
|
|
582
|
+
# If nothing loaded, don't compute
|
|
583
|
+
try:
|
|
584
|
+
ha, oo, si = self._prepared_channels()
|
|
585
|
+
if ha is None and oo is None and si is None:
|
|
586
|
+
self.status.setText("Load channels to preview.")
|
|
587
|
+
return
|
|
588
|
+
except Exception as e:
|
|
589
|
+
self.status.setText(f"Preview error: {e}")
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
def on_done(out: np.ndarray, step_name: str):
|
|
593
|
+
out2 = self._maybe_background_neutralize_rgb(out, doc_for_mask=None)
|
|
594
|
+
self.final = out2
|
|
595
|
+
|
|
596
|
+
disp = out2
|
|
597
|
+
if self.chk_preview_autostretch.isChecked():
|
|
598
|
+
disp = np.clip(stretch_color_image(disp, target_median=0.25, linked=True), 0.0, 1.0)
|
|
599
|
+
|
|
600
|
+
qimg = self._to_qimage(disp)
|
|
601
|
+
first = (self._base_pm is None)
|
|
602
|
+
self._set_preview_image(qimg, fit=first, preserve_view=True)
|
|
603
|
+
self.status.setText("Done (100%)")
|
|
604
|
+
|
|
605
|
+
def on_fail(err: str):
|
|
606
|
+
# Don’t spam modal dialogs for “missing channels” type errors
|
|
607
|
+
if "requires" in err.lower() or "load" in err.lower():
|
|
608
|
+
self.status.setText(err)
|
|
609
|
+
return
|
|
610
|
+
QMessageBox.critical(self, "Narrowband Normalization", err)
|
|
611
|
+
self.status.setText("Preview failed.")
|
|
612
|
+
|
|
613
|
+
self._start_job(
|
|
614
|
+
downsample=True,
|
|
615
|
+
step_name="NBN Preview",
|
|
616
|
+
on_done=on_done,
|
|
617
|
+
on_fail=on_fail,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _maybe_background_neutralize_rgb(self, rgb: np.ndarray, *, doc_for_mask=None) -> np.ndarray:
|
|
621
|
+
"""
|
|
622
|
+
Apply BN to an RGB float image in [0,1] if the checkbox is enabled.
|
|
623
|
+
If doc_for_mask is provided, blend result using destination active mask (headless behavior).
|
|
624
|
+
"""
|
|
625
|
+
if not getattr(self, "chk_bg_neutral", None) or not self.chk_bg_neutral.isChecked():
|
|
626
|
+
return rgb
|
|
627
|
+
|
|
628
|
+
if rgb is None or rgb.ndim != 3 or rgb.shape[2] != 3:
|
|
629
|
+
return rgb
|
|
630
|
+
|
|
631
|
+
# auto rect + neutralize (same logic as headless BN default)
|
|
632
|
+
rect = auto_rect_50x50(rgb)
|
|
633
|
+
out = background_neutralize_rgb(rgb.astype(np.float32, copy=False), rect)
|
|
634
|
+
|
|
635
|
+
# destination active-mask blend (same as apply_background_neutral_to_doc)
|
|
636
|
+
if doc_for_mask is not None:
|
|
637
|
+
m = _active_mask_array_from_doc(doc_for_mask)
|
|
638
|
+
if m is not None:
|
|
639
|
+
m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32, copy=False)
|
|
640
|
+
base_for_blend = rgb.astype(np.float32, copy=False)
|
|
641
|
+
out = base_for_blend * (1.0 - m3) + out * m3
|
|
642
|
+
|
|
643
|
+
return out.astype(np.float32, copy=False)
|
|
644
|
+
|
|
645
|
+
def _requirements_met(self, ha, oo, si) -> tuple[bool, str]:
|
|
646
|
+
scen = self._scenario()
|
|
647
|
+
if scen == "HOO":
|
|
648
|
+
if ha is None or oo is None:
|
|
649
|
+
return False, "Load Ha + OIII to preview HOO."
|
|
650
|
+
return True, ""
|
|
651
|
+
else:
|
|
652
|
+
missing = []
|
|
653
|
+
if ha is None: missing.append("Ha")
|
|
654
|
+
if oo is None: missing.append("OIII")
|
|
655
|
+
if si is None: missing.append("SII")
|
|
656
|
+
if missing:
|
|
657
|
+
return False, f"Load {', '.join(missing)} to preview {scen}."
|
|
658
|
+
return True, ""
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _form_set_row_visible(self, form: QFormLayout, row: int, visible: bool):
|
|
662
|
+
"""Hide/show both label and field for a QFormLayout row."""
|
|
663
|
+
label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
|
|
664
|
+
field_item = form.itemAt(row, QFormLayout.ItemRole.FieldRole)
|
|
665
|
+
|
|
666
|
+
for it in (label_item, field_item):
|
|
667
|
+
if it is None:
|
|
668
|
+
continue
|
|
669
|
+
w = it.widget()
|
|
670
|
+
if w is not None:
|
|
671
|
+
w.setVisible(visible)
|
|
672
|
+
else:
|
|
673
|
+
# sometimes the field is a layout
|
|
674
|
+
lay = it.layout()
|
|
675
|
+
if lay is not None:
|
|
676
|
+
for i in range(lay.count()):
|
|
677
|
+
ww = lay.itemAt(i).widget()
|
|
678
|
+
if ww is not None:
|
|
679
|
+
ww.setVisible(visible)
|
|
680
|
+
|
|
681
|
+
def _form_find_row(self, form: QFormLayout, field_widget: QWidget) -> int:
|
|
682
|
+
"""Return row index where field_widget is the FieldRole."""
|
|
683
|
+
for r in range(form.rowCount()):
|
|
684
|
+
it = form.itemAt(r, QFormLayout.ItemRole.FieldRole)
|
|
685
|
+
if it and it.widget() is field_widget:
|
|
686
|
+
return r
|
|
687
|
+
return -1
|
|
688
|
+
|
|
689
|
+
def _scenario(self) -> str:
|
|
690
|
+
return self.cmb_scenario.currentText().split()[0].upper()
|
|
691
|
+
|
|
692
|
+
def _mode_value(self) -> int:
|
|
693
|
+
# your combo is ["Non-linear (Mode=1)", "Linear (Mode=0)"]
|
|
694
|
+
return 1 if self.cmb_mode.currentIndex() == 0 else 0
|
|
695
|
+
|
|
696
|
+
def _set_lightness_items(self, items: list[str]):
|
|
697
|
+
self.cmb_lightness.blockSignals(True)
|
|
698
|
+
cur = self.cmb_lightness.currentText()
|
|
699
|
+
self.cmb_lightness.clear()
|
|
700
|
+
self.cmb_lightness.addItems(items)
|
|
701
|
+
# try to preserve selection if possible
|
|
702
|
+
idx = self.cmb_lightness.findText(cur)
|
|
703
|
+
if idx >= 0:
|
|
704
|
+
self.cmb_lightness.setCurrentIndex(idx)
|
|
705
|
+
self.cmb_lightness.blockSignals(False)
|
|
706
|
+
|
|
707
|
+
def _refresh_visibility(self, *_):
|
|
708
|
+
scen = self._scenario()
|
|
709
|
+
mode = self._mode_value()
|
|
710
|
+
|
|
711
|
+
is_hoo = (scen == "HOO")
|
|
712
|
+
self.btn_sii.setVisible(not is_hoo)
|
|
713
|
+
self.lbl_sii.setVisible(not is_hoo)
|
|
714
|
+
|
|
715
|
+
def show_row(field_widget, visible: bool):
|
|
716
|
+
r = self._form_find_row(self._norm_form, field_widget)
|
|
717
|
+
if r >= 0:
|
|
718
|
+
self._form_set_row_visible(self._norm_form, r, visible)
|
|
719
|
+
|
|
720
|
+
# HOO-only rows
|
|
721
|
+
show_row(self.cmb_blendmode, is_hoo)
|
|
722
|
+
show_row(self.row_hablend, is_hoo)
|
|
723
|
+
show_row(self.row_oiiiboost, is_hoo)
|
|
724
|
+
|
|
725
|
+
# SHO-family rows
|
|
726
|
+
show_row(self.row_siiboost, not is_hoo)
|
|
727
|
+
show_row(self.row_oiii_sho, not is_hoo)
|
|
728
|
+
|
|
729
|
+
# Optional: hide the SCNR row cleanly (instead of just the checkbox)
|
|
730
|
+
show_row(self.chk_scnr, not is_hoo)
|
|
731
|
+
|
|
732
|
+
# Lightness row visibility (unchanged)
|
|
733
|
+
lightness_allowed = (mode == 1)
|
|
734
|
+
row = self._form_find_row(self._norm_form, self.cmb_lightness)
|
|
735
|
+
if row >= 0:
|
|
736
|
+
self._form_set_row_visible(self._norm_form, row, lightness_allowed)
|
|
737
|
+
|
|
738
|
+
if lightness_allowed:
|
|
739
|
+
if is_hoo:
|
|
740
|
+
self._set_lightness_items(["Off (0)", "Original (1)", "Ha (2)", "OIII (3)"])
|
|
741
|
+
else:
|
|
742
|
+
self._set_lightness_items(["Off (0)", "Original (1)", "Ha (2)", "SII (3)", "OIII (4)"])
|
|
743
|
+
|
|
744
|
+
self.grp_hoo_extras.setVisible(is_hoo)
|
|
745
|
+
self.grp_sho_extras.setVisible(not is_hoo)
|
|
746
|
+
|
|
747
|
+
self.chk_linear_fit.setEnabled(True)
|
|
748
|
+
|
|
749
|
+
self._schedule_preview()
|
|
750
|
+
|
|
751
|
+
def _make_dspin(self, lo, hi, step, val, _debounce_timer_unused) -> QDoubleSpinBox:
|
|
752
|
+
sp = QDoubleSpinBox(self)
|
|
753
|
+
sp.setRange(lo, hi)
|
|
754
|
+
sp.setSingleStep(step)
|
|
755
|
+
sp.setDecimals(3)
|
|
756
|
+
sp.setValue(val)
|
|
757
|
+
sp.valueChanged.connect(lambda *_: self._schedule_preview())
|
|
758
|
+
return sp
|
|
759
|
+
|
|
760
|
+
def _on_mode_changed(self):
|
|
761
|
+
# Lightness only meaningful for non-linear (Mode=1) per Bill notes.
|
|
762
|
+
non_linear = (self.cmb_mode.currentIndex() == 0)
|
|
763
|
+
self.cmb_lightness.setEnabled(non_linear)
|
|
764
|
+
self._schedule_preview()
|
|
765
|
+
|
|
766
|
+
# ---------------- loaders ----------------
|
|
767
|
+
def _gather_params(self) -> NBNParams:
|
|
768
|
+
scenario = self.cmb_scenario.currentText()
|
|
769
|
+
mode = 1 if self.cmb_mode.currentIndex() == 0 else 0
|
|
770
|
+
lightness = self.cmb_lightness.currentIndex()
|
|
771
|
+
|
|
772
|
+
hlrecover = max(float(self.spin_hlrecover.value()), 0.25)
|
|
773
|
+
hlreduct = max(float(self.spin_hlreduct.value()), 0.25)
|
|
774
|
+
brightness = max(float(self.spin_brightness.value()), 0.25)
|
|
775
|
+
|
|
776
|
+
return NBNParams(
|
|
777
|
+
scenario=scenario,
|
|
778
|
+
mode=mode,
|
|
779
|
+
lightness=lightness,
|
|
780
|
+
blackpoint=float(self.spin_blackpoint.value()),
|
|
781
|
+
hlrecover=hlrecover,
|
|
782
|
+
hlreduct=hlreduct,
|
|
783
|
+
brightness=brightness,
|
|
784
|
+
blendmode=self.cmb_blendmode.currentIndex(),
|
|
785
|
+
hablend=float(self.spin_hablend.value()),
|
|
786
|
+
oiiiboost=float(self.spin_oiiiboost.value()),
|
|
787
|
+
siiboost=float(self.spin_siiboost.value()),
|
|
788
|
+
oiiiboost2=float(self.spin_oiiiboost2.value()),
|
|
789
|
+
scnr=bool(self.chk_scnr.isChecked()),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _set_status_label(self, which: str, text: str | None):
|
|
794
|
+
lab = getattr(self, f"lbl_{which.lower()}")
|
|
795
|
+
if text:
|
|
796
|
+
lab.setText(text)
|
|
797
|
+
lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
|
|
798
|
+
else:
|
|
799
|
+
lab.setText(f"No {which} loaded.")
|
|
800
|
+
lab.setStyleSheet("color:#888; margin-left:8px;")
|
|
801
|
+
|
|
802
|
+
def _load_channel(self, which: str):
|
|
803
|
+
src, ok = QInputDialog.getItem(
|
|
804
|
+
self, f"Load {which}", "Source:", ["From View", "From File"], 0, False
|
|
805
|
+
)
|
|
806
|
+
if not ok:
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
out = self._load_from_view(which) if src == "From View" else self._load_from_file(which)
|
|
810
|
+
if out is None:
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
img, header, bit_depth, is_mono, path, label = out
|
|
814
|
+
|
|
815
|
+
# NB channels → mono; OSC → RGB
|
|
816
|
+
if which in ("Ha", "OIII", "SII"):
|
|
817
|
+
if img.ndim == 3:
|
|
818
|
+
img = img[:, :, 0]
|
|
819
|
+
else:
|
|
820
|
+
if img.ndim == 2:
|
|
821
|
+
img = np.stack([img] * 3, axis=-1)
|
|
822
|
+
|
|
823
|
+
setattr(self, which.lower(), self._as_float01(img))
|
|
824
|
+
self._set_status_label(which, label)
|
|
825
|
+
self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
|
|
826
|
+
|
|
827
|
+
self._schedule_preview()
|
|
828
|
+
|
|
829
|
+
def _import_mapped_view(self, scenario: str):
|
|
830
|
+
"""
|
|
831
|
+
Import an already-mapped RGB composite (e.g. from Perfect Palette Picker)
|
|
832
|
+
and split it into Ha/OIII/SII channels according to scenario mapping.
|
|
833
|
+
"""
|
|
834
|
+
# Force scenario selection to match the mapping the user chose
|
|
835
|
+
idx = self.cmb_scenario.findText(scenario)
|
|
836
|
+
if idx >= 0:
|
|
837
|
+
self.cmb_scenario.setCurrentIndex(idx)
|
|
838
|
+
|
|
839
|
+
views = self._list_open_views()
|
|
840
|
+
if not views:
|
|
841
|
+
QMessageBox.warning(self, "No Views", "No open image views were found.")
|
|
842
|
+
return
|
|
843
|
+
|
|
844
|
+
labels = [lab for lab, _ in views]
|
|
845
|
+
choice, ok = QInputDialog.getItem(
|
|
846
|
+
self, f"Select {scenario} View", "Choose a mapped RGB view:", labels, 0, False
|
|
847
|
+
)
|
|
848
|
+
if not ok or not choice:
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
sw = dict(views)[choice]
|
|
852
|
+
doc = getattr(sw, "document", None)
|
|
853
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
854
|
+
QMessageBox.warning(self, "Empty View", "Selected view has no image.")
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
img = doc.image
|
|
858
|
+
if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
|
|
859
|
+
QMessageBox.warning(
|
|
860
|
+
self, "Not RGB",
|
|
861
|
+
"That view is mono. Import requires an RGB mapped composite (3-channel)."
|
|
862
|
+
)
|
|
863
|
+
return
|
|
864
|
+
|
|
865
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
866
|
+
QMessageBox.warning(
|
|
867
|
+
self, "Unsupported Shape",
|
|
868
|
+
f"Expected RGB (H,W,3). Got {img.shape}."
|
|
869
|
+
)
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
rgb = self._as_float01(img)
|
|
873
|
+
|
|
874
|
+
ha, oiii, sii = self._split_mapped_rgb(rgb, scenario)
|
|
875
|
+
|
|
876
|
+
# Store as mono float [0..1]
|
|
877
|
+
self.ha = ha
|
|
878
|
+
self.oiii = oiii
|
|
879
|
+
self.sii = sii
|
|
880
|
+
|
|
881
|
+
# Clear OSC helpers (we’re now using direct NB channels)
|
|
882
|
+
self.osc1 = None
|
|
883
|
+
self.osc2 = None
|
|
884
|
+
|
|
885
|
+
# Labels
|
|
886
|
+
src = f"From View: {choice}"
|
|
887
|
+
if scenario == "HOO":
|
|
888
|
+
self._set_status_label("Ha", f"(Ha←R)")
|
|
889
|
+
self._set_status_label("OIII", f"(OIII←G/B)")
|
|
890
|
+
self._set_status_label("SII", None)
|
|
891
|
+
else:
|
|
892
|
+
# indicate mapping
|
|
893
|
+
map_txt = {
|
|
894
|
+
"SHO": "(SII←R, Ha←G, OIII←B)",
|
|
895
|
+
"HSO": "(Ha←R, SII←G, OIII←B)",
|
|
896
|
+
"HOS": "(Ha←R, OIII←G, SII←B)",
|
|
897
|
+
}.get(scenario, "")
|
|
898
|
+
self._set_status_label("Ha", f"{map_txt}")
|
|
899
|
+
self._set_status_label("OIII", f"{map_txt}")
|
|
900
|
+
self._set_status_label("SII", f"{map_txt}")
|
|
901
|
+
|
|
902
|
+
self.status.setText(f"Imported mapped {scenario} view → channels loaded.")
|
|
903
|
+
self._schedule_preview()
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _split_mapped_rgb(self, rgb: np.ndarray, scenario: str) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
|
907
|
+
"""
|
|
908
|
+
Given an RGB mapped composite in [0..1], return (Ha, OIII, SII) mono channels.
|
|
909
|
+
For HOO, returns (Ha, OIII, None).
|
|
910
|
+
"""
|
|
911
|
+
r = rgb[..., 0].astype(np.float32, copy=False)
|
|
912
|
+
g = rgb[..., 1].astype(np.float32, copy=False)
|
|
913
|
+
b = rgb[..., 2].astype(np.float32, copy=False)
|
|
914
|
+
|
|
915
|
+
scen = scenario.upper().strip()
|
|
916
|
+
|
|
917
|
+
if scen == "SHO":
|
|
918
|
+
# R=SII, G=Ha, B=OIII
|
|
919
|
+
sii = r
|
|
920
|
+
ha = g
|
|
921
|
+
oiii = b
|
|
922
|
+
return ha, oiii, sii
|
|
923
|
+
|
|
924
|
+
if scen == "HSO":
|
|
925
|
+
# R=Ha, G=SII, B=OIII
|
|
926
|
+
ha = r
|
|
927
|
+
sii = g
|
|
928
|
+
oiii = b
|
|
929
|
+
return ha, oiii, sii
|
|
930
|
+
|
|
931
|
+
if scen == "HOS":
|
|
932
|
+
# R=Ha, G=OIII, B=SII
|
|
933
|
+
ha = r
|
|
934
|
+
oiii = g
|
|
935
|
+
sii = b
|
|
936
|
+
return ha, oiii, sii
|
|
937
|
+
|
|
938
|
+
if scen == "HOO":
|
|
939
|
+
# Common mapping: R=Ha, G/B = OIII-ish
|
|
940
|
+
ha = r
|
|
941
|
+
oiii = 0.5 * (g + b)
|
|
942
|
+
return ha, oiii.astype(np.float32, copy=False), None
|
|
943
|
+
|
|
944
|
+
# Fallback: treat as HOS-ish
|
|
945
|
+
ha = r
|
|
946
|
+
oiii = g
|
|
947
|
+
sii = b
|
|
948
|
+
return ha, oiii, sii
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _load_from_view(self, which):
|
|
952
|
+
views = self._list_open_views()
|
|
953
|
+
if not views:
|
|
954
|
+
QMessageBox.warning(self, "No Views", "No open image views were found.")
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
labels = [lab for lab, _ in views]
|
|
958
|
+
choice, ok = QInputDialog.getItem(
|
|
959
|
+
self, f"Select View for {which}", "Choose a view (by name):", labels, 0, False
|
|
960
|
+
)
|
|
961
|
+
if not ok or not choice:
|
|
962
|
+
return None
|
|
963
|
+
|
|
964
|
+
sw = dict(views)[choice]
|
|
965
|
+
doc = getattr(sw, "document", None)
|
|
966
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
967
|
+
QMessageBox.warning(self, "Empty View", "Selected view has no image.")
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
img = doc.image
|
|
971
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
972
|
+
header = meta.get("original_header", None)
|
|
973
|
+
bit_depth = meta.get("bit_depth", "Unknown")
|
|
974
|
+
is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
975
|
+
path = meta.get("file_path", None)
|
|
976
|
+
return img, header, bit_depth, is_mono, path, f"From View: {choice}"
|
|
977
|
+
|
|
978
|
+
def _load_from_file(self, which):
|
|
979
|
+
filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
|
|
980
|
+
path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
|
|
981
|
+
if not path:
|
|
982
|
+
return None
|
|
983
|
+
img, header, bit_depth, is_mono = legacy_load_image(path)
|
|
984
|
+
if img is None:
|
|
985
|
+
QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}")
|
|
986
|
+
return None
|
|
987
|
+
label = f"From File: {os.path.basename(path)}"
|
|
988
|
+
return img, header, bit_depth, is_mono, path, label
|
|
989
|
+
|
|
990
|
+
# ---------------- channel prep ----------------
|
|
991
|
+
def _as_float01(self, arr):
|
|
992
|
+
a = np.asarray(arr)
|
|
993
|
+
if a.dtype == np.uint8:
|
|
994
|
+
return a.astype(np.float32) / 255.0
|
|
995
|
+
if a.dtype == np.uint16:
|
|
996
|
+
return a.astype(np.float32) / 65535.0
|
|
997
|
+
return np.clip(a.astype(np.float32), 0.0, 1.0)
|
|
998
|
+
|
|
999
|
+
def _resize_to(self, arr: np.ndarray | None, size: tuple[int, int]) -> np.ndarray | None:
|
|
1000
|
+
"""Resize np array to (w,h). Keeps dtype/scale. Uses INTER_AREA for downsizing."""
|
|
1001
|
+
if arr is None:
|
|
1002
|
+
return None
|
|
1003
|
+
w, h = size
|
|
1004
|
+
if arr.ndim == 2:
|
|
1005
|
+
src_h, src_w = arr.shape
|
|
1006
|
+
else:
|
|
1007
|
+
src_h, src_w = arr.shape[:2]
|
|
1008
|
+
if (src_w, src_h) == (w, h):
|
|
1009
|
+
return arr
|
|
1010
|
+
interp = cv2.INTER_AREA if (w < src_w or h < src_h) else cv2.INTER_LINEAR
|
|
1011
|
+
return cv2.resize(arr, (w, h), interpolation=interp)
|
|
1012
|
+
|
|
1013
|
+
def _prepared_channels(self):
|
|
1014
|
+
"""
|
|
1015
|
+
Build Ha/OIII/SII bases from inputs.
|
|
1016
|
+
Strategy (strict, safer for normalization):
|
|
1017
|
+
- If NB channels are present, prefer them.
|
|
1018
|
+
- Else synthesize from OSC inputs:
|
|
1019
|
+
OSC1: R≈Ha, mean(G,B)≈OIII
|
|
1020
|
+
OSC2: R≈SII, mean(G,B)≈OIII
|
|
1021
|
+
- If dimensions differ, prompt once and resize to reference.
|
|
1022
|
+
"""
|
|
1023
|
+
ha = self.ha
|
|
1024
|
+
oo = self.oiii
|
|
1025
|
+
si = self.sii
|
|
1026
|
+
o1 = self.osc1
|
|
1027
|
+
o2 = self.osc2
|
|
1028
|
+
|
|
1029
|
+
# If NB present, keep them; else synth from OSC.
|
|
1030
|
+
if ha is None and o1 is not None:
|
|
1031
|
+
ha = o1[..., 0]
|
|
1032
|
+
if oo is None and o1 is not None:
|
|
1033
|
+
oo = o1[..., 1:3].mean(axis=2)
|
|
1034
|
+
|
|
1035
|
+
if si is None and o2 is not None:
|
|
1036
|
+
si = o2[..., 0]
|
|
1037
|
+
# If OIII still missing, try OSC2 too
|
|
1038
|
+
if oo is None and o2 is not None:
|
|
1039
|
+
oo = o2[..., 1:3].mean(axis=2)
|
|
1040
|
+
|
|
1041
|
+
# Basic requirements for scenarios:
|
|
1042
|
+
# HOO: needs Ha + OIII
|
|
1043
|
+
# others: ideally need Ha + SII + OIII (but we can allow missing and warn)
|
|
1044
|
+
shapes = [x.shape[:2] for x in (ha, oo, si) if x is not None]
|
|
1045
|
+
if len(shapes) and len(set(shapes)) > 1:
|
|
1046
|
+
# choose reference (prefer Ha, then OIII, then SII)
|
|
1047
|
+
ref = ha if ha is not None else (oo if oo is not None else si)
|
|
1048
|
+
ref_name = "Ha" if ha is not None else ("OIII" if oo is not None else "SII")
|
|
1049
|
+
ref_h, ref_w = ref.shape[:2]
|
|
1050
|
+
|
|
1051
|
+
if not self._dim_mismatch_accepted:
|
|
1052
|
+
msg = (
|
|
1053
|
+
"The loaded channels have different image dimensions.\n\n"
|
|
1054
|
+
f"• Ha: {None if ha is None else ha.shape}\n"
|
|
1055
|
+
f"• OIII: {None if oo is None else oo.shape}\n"
|
|
1056
|
+
f"• SII: {None if si is None else si.shape}\n\n"
|
|
1057
|
+
f"SASpro can resize (warp) the channels to match the reference frame:\n"
|
|
1058
|
+
f"• Reference: {ref_name}\n"
|
|
1059
|
+
f"• Target size: ({ref_w} × {ref_h})\n\n"
|
|
1060
|
+
"Proceed and resize mismatched channels?"
|
|
1061
|
+
)
|
|
1062
|
+
ret = QMessageBox.question(
|
|
1063
|
+
self,
|
|
1064
|
+
"Channel Size Mismatch",
|
|
1065
|
+
msg,
|
|
1066
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1067
|
+
QMessageBox.StandardButton.Yes
|
|
1068
|
+
)
|
|
1069
|
+
if ret != QMessageBox.StandardButton.Yes:
|
|
1070
|
+
return None, None, None
|
|
1071
|
+
self._dim_mismatch_accepted = True
|
|
1072
|
+
|
|
1073
|
+
ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
|
|
1074
|
+
oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
|
|
1075
|
+
si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
|
|
1076
|
+
|
|
1077
|
+
return ha, oo, si
|
|
1078
|
+
|
|
1079
|
+
def _linear_fit_channels(self, ha, oo, si, ref="Ha"):
|
|
1080
|
+
"""
|
|
1081
|
+
Use the shared Linear Fit engine to median-match each mono channel
|
|
1082
|
+
to a chosen reference channel.
|
|
1083
|
+
|
|
1084
|
+
Uses rescale_mode_idx=2 (leave as-is) so we don't normalize/clip here;
|
|
1085
|
+
the normalization algorithm should decide what to do later.
|
|
1086
|
+
"""
|
|
1087
|
+
if ha is None and oo is None and si is None:
|
|
1088
|
+
return ha, oo, si
|
|
1089
|
+
|
|
1090
|
+
# pick reference array
|
|
1091
|
+
ref_arr = None
|
|
1092
|
+
if ref == "Ha" and ha is not None:
|
|
1093
|
+
ref_arr = ha
|
|
1094
|
+
elif ref == "OIII" and oo is not None:
|
|
1095
|
+
ref_arr = oo
|
|
1096
|
+
elif ref == "SII" and si is not None:
|
|
1097
|
+
ref_arr = si
|
|
1098
|
+
else:
|
|
1099
|
+
# fallback: first available
|
|
1100
|
+
ref_arr = ha if ha is not None else (oo if oo is not None else si)
|
|
1101
|
+
|
|
1102
|
+
if ref_arr is None:
|
|
1103
|
+
return ha, oo, si
|
|
1104
|
+
|
|
1105
|
+
# rescale_mode_idx:
|
|
1106
|
+
# 0 clip, 1 normalize if needed, 2 leave as-is
|
|
1107
|
+
rescale_mode_idx = 2
|
|
1108
|
+
|
|
1109
|
+
def fit(ch):
|
|
1110
|
+
if ch is None:
|
|
1111
|
+
return None
|
|
1112
|
+
out, _, _ = linear_fit_mono_to_ref(ch, ref_arr, rescale_mode_idx=rescale_mode_idx)
|
|
1113
|
+
return out.astype(np.float32, copy=False)
|
|
1114
|
+
|
|
1115
|
+
return fit(ha), fit(oo), fit(si)
|
|
1116
|
+
|
|
1117
|
+
# ---------------- Single Laungch Job Function -----------------------
|
|
1118
|
+
def _set_busy(self, busy: bool, msg: str = ""):
|
|
1119
|
+
# Optional UX: disable buttons while processing
|
|
1120
|
+
for w in (self.btn_preview, self.btn_apply, self.btn_push, self.btn_clear,
|
|
1121
|
+
self.btn_ha, self.btn_oiii, self.btn_sii, self.btn_osc1, self.btn_osc2):
|
|
1122
|
+
try:
|
|
1123
|
+
w.setEnabled(not busy)
|
|
1124
|
+
except Exception:
|
|
1125
|
+
pass
|
|
1126
|
+
if msg:
|
|
1127
|
+
self.status.setText(msg)
|
|
1128
|
+
|
|
1129
|
+
def _prepare_inputs_for_job(self, *, downsample: bool) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None, float]:
|
|
1130
|
+
"""
|
|
1131
|
+
Returns (ha, oo, si, scale_used). If downsample=True, returns downsampled channels.
|
|
1132
|
+
Applies the SAME channel derivation + optional linear fit as the full-res path,
|
|
1133
|
+
just on the preview-resolution data.
|
|
1134
|
+
"""
|
|
1135
|
+
ha, oo, si = self._prepared_channels()
|
|
1136
|
+
ok, msg = self._requirements_met(ha, oo, si)
|
|
1137
|
+
if not ok:
|
|
1138
|
+
raise RuntimeError(msg)
|
|
1139
|
+
|
|
1140
|
+
# Downsample first (preview path)
|
|
1141
|
+
s = 1.0
|
|
1142
|
+
if downsample:
|
|
1143
|
+
s = self._preview_scale()
|
|
1144
|
+
ha = self._downsample_mono(ha, s)
|
|
1145
|
+
oo = self._downsample_mono(oo, s)
|
|
1146
|
+
si = self._downsample_mono(si, s)
|
|
1147
|
+
|
|
1148
|
+
# Optional linear fit (apply it on whatever resolution we’re running at)
|
|
1149
|
+
if self.chk_linear_fit.isChecked():
|
|
1150
|
+
meds = {}
|
|
1151
|
+
if ha is not None: meds["Ha"] = _nanmedian(ha)
|
|
1152
|
+
if oo is not None: meds["OIII"] = _nanmedian(oo)
|
|
1153
|
+
if si is not None: meds["SII"] = _nanmedian(si)
|
|
1154
|
+
ref = max(meds, key=meds.get) if meds else "Ha"
|
|
1155
|
+
ha, oo, si = self._linear_fit_channels(ha, oo, si, ref=ref)
|
|
1156
|
+
|
|
1157
|
+
return ha, oo, si, s
|
|
1158
|
+
|
|
1159
|
+
def _start_job(
|
|
1160
|
+
self,
|
|
1161
|
+
*,
|
|
1162
|
+
downsample: bool,
|
|
1163
|
+
step_name: str,
|
|
1164
|
+
on_done, # (out: np.ndarray, step_name: str) -> None
|
|
1165
|
+
on_fail=None,
|
|
1166
|
+
):
|
|
1167
|
+
"""
|
|
1168
|
+
One job launcher for preview/apply/push.
|
|
1169
|
+
Uses seq gating so stale workers can’t overwrite newer results.
|
|
1170
|
+
"""
|
|
1171
|
+
self._calc_seq += 1
|
|
1172
|
+
seq = int(self._calc_seq)
|
|
1173
|
+
self._active_seq = seq
|
|
1174
|
+
|
|
1175
|
+
try:
|
|
1176
|
+
ha, oo, si, s = self._prepare_inputs_for_job(downsample=downsample)
|
|
1177
|
+
except Exception as e:
|
|
1178
|
+
self.status.setText(str(e))
|
|
1179
|
+
return
|
|
1180
|
+
|
|
1181
|
+
if downsample and s < 0.999:
|
|
1182
|
+
self._set_busy(True, f"Computing preview… (downsample {s:.2f}×)")
|
|
1183
|
+
else:
|
|
1184
|
+
self._set_busy(True, "Computing…")
|
|
1185
|
+
|
|
1186
|
+
def _done(out, _step):
|
|
1187
|
+
if seq != self._calc_seq:
|
|
1188
|
+
return
|
|
1189
|
+
try:
|
|
1190
|
+
self.final = out
|
|
1191
|
+
on_done(out, _step)
|
|
1192
|
+
finally:
|
|
1193
|
+
self._set_busy(False)
|
|
1194
|
+
|
|
1195
|
+
def _fail(err: str):
|
|
1196
|
+
if seq != self._calc_seq:
|
|
1197
|
+
return
|
|
1198
|
+
try:
|
|
1199
|
+
if on_fail is not None:
|
|
1200
|
+
on_fail(err)
|
|
1201
|
+
else:
|
|
1202
|
+
QMessageBox.critical(self, "Narrowband Normalization", err)
|
|
1203
|
+
self.status.setText("Failed.")
|
|
1204
|
+
finally:
|
|
1205
|
+
self._set_busy(False)
|
|
1206
|
+
|
|
1207
|
+
# Always go through worker so progress emits (preview + full-res)
|
|
1208
|
+
self._start_nbn_worker(ha, oo, si, step_name=step_name, on_done=_done, on_fail=_fail)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
# ---------------- normalization core (STUBS for now) ----------------
|
|
1213
|
+
def _run_normalization(self, ha, oo, si) -> np.ndarray:
|
|
1214
|
+
"""
|
|
1215
|
+
Placeholder implementation:
|
|
1216
|
+
- Applies optional quick linear fit
|
|
1217
|
+
- Produces a basic RGB mapping for the selected scenario so UI works today
|
|
1218
|
+
"""
|
|
1219
|
+
scenario = self.cmb_scenario.currentText()
|
|
1220
|
+
|
|
1221
|
+
if self.chk_linear_fit.isChecked():
|
|
1222
|
+
# auto pick highest-median reference among available channels
|
|
1223
|
+
meds = {}
|
|
1224
|
+
if ha is not None: meds["Ha"] = _nanmedian(ha)
|
|
1225
|
+
if oo is not None: meds["OIII"] = _nanmedian(oo)
|
|
1226
|
+
if si is not None: meds["SII"] = _nanmedian(si)
|
|
1227
|
+
ref = max(meds, key=meds.get) if meds else "Ha"
|
|
1228
|
+
ha, oo, si = self._linear_fit_channels(ha, oo, si, ref=ref)
|
|
1229
|
+
|
|
1230
|
+
# Basic sanity
|
|
1231
|
+
if scenario == "HOO":
|
|
1232
|
+
if ha is None or oo is None:
|
|
1233
|
+
raise RuntimeError("HOO requires Ha + OIII (or OSC1 providing both).")
|
|
1234
|
+
r = ha
|
|
1235
|
+
g = oo
|
|
1236
|
+
b = oo
|
|
1237
|
+
else:
|
|
1238
|
+
if ha is None or oo is None or si is None:
|
|
1239
|
+
raise RuntimeError(f"{scenario} requires Ha + OIII + SII (or OSC1+OSC2).")
|
|
1240
|
+
if scenario == "SHO":
|
|
1241
|
+
r, g, b = si, ha, oo
|
|
1242
|
+
elif scenario == "HSO":
|
|
1243
|
+
r, g, b = ha, si, oo
|
|
1244
|
+
elif scenario == "HOS":
|
|
1245
|
+
r, g, b = ha, oo, si
|
|
1246
|
+
else:
|
|
1247
|
+
r, g, b = ha, oo, si
|
|
1248
|
+
|
|
1249
|
+
rgb = np.stack([r, g, b], axis=2).astype(np.float32)
|
|
1250
|
+
mx = float(rgb.max()) or 1.0
|
|
1251
|
+
rgb = np.clip(rgb / mx, 0.0, 1.0)
|
|
1252
|
+
return rgb
|
|
1253
|
+
|
|
1254
|
+
def _start_nbn_worker(self, ha, oo, si, *, step_name: str, on_done, on_fail=None):
|
|
1255
|
+
"""
|
|
1256
|
+
Start a background normalization job.
|
|
1257
|
+
Keeps a strong reference to the worker and routes signals safely.
|
|
1258
|
+
"""
|
|
1259
|
+
params = self._gather_params()
|
|
1260
|
+
job = _NBNJob(ha=ha, oiii=oo, sii=si, params=params, step_name=step_name)
|
|
1261
|
+
|
|
1262
|
+
# If an old worker is still running, we don't try to kill it (QThread termination is unsafe).
|
|
1263
|
+
# Instead, we rely on seq checks to ignore stale results.
|
|
1264
|
+
self._worker = _NBNWorker(job)
|
|
1265
|
+
|
|
1266
|
+
self._worker.progress.connect(
|
|
1267
|
+
lambda p, m: self.status.setText(f"{m} ({p}%)" if m else f"{p}%")
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
self._worker.done.connect(on_done)
|
|
1271
|
+
|
|
1272
|
+
if on_fail is None:
|
|
1273
|
+
self._worker.failed.connect(lambda err: QMessageBox.critical(self, "Narrowband Normalization", err))
|
|
1274
|
+
else:
|
|
1275
|
+
self._worker.failed.connect(on_fail)
|
|
1276
|
+
|
|
1277
|
+
self._worker.start()
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ---------------- preview ----------------
|
|
1282
|
+
def _update_preview(self):
|
|
1283
|
+
ha, oo, si = self._prepared_channels()
|
|
1284
|
+
ok, msg = self._requirements_met(ha, oo, si)
|
|
1285
|
+
if not ok:
|
|
1286
|
+
self.status.setText(msg)
|
|
1287
|
+
return
|
|
1288
|
+
|
|
1289
|
+
# optional linear fit
|
|
1290
|
+
if self.chk_linear_fit.isChecked():
|
|
1291
|
+
meds = {}
|
|
1292
|
+
if ha is not None: meds["Ha"] = _nanmedian(ha)
|
|
1293
|
+
if oo is not None: meds["OIII"] = _nanmedian(oo)
|
|
1294
|
+
if si is not None: meds["SII"] = _nanmedian(si)
|
|
1295
|
+
ref = max(meds, key=meds.get) if meds else "Ha"
|
|
1296
|
+
ha, oo, si = self._linear_fit_channels(ha, oo, si, ref=ref)
|
|
1297
|
+
|
|
1298
|
+
params = self._gather_params()
|
|
1299
|
+
out = normalize_narrowband(ha, oo, si, params, progress_cb=None)
|
|
1300
|
+
self.final = out
|
|
1301
|
+
|
|
1302
|
+
disp = out
|
|
1303
|
+
if self.chk_preview_autostretch.isChecked():
|
|
1304
|
+
disp = np.clip(stretch_color_image(disp, target_median=0.25, linked=True), 0.0, 1.0)
|
|
1305
|
+
|
|
1306
|
+
first = (self._base_pm is None)
|
|
1307
|
+
self._set_preview_image(self._to_qimage(disp), fit=first, preserve_view=True)
|
|
1308
|
+
self.status.setText(f"Preview updated ({self.cmb_scenario.currentText()}).")
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _capture_view_state(self):
|
|
1312
|
+
if self._base_pm is None:
|
|
1313
|
+
return None
|
|
1314
|
+
vp = self.scroll.viewport()
|
|
1315
|
+
|
|
1316
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
1317
|
+
anchor_lbl = self.preview.mapFrom(vp, anchor_vp)
|
|
1318
|
+
|
|
1319
|
+
base_x = anchor_lbl.x() / max(self._zoom, 1e-6)
|
|
1320
|
+
base_y = anchor_lbl.y() / max(self._zoom, 1e-6)
|
|
1321
|
+
|
|
1322
|
+
pm = self._base_pm.size()
|
|
1323
|
+
fx = 0.5 if pm.width() <= 0 else (base_x / pm.width())
|
|
1324
|
+
fy = 0.5 if pm.height() <= 0 else (base_y / pm.height())
|
|
1325
|
+
|
|
1326
|
+
return {"zoom": float(self._zoom), "fx": float(fx), "fy": float(fy)}
|
|
1327
|
+
|
|
1328
|
+
def _restore_view_state(self, state):
|
|
1329
|
+
if not state or self._base_pm is None:
|
|
1330
|
+
return
|
|
1331
|
+
|
|
1332
|
+
self._zoom = max(self._min_zoom, min(self._max_zoom, float(state["zoom"])))
|
|
1333
|
+
self._update_preview_pixmap()
|
|
1334
|
+
|
|
1335
|
+
pm = self._base_pm.size()
|
|
1336
|
+
fx = float(state.get("fx", 0.5))
|
|
1337
|
+
fy = float(state.get("fy", 0.5))
|
|
1338
|
+
base_x = fx * pm.width()
|
|
1339
|
+
base_y = fy * pm.height()
|
|
1340
|
+
|
|
1341
|
+
lbl_x = int(base_x * self._zoom)
|
|
1342
|
+
lbl_y = int(base_y * self._zoom)
|
|
1343
|
+
|
|
1344
|
+
vp = self.scroll.viewport()
|
|
1345
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
1346
|
+
|
|
1347
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1348
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1349
|
+
hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_x - anchor_vp.x())))
|
|
1350
|
+
vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_y - anchor_vp.y())))
|
|
1351
|
+
|
|
1352
|
+
def _set_preview_image(self, qimg: QImage, *, fit: bool = False, preserve_view: bool = True):
|
|
1353
|
+
state = None
|
|
1354
|
+
if preserve_view and (not fit) and (self._base_pm is not None):
|
|
1355
|
+
state = self._capture_view_state()
|
|
1356
|
+
|
|
1357
|
+
self._base_pm = QPixmap.fromImage(qimg)
|
|
1358
|
+
|
|
1359
|
+
if fit or state is None:
|
|
1360
|
+
self._zoom = 1.0
|
|
1361
|
+
self._update_preview_pixmap()
|
|
1362
|
+
if fit:
|
|
1363
|
+
QTimer.singleShot(0, self._fit_to_preview)
|
|
1364
|
+
else:
|
|
1365
|
+
QTimer.singleShot(0, self._center_scrollbars)
|
|
1366
|
+
return
|
|
1367
|
+
|
|
1368
|
+
self._restore_view_state(state)
|
|
1369
|
+
|
|
1370
|
+
def _update_preview_pixmap(self):
|
|
1371
|
+
if self._base_pm is None:
|
|
1372
|
+
return
|
|
1373
|
+
|
|
1374
|
+
base_sz = self._base_pm.size()
|
|
1375
|
+
w = max(1, int(base_sz.width() * self._zoom))
|
|
1376
|
+
h = max(1, int(base_sz.height() * self._zoom))
|
|
1377
|
+
|
|
1378
|
+
# Heuristic:
|
|
1379
|
+
# - Fast when zoomed out (lots of pixels squeezed) or when scaled image is huge
|
|
1380
|
+
# - Smooth when zoomed in (user wants quality)
|
|
1381
|
+
scaled_pixels = w * h
|
|
1382
|
+
huge = scaled_pixels >= 6_000_000 # ~6MP threshold (tweak)
|
|
1383
|
+
zoomed_out = self._zoom < 1.0
|
|
1384
|
+
|
|
1385
|
+
mode = Qt.TransformationMode.FastTransformation if (huge or zoomed_out) else Qt.TransformationMode.SmoothTransformation
|
|
1386
|
+
|
|
1387
|
+
scaled = self._base_pm.scaled(
|
|
1388
|
+
w, h,
|
|
1389
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1390
|
+
mode
|
|
1391
|
+
)
|
|
1392
|
+
self.preview.setPixmap(scaled)
|
|
1393
|
+
self.preview.resize(scaled.size())
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _set_zoom(self, new_zoom: float):
|
|
1397
|
+
self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
|
|
1398
|
+
self._update_preview_pixmap()
|
|
1399
|
+
|
|
1400
|
+
def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
|
|
1401
|
+
if self._base_pm is None:
|
|
1402
|
+
return
|
|
1403
|
+
|
|
1404
|
+
vp = self.scroll.viewport()
|
|
1405
|
+
if anchor_vp is None:
|
|
1406
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
1407
|
+
|
|
1408
|
+
lbl_before = self.preview.mapFrom(vp, anchor_vp)
|
|
1409
|
+
|
|
1410
|
+
old_zoom = self._zoom
|
|
1411
|
+
new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
|
|
1412
|
+
ratio = new_zoom / max(old_zoom, 1e-6)
|
|
1413
|
+
if abs(ratio - 1.0) < 1e-6:
|
|
1414
|
+
return
|
|
1415
|
+
|
|
1416
|
+
self._zoom = new_zoom
|
|
1417
|
+
self._update_preview_pixmap()
|
|
1418
|
+
|
|
1419
|
+
lbl_after_x = int(lbl_before.x() * ratio)
|
|
1420
|
+
lbl_after_y = int(lbl_before.y() * ratio)
|
|
1421
|
+
|
|
1422
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1423
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1424
|
+
hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_after_x - anchor_vp.x())))
|
|
1425
|
+
vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_after_y - anchor_vp.y())))
|
|
1426
|
+
|
|
1427
|
+
def _fit_to_preview(self):
|
|
1428
|
+
if self._base_pm is None:
|
|
1429
|
+
return
|
|
1430
|
+
vp = self.scroll.viewport().size()
|
|
1431
|
+
pm = self._base_pm.size()
|
|
1432
|
+
if pm.width() == 0 or pm.height() == 0:
|
|
1433
|
+
return
|
|
1434
|
+
k = min(vp.width() / pm.width(), vp.height() / pm.height())
|
|
1435
|
+
self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
|
|
1436
|
+
self._center_scrollbars()
|
|
1437
|
+
|
|
1438
|
+
def _center_scrollbars(self):
|
|
1439
|
+
h = self.scroll.horizontalScrollBar()
|
|
1440
|
+
v = self.scroll.verticalScrollBar()
|
|
1441
|
+
h.setValue((h.maximum() + h.minimum()) // 2)
|
|
1442
|
+
v.setValue((v.maximum() + v.minimum()) // 2)
|
|
1443
|
+
|
|
1444
|
+
# ---------------- actions ----------------
|
|
1445
|
+
def _clear_channels(self):
|
|
1446
|
+
self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
|
|
1447
|
+
self._dim_mismatch_accepted = False
|
|
1448
|
+
self.final = None
|
|
1449
|
+
self._base_pm = None
|
|
1450
|
+
self.preview.clear()
|
|
1451
|
+
for which in ("Ha", "OIII", "SII", "OSC1", "OSC2"):
|
|
1452
|
+
self._set_status_label(which, None)
|
|
1453
|
+
self.status.setText("Cleared all loaded channels.")
|
|
1454
|
+
|
|
1455
|
+
def _apply_to_current_view(self):
|
|
1456
|
+
mw = self._find_main_window()
|
|
1457
|
+
doc = getattr(mw, "current_document", None)() if (mw and hasattr(mw, "current_document")) else None
|
|
1458
|
+
if doc is None:
|
|
1459
|
+
QMessageBox.information(self, "No Active Doc", "Couldn't find an active document; pushing to new view instead.")
|
|
1460
|
+
self._push_result()
|
|
1461
|
+
return
|
|
1462
|
+
|
|
1463
|
+
def on_done(out: np.ndarray, step_name: str):
|
|
1464
|
+
try:
|
|
1465
|
+
out2 = self._maybe_background_neutralize_rgb(out, doc_for_mask=doc)
|
|
1466
|
+
|
|
1467
|
+
# Prefer apply_edit if your doc supports it (history + metadata)
|
|
1468
|
+
if hasattr(doc, "apply_edit"):
|
|
1469
|
+
meta = {"step_name": "Narrowband Normalization"}
|
|
1470
|
+
if self.chk_bg_neutral.isChecked():
|
|
1471
|
+
meta["post_step"] = "Background Neutralization (auto)"
|
|
1472
|
+
doc.apply_edit(out2.astype(np.float32, copy=False), metadata=meta, step_name="Narrowband Normalization")
|
|
1473
|
+
elif hasattr(doc, "set_image"):
|
|
1474
|
+
doc.set_image(out2, step_name="Narrowband Normalization")
|
|
1475
|
+
else:
|
|
1476
|
+
doc.image = out2
|
|
1477
|
+
|
|
1478
|
+
self.status.setText("Applied normalization to current view.")
|
|
1479
|
+
except Exception as e:
|
|
1480
|
+
QMessageBox.critical(self, "Apply Error", f"Failed to apply:\n{e}")
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
self._start_job(
|
|
1484
|
+
downsample=False, # FULL RES
|
|
1485
|
+
step_name="NBN Apply",
|
|
1486
|
+
on_done=on_done,
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
def _get_doc_manager(self):
|
|
1490
|
+
if self.doc_manager is not None:
|
|
1491
|
+
return self.doc_manager
|
|
1492
|
+
mw = self._find_main_window()
|
|
1493
|
+
if mw is None:
|
|
1494
|
+
return None
|
|
1495
|
+
return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
|
|
1496
|
+
|
|
1497
|
+
def _push_result(self):
|
|
1498
|
+
dm = self._get_doc_manager()
|
|
1499
|
+
if dm is None:
|
|
1500
|
+
QMessageBox.warning(self, "DocManager Missing", "DocManager not found; can't push to a new view.")
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
title = f"NBN {self.cmb_scenario.currentText()}"
|
|
1504
|
+
|
|
1505
|
+
def on_done(out: np.ndarray, step_name: str):
|
|
1506
|
+
try:
|
|
1507
|
+
# Apply optional headless BN to the RESULT before pushing
|
|
1508
|
+
out2 = self._maybe_background_neutralize_rgb(out, doc_for_mask=None)
|
|
1509
|
+
|
|
1510
|
+
meta = {"is_mono": False}
|
|
1511
|
+
if getattr(self, "chk_bg_neutral", None) and self.chk_bg_neutral.isChecked():
|
|
1512
|
+
meta["post_step"] = "Background Neutralization (auto)"
|
|
1513
|
+
|
|
1514
|
+
if hasattr(dm, "open_array"):
|
|
1515
|
+
dm.open_array(out2, metadata=meta, title=title)
|
|
1516
|
+
elif hasattr(dm, "create_document"):
|
|
1517
|
+
dm.create_document(image=out2, metadata=meta, name=title)
|
|
1518
|
+
else:
|
|
1519
|
+
raise RuntimeError("DocManager lacks open_array/create_document")
|
|
1520
|
+
|
|
1521
|
+
self.status.setText("Opened result in a new view.")
|
|
1522
|
+
except Exception as e:
|
|
1523
|
+
QMessageBox.critical(self, "Push Error", f"Failed to open new view:\n{e}")
|
|
1524
|
+
|
|
1525
|
+
self._start_job(
|
|
1526
|
+
downsample=False, # FULL RES
|
|
1527
|
+
step_name="NBN Push",
|
|
1528
|
+
on_done=on_done,
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
# ---------------- utilities ----------------
|
|
1532
|
+
def _to_qimage(self, arr):
|
|
1533
|
+
a = np.clip(arr, 0, 1)
|
|
1534
|
+
if a.ndim == 2:
|
|
1535
|
+
u = (a * 255).astype(np.uint8)
|
|
1536
|
+
h, w = u.shape
|
|
1537
|
+
return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
|
|
1538
|
+
if a.ndim == 3 and a.shape[2] == 3:
|
|
1539
|
+
u = (a * 255).astype(np.uint8)
|
|
1540
|
+
h, w, _ = u.shape
|
|
1541
|
+
return QImage(u.data, w, h, w * 3, QImage.Format.Format_RGB888).copy()
|
|
1542
|
+
raise ValueError(f"Unexpected image shape: {a.shape}")
|
|
1543
|
+
|
|
1544
|
+
def _find_main_window(self):
|
|
1545
|
+
w = self
|
|
1546
|
+
from PyQt6.QtWidgets import QMainWindow, QApplication
|
|
1547
|
+
while w is not None and not isinstance(w, QMainWindow):
|
|
1548
|
+
w = w.parentWidget()
|
|
1549
|
+
if w:
|
|
1550
|
+
return w
|
|
1551
|
+
for tlw in QApplication.topLevelWidgets():
|
|
1552
|
+
if isinstance(tlw, QMainWindow):
|
|
1553
|
+
return tlw
|
|
1554
|
+
return None
|
|
1555
|
+
|
|
1556
|
+
def _list_open_views(self):
|
|
1557
|
+
mw = self._find_main_window()
|
|
1558
|
+
if not mw:
|
|
1559
|
+
return []
|
|
1560
|
+
try:
|
|
1561
|
+
from setiastro.saspro.subwindow import ImageSubWindow
|
|
1562
|
+
subs = mw.findChildren(ImageSubWindow)
|
|
1563
|
+
except Exception:
|
|
1564
|
+
subs = []
|
|
1565
|
+
out = []
|
|
1566
|
+
for sw in subs:
|
|
1567
|
+
title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
|
|
1568
|
+
out.append((str(title), sw))
|
|
1569
|
+
return out
|
|
1570
|
+
|
|
1571
|
+
# ---------------- event filter (zoom/pan) ----------------
|
|
1572
|
+
def eventFilter(self, obj, ev):
|
|
1573
|
+
# Ctrl+wheel = zoom at mouse (no scrolling). Wheel without Ctrl = eaten.
|
|
1574
|
+
if ev.type() == QEvent.Type.Wheel and (
|
|
1575
|
+
obj is self.preview
|
|
1576
|
+
or obj is self.scroll
|
|
1577
|
+
or obj is self.scroll.viewport()
|
|
1578
|
+
or obj is self.scroll.horizontalScrollBar()
|
|
1579
|
+
or obj is self.scroll.verticalScrollBar()
|
|
1580
|
+
):
|
|
1581
|
+
ev.accept()
|
|
1582
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
1583
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
|
|
1584
|
+
|
|
1585
|
+
vp = self.scroll.viewport()
|
|
1586
|
+
anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
|
|
1587
|
+
|
|
1588
|
+
r = vp.rect()
|
|
1589
|
+
if not r.contains(anchor_vp):
|
|
1590
|
+
anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
|
|
1591
|
+
anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
|
|
1592
|
+
|
|
1593
|
+
self._zoom_at(factor, anchor_vp)
|
|
1594
|
+
return True
|
|
1595
|
+
|
|
1596
|
+
# click-drag pan on viewport
|
|
1597
|
+
if obj is self.scroll.viewport():
|
|
1598
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
1599
|
+
self._panning = True
|
|
1600
|
+
self._pan_last = ev.position().toPoint()
|
|
1601
|
+
self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
1602
|
+
return True
|
|
1603
|
+
if ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
1604
|
+
cur = ev.position().toPoint()
|
|
1605
|
+
delta = cur - (self._pan_last or cur)
|
|
1606
|
+
self._pan_last = cur
|
|
1607
|
+
h = self.scroll.horizontalScrollBar()
|
|
1608
|
+
v = self.scroll.verticalScrollBar()
|
|
1609
|
+
h.setValue(h.value() - delta.x())
|
|
1610
|
+
v.setValue(v.value() - delta.y())
|
|
1611
|
+
return True
|
|
1612
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
1613
|
+
self._panning = False
|
|
1614
|
+
self._pan_last = None
|
|
1615
|
+
self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
1616
|
+
return True
|
|
1617
|
+
|
|
1618
|
+
return super().eventFilter(obj, ev)
|