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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {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 &amp; 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
+ &nbsp;&nbsp;|&nbsp;&nbsp;
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)