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,1258 @@
1
+ # src/setiastro/saspro/serviewer.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import numpy as np
6
+
7
+ from PyQt6.QtCore import Qt, QTimer, QSettings, QEvent, QPoint, QRect, QSize
8
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QColor
9
+ from PyQt6.QtWidgets import (
10
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog,
11
+ QScrollArea, QSlider, QCheckBox, QGroupBox, QFormLayout, QSpinBox,
12
+ QMessageBox, QRubberBand, QComboBox, QDoubleSpinBox
13
+ )
14
+
15
+ from setiastro.saspro.imageops.serloader import open_planetary_source, PlanetaryFrameSource
16
+
17
+
18
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
19
+ from setiastro.saspro.ser_stack_config import SERStackConfig
20
+ from setiastro.saspro.ser_stacker import stack_ser
21
+ from setiastro.saspro.ser_stacker_dialog import SERStackerDialog
22
+
23
+ # Use your stretch functions for DISPLAY
24
+ try:
25
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
26
+ except Exception:
27
+ stretch_mono_image = None
28
+ stretch_color_image = None
29
+
30
+
31
+ class SERViewer(QDialog):
32
+ """
33
+ Minimal SER viewer:
34
+ - Open SER
35
+ - Slider to scrub frames
36
+ - Play/pause
37
+ - ROI controls (x,y,w,h + enable)
38
+ - Debayer toggle (for Bayer SER)
39
+ - Linked autostretch toggle (preview only)
40
+ """
41
+
42
+ def __init__(self, parent=None):
43
+ super().__init__(parent)
44
+ self.setWindowTitle("Planetary Stacker Viewer")
45
+ self.setWindowFlag(Qt.WindowType.Window, True)
46
+ self.setWindowModality(Qt.WindowModality.NonModal)
47
+ try:
48
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
49
+ except Exception:
50
+ pass
51
+ self._panning = False
52
+ self._pan_start_pos = None # QPoint in viewport coords
53
+ self._pan_start_h = 0
54
+ self._pan_start_v = 0
55
+ self.reader: PlanetaryFrameSource | None = None
56
+ self._cur = 0
57
+ self._playing = False
58
+ self._roi_dragging = False
59
+ self._roi_start = None # QPoint (viewport coords)
60
+ self._roi_end = None # QPoint (viewport coords)
61
+ self._rubber = None
62
+ self._timer = QTimer(self)
63
+ self._timer.setInterval(33) # ~30fps scrub/play
64
+ self._timer.timeout.connect(self._tick_playback)
65
+ self._drag_mode = None # None / "roi" / "anchor"
66
+ self._surface_anchor = None # (x,y,w,h) in ROI-space
67
+ self._source_spec = None # str or list[str]
68
+ self._zoom = 1.0
69
+ self._fit_mode = True
70
+ self._last_qimg: QImage | None = None
71
+ self._last_disp_arr: np.ndarray | None = None # the float [0..1] image we displayed (after stretch + tone)
72
+ self._last_overlay = None # dict with overlay info for _render_last()
73
+
74
+ self._build_ui()
75
+
76
+
77
+ # ---------------- UI ----------------
78
+
79
+ def _build_ui(self):
80
+ # Root: left (viewer) + right (controls)
81
+ root = QHBoxLayout(self)
82
+ root.setContentsMargins(8, 8, 8, 8)
83
+ root.setSpacing(8)
84
+
85
+ # ---------- LEFT: playback + scrubber + preview + zoom ----------
86
+ left = QVBoxLayout()
87
+ left.setSpacing(8)
88
+ root.addLayout(left, 1)
89
+
90
+ # Top controls (left)
91
+ top = QHBoxLayout()
92
+ self.btn_open = QPushButton("Open SER/AVI/Frames…", self)
93
+ self.btn_play = QPushButton("Play", self)
94
+ self.btn_play.setEnabled(False)
95
+
96
+ top.addWidget(self.btn_open)
97
+ top.addWidget(self.btn_play)
98
+ top.addStretch(1)
99
+ left.addLayout(top)
100
+
101
+ self.lbl_info = QLabel("No SER loaded.", self)
102
+ self.lbl_info.setStyleSheet("color:#888;")
103
+ self.lbl_info.setWordWrap(True)
104
+ left.addWidget(self.lbl_info)
105
+
106
+ # Scrubber (left)
107
+ scrub = QHBoxLayout()
108
+ self.sld = QSlider(Qt.Orientation.Horizontal, self)
109
+ self.sld.setRange(0, 0)
110
+ self.sld.setEnabled(False)
111
+ self.lbl_frame = QLabel("0 / 0", self)
112
+ scrub.addWidget(self.sld, 1)
113
+ scrub.addWidget(self.lbl_frame, 0)
114
+ left.addLayout(scrub)
115
+
116
+ # Preview area (left)
117
+ self.scroll = QScrollArea(self)
118
+ # IMPORTANT: for sane zoom + scrollbars, do NOT let the scroll area auto-resize the widget
119
+ self.scroll.setWidgetResizable(False)
120
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
121
+ self.scroll.viewport().installEventFilter(self)
122
+ self.scroll.viewport().setMouseTracking(True)
123
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
124
+
125
+ # Rubber band for Shift+drag ROI (thick, bright green, always visible)
126
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self.scroll.viewport())
127
+ self._rubber.setStyleSheet(
128
+ "QRubberBand {"
129
+ " border: 3px solid #00ff00;"
130
+ " background: rgba(0,255,0,30);"
131
+ "}"
132
+ )
133
+ self._rubber.hide()
134
+
135
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
136
+ self.preview.setMinimumSize(640, 360)
137
+ self.scroll.setWidget(self.preview)
138
+ left.addWidget(self.scroll, 1)
139
+
140
+ # Zoom buttons (NOW under preview, centered)
141
+ zoom_row = QHBoxLayout()
142
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
143
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
144
+ self.btn_zoom_1_1 = themed_toolbtn("zoom-original", "1:1")
145
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
146
+
147
+ zoom_row.addStretch(1)
148
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_1_1, self.btn_zoom_fit):
149
+ zoom_row.addWidget(b)
150
+ zoom_row.addStretch(1)
151
+ left.addLayout(zoom_row)
152
+
153
+ # ---------- RIGHT: options + stacking ----------
154
+ right = QVBoxLayout()
155
+ right.setSpacing(8)
156
+ root.addLayout(right, 0)
157
+
158
+ # Preview Options (right)
159
+ opts = QGroupBox("Preview Options", self)
160
+ form = QFormLayout(opts)
161
+
162
+ self.chk_roi = QCheckBox("Use ROI (crop for preview)", self)
163
+
164
+ self.chk_debayer = QCheckBox("Debayer (Bayer SER)", self)
165
+ self.chk_debayer.setChecked(True)
166
+ self.cmb_bayer = QComboBox(self)
167
+ self.cmb_bayer.addItems(["AUTO", "RGGB", "GRBG", "GBRG", "BGGR"])
168
+ self.cmb_bayer.setCurrentText("AUTO") # ✅ default for raw mosaic AVI
169
+
170
+ self.chk_autostretch = QCheckBox("Autostretch preview (linked)", self)
171
+ self.chk_autostretch.setChecked(False)
172
+
173
+ # ROI controls
174
+ self.spin_x = QSpinBox(self); self.spin_x.setRange(0, 999999)
175
+ self.spin_y = QSpinBox(self); self.spin_y.setRange(0, 999999)
176
+ self.spin_w = QSpinBox(self); self.spin_w.setRange(1, 999999); self.spin_w.setValue(512)
177
+ self.spin_h = QSpinBox(self); self.spin_h.setRange(1, 999999); self.spin_h.setValue(512)
178
+
179
+ form.addRow("", self.chk_roi)
180
+
181
+ row1 = QHBoxLayout()
182
+ row1.setContentsMargins(0, 0, 0, 0)
183
+ row1.addWidget(QLabel("x:", self)); row1.addWidget(self.spin_x)
184
+ row1.addWidget(QLabel("y:", self)); row1.addWidget(self.spin_y)
185
+ form.addRow("ROI origin", row1)
186
+
187
+ row2 = QHBoxLayout()
188
+ row2.setContentsMargins(0, 0, 0, 0)
189
+ row2.addWidget(QLabel("w:", self)); row2.addWidget(self.spin_w)
190
+ row2.addWidget(QLabel("h:", self)); row2.addWidget(self.spin_h)
191
+ form.addRow("ROI size", row2)
192
+
193
+ form.addRow("", self.chk_debayer)
194
+ form.addRow("Bayer pattern", self.cmb_bayer)
195
+ form.addRow("", self.chk_autostretch)
196
+
197
+ # --- Preview tone controls (DISPLAY ONLY) ---
198
+ self.sld_brightness = QSlider(Qt.Orientation.Horizontal, self)
199
+ self.sld_brightness.setRange(-100, 100) # maps to -0.25 .. +0.25
200
+ self.sld_brightness.setValue(0)
201
+ self.sld_brightness.setToolTip("Preview brightness (display only)")
202
+
203
+ self.sld_gamma = QSlider(Qt.Orientation.Horizontal, self)
204
+ self.sld_gamma.setRange(30, 300) # 0.30 .. 3.00
205
+ self.sld_gamma.setValue(100) # 1.00
206
+ self.sld_gamma.setToolTip("Preview gamma (display only)")
207
+
208
+ form.addRow("Brightness", self.sld_brightness)
209
+ form.addRow("Gamma", self.sld_gamma)
210
+
211
+ right.addWidget(opts, 0)
212
+
213
+ # Stacking Options (right)
214
+ stack = QGroupBox("Stacking Options", self)
215
+ sform = QFormLayout(stack)
216
+
217
+ self.cmb_track = QComboBox(self)
218
+ self.cmb_track.addItems(["Planetary", "Surface", "Off"]) # map to config
219
+ self.cmb_track.setCurrentText("Planetary")
220
+
221
+ self.spin_keep = QDoubleSpinBox(self)
222
+ self.spin_keep.setRange(0.1, 100.0)
223
+ self.spin_keep.setDecimals(1)
224
+ self.spin_keep.setSingleStep(1.0)
225
+ self.spin_keep.setValue(20.0)
226
+
227
+ self.lbl_anchor = QLabel("Surface anchor: (not set)", self)
228
+ self.lbl_anchor.setStyleSheet("color:#888;")
229
+ self.lbl_anchor.setWordWrap(True)
230
+ self.lbl_anchor.setToolTip(
231
+ "Surface tracking needs an anchor patch.\n"
232
+ "Ctrl+Shift+drag to define it (within ROI)."
233
+ )
234
+
235
+ self.btn_stack = QPushButton("Open Stacker…", self)
236
+ self.btn_stack.setEnabled(False) # enabled once SER loaded
237
+
238
+ sform.addRow("Tracking", self.cmb_track)
239
+ sform.addRow("Keep %", self.spin_keep)
240
+ sform.addRow("", self.lbl_anchor)
241
+ sform.addRow("", self.btn_stack)
242
+
243
+ right.addWidget(stack, 0)
244
+
245
+ right.addStretch(1)
246
+
247
+ # Keep the right panel from getting too wide
248
+ for gb in (opts, stack):
249
+ gb.setMinimumWidth(360)
250
+
251
+ # ---------- Signals ----------
252
+ self.btn_open.clicked.connect(self._open_source)
253
+ self.btn_play.clicked.connect(self._toggle_play)
254
+ self.sld.valueChanged.connect(self._on_slider_changed)
255
+
256
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_step(1/1.25))
257
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_step(1.25))
258
+ self.btn_zoom_1_1.clicked.connect(lambda: self._set_zoom(1.0, anchor=self._viewport_center_anchor()))
259
+ self.btn_zoom_fit.clicked.connect(self._set_fit_mode)
260
+
261
+ for w in (self.chk_roi, self.chk_debayer, self.chk_autostretch,
262
+ self.spin_x, self.spin_y, self.spin_w, self.spin_h,
263
+ self.sld_brightness, self.sld_gamma):
264
+ if hasattr(w, "toggled"):
265
+ w.toggled.connect(self._refresh)
266
+ if hasattr(w, "valueChanged"):
267
+ w.valueChanged.connect(self._refresh)
268
+
269
+ self.cmb_track.currentIndexChanged.connect(self._on_track_mode_changed)
270
+ self.btn_stack.clicked.connect(self._open_stacker_clicked)
271
+ self.cmb_bayer.currentIndexChanged.connect(self._refresh)
272
+ self.chk_debayer.toggled.connect(lambda v: self.cmb_bayer.setEnabled(bool(v)))
273
+ self.cmb_bayer.setEnabled(self.chk_debayer.isChecked())
274
+ self.resize(1200, 800)
275
+
276
+
277
+ #-----qsettings
278
+ def _settings(self) -> QSettings:
279
+ # Prefer app-wide QSettings if your main window provides it
280
+ if hasattr(self.parent(), "settings"):
281
+ s = getattr(self.parent(), "settings")
282
+ if isinstance(s, QSettings):
283
+ return s
284
+ # Fallback: app-global QSettings (uses org/app set in main())
285
+ return QSettings()
286
+
287
+ def _last_open_dir(self) -> str:
288
+ s = self._settings()
289
+ return s.value("serviewer/last_open_dir", "", type=str) or ""
290
+
291
+ def _set_last_open_dir(self, path: str) -> None:
292
+ try:
293
+ d = os.path.dirname(os.path.abspath(path))
294
+ except Exception:
295
+ return
296
+ if d:
297
+ s = self._settings()
298
+ s.setValue("serviewer/last_open_dir", d)
299
+
300
+
301
+ # ---------------- actions ----------------
302
+
303
+ def _apply_preview_tone(self, img: np.ndarray) -> np.ndarray:
304
+ """
305
+ Preview-only brightness + gamma.
306
+ - brightness: adds offset in [-0.25..+0.25]
307
+ - gamma: power curve in [0.30..3.00] (1.0 = no change)
308
+ Works on mono or RGB float32 [0..1].
309
+ """
310
+ if img is None:
311
+ return img
312
+
313
+ # Brightness: -100..100 -> -0.25..+0.25
314
+ b = float(self.sld_brightness.value()) / 100.0 * 0.25
315
+
316
+ # Gamma: 30..300 -> 0.30..3.00
317
+ g = float(self.sld_gamma.value()) / 100.0
318
+ if g <= 0:
319
+ g = 1.0
320
+
321
+ out = img
322
+
323
+ if abs(b) > 1e-6:
324
+ out = np.clip(out + b, 0.0, 1.0)
325
+
326
+ if abs(g - 1.0) > 1e-6:
327
+ # gamma > 1 darkens, gamma < 1 brightens
328
+ out = np.clip(np.power(np.clip(out, 0.0, 1.0), g), 0.0, 1.0)
329
+
330
+ return out
331
+
332
+ def _viewport_center_anchor(self):
333
+ vp = self.scroll.viewport()
334
+ return vp.rect().center()
335
+
336
+ def _mouse_anchor(self):
337
+ # Anchor zoom to mouse position if mouse is over the viewport, else center.
338
+ vp = self.scroll.viewport()
339
+ p = vp.mapFromGlobal(self.cursor().pos())
340
+ if vp.rect().contains(p):
341
+ return p
342
+ return vp.rect().center()
343
+
344
+ def _set_fit_mode(self):
345
+ self._fit_mode = True
346
+ self._render_last() # rerender in fit mode
347
+
348
+ def _set_zoom(self, z: float, anchor=None):
349
+ self._fit_mode = False
350
+ self._zoom = float(max(0.05, min(20.0, z)))
351
+ self._render_last(anchor=anchor)
352
+
353
+ def _zoom_step(self, factor: float):
354
+ # Anchor zoom to mouse
355
+ anchor = self._mouse_anchor()
356
+
357
+ # If coming from fit, start from the fit zoom (prevents snapping)
358
+ self._ensure_manual_zoom_from_fit()
359
+
360
+ self._set_zoom(self._zoom * factor, anchor=anchor)
361
+
362
+ def _fit_zoom_factor(self) -> float:
363
+ """
364
+ If we are in fit mode and a pixmap is displayed, return the effective zoom
365
+ relative to the *original* frame size. This is what the user is visually seeing.
366
+ """
367
+ if self._last_qimg is None:
368
+ return 1.0
369
+
370
+ pm = self.preview.pixmap()
371
+ if pm is None or pm.isNull():
372
+ return 1.0
373
+
374
+ ow = max(1, self._last_qimg.width())
375
+ oh = max(1, self._last_qimg.height())
376
+ fw = max(1, pm.width())
377
+ fh = max(1, pm.height())
378
+
379
+ # KeepAspectRatio means either width or height matches; take the smaller ratio to be safe.
380
+ return min(fw / ow, fh / oh)
381
+
382
+ def _ensure_manual_zoom_from_fit(self):
383
+ """
384
+ If we are currently in fit mode, switch to manual zoom using the current
385
+ effective fit zoom as the starting point (prevents snapping to ~1:1).
386
+ """
387
+ if self._fit_mode:
388
+ self._zoom = self._fit_zoom_factor()
389
+ self._fit_mode = False
390
+
391
+ def _roi_rect_vp(self):
392
+ """ROI QRect in viewport coords from start/end points."""
393
+ if self._roi_start is None or self._roi_end is None:
394
+ return None
395
+ x1, y1 = self._roi_start.x(), self._roi_start.y()
396
+ x2, y2 = self._roi_end.x(), self._roi_end.y()
397
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
398
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
399
+ # enforce minimum size
400
+ if (right - left) < 4 or (bottom - top) < 4:
401
+ return None
402
+ from PyQt6.QtCore import QRect
403
+ return QRect(left, top, right - left, bottom - top)
404
+
405
+ def _viewport_rect_to_display_image(self, r_vp):
406
+ """
407
+ Convert a viewport QRect (rubberband geometry) into coords in the CURRENT DISPLAYED IMAGE.
408
+ That image is exactly self._last_qimg (ROI-sized if ROI is enabled).
409
+ Returns (x,y,w,h) in _last_qimg pixel space.
410
+ """
411
+ if self._last_qimg is None:
412
+ return None
413
+ pm = self.preview.pixmap()
414
+ if pm is None or pm.isNull():
415
+ return None
416
+
417
+ # preview widget top-left inside viewport coords
418
+ wp = self.preview.pos()
419
+ lbl_left = int(wp.x())
420
+ lbl_top = int(wp.y())
421
+
422
+ lbl_w = int(self.preview.width())
423
+ lbl_h = int(self.preview.height())
424
+ if lbl_w < 2 or lbl_h < 2:
425
+ return None
426
+
427
+ # rect corners in preview-widget coords
428
+ x1 = int(r_vp.left() - lbl_left)
429
+ y1 = int(r_vp.top() - lbl_top)
430
+ x2 = int(r_vp.right() - lbl_left)
431
+ y2 = int(r_vp.bottom() - lbl_top)
432
+
433
+ # clamp to widget bounds
434
+ x1 = max(0, min(lbl_w - 1, x1))
435
+ y1 = max(0, min(lbl_h - 1, y1))
436
+ x2 = max(0, min(lbl_w - 1, x2))
437
+ y2 = max(0, min(lbl_h - 1, y2))
438
+ if x2 <= x1 or y2 <= y1:
439
+ return None
440
+
441
+ # map widget coords -> displayed image coords (_last_qimg space)
442
+ ow = max(1, self._last_qimg.width())
443
+ oh = max(1, self._last_qimg.height())
444
+
445
+ scale_x = ow / float(lbl_w)
446
+ scale_y = oh / float(lbl_h)
447
+
448
+ ix = int(round(x1 * scale_x))
449
+ iy = int(round(y1 * scale_y))
450
+ iw = int(round((x2 - x1) * scale_x))
451
+ ih = int(round((y2 - y1) * scale_y))
452
+
453
+ # clamp to image bounds
454
+ ix = max(0, min(ow - 1, ix))
455
+ iy = max(0, min(oh - 1, iy))
456
+ iw = max(1, min(ow - ix, iw))
457
+ ih = max(1, min(oh - iy, ih))
458
+
459
+ return (ix, iy, iw, ih)
460
+
461
+
462
+ def _viewport_rect_to_image_roi(self, r_vp):
463
+ """
464
+ Convert a viewport-rect (viewport coords) into an ROI in IMAGE coords:
465
+ returns (x,y,w,h) in original frame pixel space.
466
+ Works in both fit mode and manual zoom mode, with scrollbars and centering.
467
+ """
468
+ if self._last_qimg is None:
469
+ return None
470
+ pm = self.preview.pixmap()
471
+ if pm is None or pm.isNull():
472
+ return None
473
+
474
+ # Where the preview widget actually is inside the viewport (accounts for scroll + centering)
475
+ wp = self.preview.pos() # QPoint in viewport coords
476
+ lbl_left = int(wp.x())
477
+ lbl_top = int(wp.y())
478
+
479
+ lbl_w = int(self.preview.width())
480
+ lbl_h = int(self.preview.height())
481
+ if lbl_w < 2 or lbl_h < 2:
482
+ return None
483
+
484
+ # ROI corners in widget coords
485
+ x1 = int(r_vp.left() - lbl_left)
486
+ y1 = int(r_vp.top() - lbl_top)
487
+ x2 = int(r_vp.right() - lbl_left)
488
+ y2 = int(r_vp.bottom() - lbl_top)
489
+
490
+ # Clamp to widget bounds
491
+ x1 = max(0, min(lbl_w - 1, x1))
492
+ y1 = max(0, min(lbl_h - 1, y1))
493
+ x2 = max(0, min(lbl_w - 1, x2))
494
+ y2 = max(0, min(lbl_h - 1, y2))
495
+
496
+ if x2 <= x1 or y2 <= y1:
497
+ return None
498
+
499
+ # Map widget coords -> original image coords
500
+ ow = max(1, self._last_qimg.width())
501
+ oh = max(1, self._last_qimg.height())
502
+
503
+ scale_x = ow / float(lbl_w)
504
+ scale_y = oh / float(lbl_h)
505
+
506
+ ix = int(round(x1 * scale_x))
507
+ iy = int(round(y1 * scale_y))
508
+ iw = int(round((x2 - x1) * scale_x))
509
+ ih = int(round((y2 - y1) * scale_y))
510
+
511
+ # clamp to image bounds
512
+ ix = max(0, min(ow - 1, ix))
513
+ iy = max(0, min(oh - 1, iy))
514
+ iw = max(1, min(ow - ix, iw))
515
+ ih = max(1, min(oh - iy, ih))
516
+
517
+ return (ix, iy, iw, ih)
518
+
519
+ def _update_anchor_label(self):
520
+ a = getattr(self, "_surface_anchor", None)
521
+ if a is None:
522
+ self.lbl_anchor.setText("Surface anchor: (not set) • Ctrl+Shift+drag to set")
523
+ self.lbl_anchor.setStyleSheet("color:#888;")
524
+ else:
525
+ x, y, w, h = a
526
+ self.lbl_anchor.setText(f"Surface anchor: x={x}, y={y}, w={w}, h={h} • Ctrl+Shift+drag to change")
527
+ self.lbl_anchor.setStyleSheet("color:#4a4;")
528
+
529
+ def _on_track_mode_changed(self):
530
+ mode = self._track_mode_value()
531
+
532
+ # ✅ always reflect current anchor state
533
+ self._update_anchor_label()
534
+
535
+ if mode == "surface" and self._surface_anchor is None:
536
+ self.lbl_anchor.setText("Surface anchor: REQUIRED • Ctrl+Shift+drag to set")
537
+ self.lbl_anchor.setStyleSheet("color:#c66;")
538
+
539
+ self._refresh()
540
+
541
+
542
+ def _track_mode_value(self) -> str:
543
+ t = self.cmb_track.currentText().strip().lower()
544
+ if t.startswith("planet"):
545
+ return "planetary"
546
+ if t.startswith("surface"):
547
+ return "surface"
548
+ return "off"
549
+
550
+
551
+ def eventFilter(self, obj, event):
552
+ vp = self.scroll.viewport()
553
+ try:
554
+ if obj is vp:
555
+ et = event.type()
556
+
557
+ # ---- Ctrl+Wheel zoom ----
558
+ if et == QEvent.Type.Wheel:
559
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
560
+ dy = event.angleDelta().y()
561
+ if dy != 0:
562
+ factor = 1.25 if dy > 0 else (1 / 1.25)
563
+ anchor = event.position().toPoint() # viewport coords
564
+ self._ensure_manual_zoom_from_fit()
565
+ self._set_zoom(self._zoom * factor, anchor=anchor)
566
+ event.accept()
567
+ return True
568
+ return False
569
+
570
+ # ---- Left-drag pan and ROI ----
571
+ if et == QEvent.Type.MouseButtonPress:
572
+ if event.button() == Qt.MouseButton.LeftButton:
573
+ mods = event.modifiers()
574
+
575
+ is_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
576
+ is_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
577
+
578
+ if is_shift:
579
+ # Shift+Drag = ROI, Ctrl+Shift+Drag = Anchor
580
+ self._roi_dragging = True
581
+ self._roi_start = event.position().toPoint()
582
+ self._drag_mode = "anchor" if is_ctrl else "roi"
583
+
584
+ if self._rubber is not None:
585
+ self._rubber.setGeometry(QRect(self._roi_start, QSize(1, 1)))
586
+ self._rubber.show()
587
+ self._rubber.raise_()
588
+
589
+ # Optional: different color for anchor
590
+ if self._drag_mode == "anchor":
591
+ self._rubber.setStyleSheet(
592
+ "QRubberBand { border: 3px solid #00aaff; background: rgba(0,170,255,30); }"
593
+ )
594
+ else:
595
+ self._rubber.setStyleSheet(
596
+ "QRubberBand { border: 3px solid #00ff00; background: rgba(0,255,0,30); }"
597
+ )
598
+
599
+ vp.setCursor(Qt.CursorShape.CrossCursor)
600
+ event.accept()
601
+ return True
602
+
603
+ # Normal left-drag pan
604
+ self._panning = True
605
+ self._pan_start_pos = event.position().toPoint()
606
+ self._pan_start_h = self.scroll.horizontalScrollBar().value()
607
+ self._pan_start_v = self.scroll.verticalScrollBar().value()
608
+ vp.setCursor(Qt.CursorShape.ClosedHandCursor)
609
+ event.accept()
610
+ return True
611
+ if et == QEvent.Type.MouseMove:
612
+ if self._roi_dragging and self._roi_start is not None:
613
+ cur = event.position().toPoint()
614
+ if self._rubber is not None:
615
+ self._rubber.setGeometry(QRect(self._roi_start, cur).normalized())
616
+ self._rubber.raise_()
617
+ event.accept()
618
+ return True
619
+
620
+ if self._panning and self._pan_start_pos is not None:
621
+ cur = event.position().toPoint()
622
+ delta = cur - self._pan_start_pos
623
+ hbar = self.scroll.horizontalScrollBar()
624
+ vbar = self.scroll.verticalScrollBar()
625
+ hbar.setValue(self._pan_start_h - delta.x())
626
+ vbar.setValue(self._pan_start_v - delta.y())
627
+ event.accept()
628
+ return True
629
+
630
+ if et == QEvent.Type.MouseButtonRelease:
631
+ if event.button() == Qt.MouseButton.LeftButton:
632
+
633
+ # --- finish ROI/anchor rubberband drag ---
634
+ if self._roi_dragging:
635
+ self._roi_dragging = False
636
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
637
+
638
+ r_vp = None
639
+ if self._rubber is not None:
640
+ r_vp = self._rubber.geometry()
641
+ self._rubber.hide()
642
+
643
+ self._roi_start = None
644
+
645
+ if r_vp is not None and r_vp.width() >= 4 and r_vp.height() >= 4:
646
+ rect_disp = self._viewport_rect_to_display_image(r_vp) # coords in _last_qimg space (ROI-sized if ROI enabled)
647
+ if rect_disp is not None:
648
+ if self._drag_mode == "roi":
649
+ # If ROI is already enabled, the displayed image is ROI-space.
650
+ # The user is drawing a NEW ROI inside that ROI -> convert to full-frame.
651
+ if self.chk_roi.isChecked():
652
+ rx, ry, rw, rh = self._roi_bounds()
653
+ x, y, w, h = rect_disp
654
+ x_full = int(rx + x)
655
+ y_full = int(ry + y)
656
+ self.spin_x.setValue(x_full)
657
+ self.spin_y.setValue(y_full)
658
+ self.spin_w.setValue(int(w))
659
+ self.spin_h.setValue(int(h))
660
+ else:
661
+ x, y, w, h = rect_disp
662
+ self.spin_x.setValue(int(x))
663
+ self.spin_y.setValue(int(y))
664
+ self.spin_w.setValue(int(w))
665
+ self.spin_h.setValue(int(h))
666
+
667
+ self.chk_roi.setChecked(True)
668
+ self._refresh()
669
+
670
+ elif self._drag_mode == "anchor":
671
+ # Anchor is ALWAYS stored in ROI-space.
672
+ # When ROI is enabled, displayed image == ROI-space, so rect_disp is already correct.
673
+ # When ROI is disabled, ROI-space == full-frame, so rect_disp is still correct.
674
+ self._surface_anchor = tuple(int(v) for v in rect_disp)
675
+ self._update_anchor_label()
676
+ self._render_last()
677
+
678
+ self._drag_mode = None
679
+ event.accept()
680
+ return True
681
+
682
+ # --- finish panning ---
683
+ if self._panning:
684
+ self._panning = False
685
+ self._pan_start_pos = None
686
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
687
+ event.accept()
688
+ return True
689
+
690
+
691
+ if et == QEvent.Type.Leave:
692
+ if self._panning or self._roi_dragging:
693
+ self._panning = False
694
+ self._roi_dragging = False
695
+ self._pan_start_pos = None
696
+ self._roi_start = None
697
+ if self._rubber is not None:
698
+ self._rubber.hide()
699
+ self._drag_mode = None
700
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
701
+ event.accept()
702
+ return True
703
+
704
+ except Exception:
705
+ pass
706
+
707
+ return super().eventFilter(obj, event)
708
+
709
+ def _open_stacker_clicked(self):
710
+ if self.reader is None:
711
+ return
712
+
713
+ source = self.get_source_spec()
714
+ if not source:
715
+ return
716
+
717
+ # Only meaningful for single-file sources; OK to pass None for sequences
718
+ ser_path = source if isinstance(source, str) else None
719
+
720
+ roi = self.get_roi()
721
+ anchor = self.get_surface_anchor()
722
+
723
+ main = self.parent() or self
724
+ current_doc = None
725
+ try:
726
+ if hasattr(main, "active_document"):
727
+ current_doc = main.active_document()
728
+ elif hasattr(main, "currentDocument"):
729
+ current_doc = main.currentDocument()
730
+ elif hasattr(main, "docman") and hasattr(main.docman, "current_document"):
731
+ current_doc = main.docman.current_document()
732
+ elif hasattr(main, "docman") and hasattr(main.docman, "current"):
733
+ current_doc = main.docman.current()
734
+ except Exception:
735
+ current_doc = None
736
+
737
+ debayer = bool(self.chk_debayer.isChecked())
738
+
739
+ # Normalize: "AUTO" means "let the loader decide"
740
+ bp = self.cmb_bayer.currentText().strip().upper()
741
+ if not debayer or bp == "AUTO":
742
+ bp = None
743
+
744
+ dlg = SERStackerDialog(
745
+ parent=self,
746
+ main=main,
747
+ source_doc=current_doc,
748
+ ser_path=ser_path,
749
+ source=source,
750
+ roi=roi,
751
+ track_mode=self._track_mode_value(),
752
+ surface_anchor=anchor,
753
+ debayer=debayer,
754
+ bayer_pattern=bp, # ✅ THIS IS THE FIX
755
+ keep_percent=float(self.spin_keep.value()),
756
+ )
757
+
758
+
759
+ dlg.stackProduced.connect(self._on_stacker_produced)
760
+ dlg.show()
761
+ dlg.raise_()
762
+ dlg.activateWindow()
763
+
764
+ def _on_stacker_produced(self, out: np.ndarray, diag: dict):
765
+ """
766
+ Viewer should NOT decide “save vs new view” long-term.
767
+ For now, we just hand it to the parent/main if it supports a hook.
768
+ """
769
+ # Try common patterns without hard dependency:
770
+ # - main window has doc manager: main.push_array_to_new_view(...)
771
+ # - or a generic method: main.open_image_from_array(...)
772
+ main = self.parent()
773
+
774
+ # 1) Example hook you can implement in MainWindow/DocManager:
775
+ if main is not None and hasattr(main, "push_array_to_new_view"):
776
+ try:
777
+ title = f"Stacked SER ({diag.get('frames_kept')}/{diag.get('frames_total')})"
778
+ main.push_array_to_new_view(out, title=title, meta={"ser_diag": diag})
779
+ return
780
+ except Exception:
781
+ pass
782
+
783
+ # 2) Fallback: show it in this viewer preview (temporary)
784
+ try:
785
+ qimg = self._to_qimage(out)
786
+ self._last_qimg = qimg
787
+ self._fit_mode = True
788
+ self._render_last()
789
+ self.lbl_info.setText(
790
+ self.lbl_info.text()
791
+ + f"<br><b>Stacked (from stacker):</b> kept {diag.get('frames_kept')} / {diag.get('frames_total')}"
792
+ )
793
+ except Exception:
794
+ pass
795
+
796
+ def _compute_planet_com_px(self, img01: np.ndarray) -> tuple[float, float] | None:
797
+ """
798
+ Compute a quick center-of-mass in *image pixel coords* of the currently displayed image (ROI-space).
799
+ Uses a simple brightness-weighted COM with background subtraction.
800
+ """
801
+ try:
802
+ if img01 is None:
803
+ return None
804
+ if img01.ndim == 3:
805
+ # simple luma (no extra deps)
806
+ m = 0.2126 * img01[..., 0] + 0.7152 * img01[..., 1] + 0.0722 * img01[..., 2]
807
+ else:
808
+ m = img01
809
+
810
+ m = np.asarray(m, dtype=np.float32)
811
+ H, W = m.shape[:2]
812
+ if H < 2 or W < 2:
813
+ return None
814
+
815
+ # Robust-ish background subtraction to focus on the planet
816
+ bg = float(np.percentile(m, 60)) # helps ignore dark background
817
+ w = np.clip(m - bg, 0.0, None)
818
+
819
+ s = float(w.sum())
820
+ if s <= 1e-8:
821
+ return None
822
+
823
+ ys = np.arange(H, dtype=np.float32)[:, None]
824
+ xs = np.arange(W, dtype=np.float32)[None, :]
825
+ cy = float((w * ys).sum() / s)
826
+ cx = float((w * xs).sum() / s)
827
+ return (cx, cy)
828
+ except Exception:
829
+ return None
830
+
831
+
832
+ def _img_xy_to_pixmap_xy(self, x: float, y: float) -> tuple[int, int] | None:
833
+ """
834
+ Map a point in ORIGINAL IMAGE pixel coords (of _last_qimg) into current pixmap coords.
835
+ In this viewer, pixmap size == preview label size in both fit and manual modes.
836
+ """
837
+ if self._last_qimg is None:
838
+ return None
839
+ pm = self.preview.pixmap()
840
+ if pm is None or pm.isNull():
841
+ return None
842
+
843
+ ow = max(1, self._last_qimg.width())
844
+ oh = max(1, self._last_qimg.height())
845
+ pw = max(1, pm.width())
846
+ ph = max(1, pm.height())
847
+
848
+ px = int(round((x / ow) * pw))
849
+ py = int(round((y / oh) * ph))
850
+ return (px, py)
851
+
852
+
853
+ def _roi_rect_to_pixmap_rect(self, rect_roi: tuple[int, int, int, int]) -> QRect | None:
854
+ """
855
+ rect_roi is ROI-space (0..roi_w,0..roi_h) but the displayed image is also ROI-sized
856
+ whenever ROI checkbox is ON (because get_frame(roi=roi) crops).
857
+ So ROI-space == displayed image pixel space. Great.
858
+ """
859
+ if rect_roi is None:
860
+ return None
861
+
862
+ x, y, w, h = [int(v) for v in rect_roi]
863
+ p1 = self._img_xy_to_pixmap_xy(x, y)
864
+ p2 = self._img_xy_to_pixmap_xy(x + w, y + h)
865
+ if p1 is None or p2 is None:
866
+ return None
867
+ x1, y1 = p1
868
+ x2, y2 = p2
869
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
870
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
871
+ return QRect(left, top, max(1, right - left), max(1, bottom - top))
872
+
873
+
874
+ def _paint_overlays_on_current_pixmap(self):
875
+ """
876
+ Draw overlays (COM crosshair and/or anchor rectangle) onto the CURRENT pixmap.
877
+ Call this at the end of _render_last().
878
+ """
879
+ pm = self.preview.pixmap()
880
+ if pm is None or pm.isNull():
881
+ return
882
+ if self._last_disp_arr is None:
883
+ return
884
+
885
+ mode = self._track_mode_value()
886
+
887
+ # Make a paintable copy
888
+ pm2 = pm.copy()
889
+ p = QPainter(pm2)
890
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
891
+
892
+ # --- Surface anchor box overlay ---
893
+ if mode == "surface" and self._surface_anchor is not None:
894
+ r = self._roi_rect_to_pixmap_rect(self._surface_anchor)
895
+ if r is not None:
896
+ pen = QPen(QColor(0, 170, 255))
897
+ pen.setWidth(3)
898
+ p.setPen(pen)
899
+ p.setBrush(QColor(0, 170, 255, 30))
900
+ p.drawRect(r)
901
+
902
+ # --- Planetary COM crosshair overlay ---
903
+ if mode == "planetary":
904
+ com = self._compute_planet_com_px(self._last_disp_arr)
905
+ if com is not None:
906
+ cx, cy = com
907
+ qpt = self._img_xy_to_pixmap_xy(cx, cy)
908
+ if qpt is not None:
909
+ px, py = qpt
910
+
911
+ pen = QPen(QColor(255, 220, 0)) # bright yellow
912
+ pen.setWidth(3)
913
+ p.setPen(pen)
914
+
915
+ # crosshair size in pixmap pixels (constant visibility)
916
+ r = 10
917
+ p.drawLine(px - r, py, px + r, py)
918
+ p.drawLine(px, py - r, px, py + r)
919
+
920
+ # small center dot
921
+ p.setBrush(QColor(255, 220, 0))
922
+ p.drawEllipse(px - 2, py - 2, 4, 4)
923
+
924
+ p.end()
925
+
926
+ self.preview.setPixmap(pm2)
927
+
928
+
929
+ def _render_last(self, anchor=None):
930
+ if self._last_qimg is None:
931
+ return
932
+
933
+ pm = QPixmap.fromImage(self._last_qimg)
934
+ if pm.isNull():
935
+ return
936
+
937
+ if self._fit_mode:
938
+ # Fit: scale pixmap to viewport, and size the label to the scaled pixmap.
939
+ vp = self.scroll.viewport().size()
940
+ if vp.width() < 5 or vp.height() < 5:
941
+ return
942
+ pm2 = pm.scaled(vp, Qt.AspectRatioMode.KeepAspectRatio,
943
+ Qt.TransformationMode.SmoothTransformation)
944
+ self.preview.setPixmap(pm2)
945
+ self.preview.resize(pm2.size()) # in fit mode, label == pixmap size
946
+ self._paint_overlays_on_current_pixmap()
947
+ return
948
+
949
+ # Manual zoom: label becomes the scaled size so scrollbars are correct/stable
950
+ w = max(1, int(pm.width() * self._zoom))
951
+ h = max(1, int(pm.height() * self._zoom))
952
+ pm2 = pm.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio,
953
+ Qt.TransformationMode.SmoothTransformation)
954
+
955
+ # Preserve current view position (anchor before/after)
956
+ # Preserve current view position (anchor before/after)
957
+ if anchor is None:
958
+ anchor = self._viewport_center_anchor()
959
+
960
+ hbar = self.scroll.horizontalScrollBar()
961
+ vbar = self.scroll.verticalScrollBar()
962
+
963
+ # old content size
964
+ old_w = max(1, self.preview.width())
965
+ old_h = max(1, self.preview.height())
966
+
967
+ # anchor point in CONTENT coords before change
968
+ ax = hbar.value() + anchor.x()
969
+ ay = vbar.value() + anchor.y()
970
+
971
+ # fractional anchor position in old content
972
+ fx = ax / old_w
973
+ fy = ay / old_h
974
+
975
+ self.preview.setPixmap(pm2)
976
+ self.preview.resize(pm2.size())
977
+
978
+ # new content size
979
+ new_w = max(1, self.preview.width())
980
+ new_h = max(1, self.preview.height())
981
+
982
+ # restore scrollbars so anchor stays put
983
+ hbar.setValue(int(fx * new_w - anchor.x()))
984
+ vbar.setValue(int(fy * new_h - anchor.y()))
985
+ self._paint_overlays_on_current_pixmap()
986
+
987
+
988
+
989
+ def _open_source(self):
990
+ start_dir = self._last_open_dir()
991
+
992
+ # Let user either:
993
+ # - pick a single SER/AVI
994
+ # - OR multi-select images for a sequence
995
+ dlg = QFileDialog(self, "Open Planetary Frames")
996
+ if start_dir:
997
+ dlg.setDirectory(start_dir)
998
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
999
+ dlg.setNameFilters([
1000
+ "Planetary Sources (*.ser *.avi *.mp4 *.mov *.mkv *.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp)",
1001
+ "SER Videos (*.ser)",
1002
+ "AVI/Video (*.avi *.mp4 *.mov *.mkv)",
1003
+ "Images (*.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp)",
1004
+ "All Files (*)",
1005
+ ])
1006
+
1007
+ if not dlg.exec():
1008
+ return
1009
+
1010
+ files = dlg.selectedFiles()
1011
+ if not files:
1012
+ return
1013
+
1014
+ # Heuristic:
1015
+ # - If exactly one file and it's .ser/.avi/etc -> open as that
1016
+ # - If multiple files -> treat as image sequence (sorted)
1017
+ files = [os.fspath(f) for f in files]
1018
+ files_sorted = sorted(files, key=lambda p: os.path.basename(p).lower())
1019
+ self._source_spec = files_sorted[0] if len(files_sorted) == 1 else files_sorted
1020
+
1021
+ try:
1022
+ if self.reader is not None:
1023
+ self.reader.close()
1024
+ except Exception:
1025
+ pass
1026
+ self.reader = None
1027
+
1028
+ try:
1029
+ if len(files_sorted) == 1:
1030
+ src = open_planetary_source(files_sorted[0], cache_items=10)
1031
+ self._set_last_open_dir(files_sorted[0])
1032
+ else:
1033
+ src = open_planetary_source(files_sorted, cache_items=10)
1034
+ self._set_last_open_dir(files_sorted[0])
1035
+
1036
+ self.reader = src
1037
+
1038
+ except Exception as e:
1039
+ QMessageBox.critical(self, "SER Viewer", f"Failed to open:\n{e}")
1040
+ self.reader = None
1041
+ return
1042
+
1043
+ m = self.reader.meta
1044
+ base = os.path.basename(m.path or (files_sorted[0] if files_sorted else ""))
1045
+
1046
+ # Nice info string
1047
+ src_kind = getattr(m, "source_kind", "unknown")
1048
+ extra = ""
1049
+ if src_kind == "sequence":
1050
+ extra = f" • sequence={m.frames}"
1051
+ elif src_kind == "avi":
1052
+ extra = f" • video={m.frames}"
1053
+ elif src_kind == "ser":
1054
+ extra = f" • frames={m.frames}"
1055
+ else:
1056
+ extra = f" • frames={m.frames}"
1057
+
1058
+ self.lbl_info.setText(
1059
+ f"<b>{base}</b><br>"
1060
+ f"{m.width}×{m.height}{extra} • depth={m.pixel_depth}-bit • format={m.color_name}"
1061
+ + (" • timestamps" if getattr(m, "has_timestamps", False) else "")
1062
+ )
1063
+
1064
+ self._cur = 0
1065
+ self.sld.setEnabled(True)
1066
+ self.sld.setRange(0, max(0, m.frames - 1))
1067
+ self.sld.setValue(0)
1068
+
1069
+ # Set ROI defaults to centered box
1070
+ cx = max(0, (m.width // 2) - 256)
1071
+ cy = max(0, (m.height // 2) - 256)
1072
+ self.spin_x.setValue(cx)
1073
+ self.spin_y.setValue(cy)
1074
+ self.spin_w.setValue(min(512, m.width))
1075
+ self.spin_h.setValue(min(512, m.height))
1076
+
1077
+ # Debayer only makes sense for SER Bayer; but leaving enabled is fine (no-op elsewhere)
1078
+ self.btn_play.setEnabled(True)
1079
+ self.btn_stack.setEnabled(True) # (see note below about stacker input)
1080
+ self._surface_anchor = None
1081
+ self._update_anchor_label()
1082
+ self.btn_play.setText("Play")
1083
+ self._playing = False
1084
+
1085
+ self._refresh()
1086
+
1087
+
1088
+ def _toggle_play(self):
1089
+ if self.reader is None:
1090
+ return
1091
+ self._playing = not self._playing
1092
+ self.btn_play.setText("Pause" if self._playing else "Play")
1093
+ if self._playing:
1094
+ self._timer.start()
1095
+ else:
1096
+ self._timer.stop()
1097
+
1098
+ def _tick_playback(self):
1099
+ if self.reader is None:
1100
+ return
1101
+ if self._cur >= self.reader.meta.frames - 1:
1102
+ self._cur = 0
1103
+ else:
1104
+ self._cur += 1
1105
+ self.sld.blockSignals(True)
1106
+ self.sld.setValue(self._cur)
1107
+ self.sld.blockSignals(False)
1108
+ self._refresh()
1109
+
1110
+ def _on_slider_changed(self, v: int):
1111
+ self._cur = int(v)
1112
+ self._refresh()
1113
+
1114
+ # ---------------- rendering ----------------
1115
+
1116
+ def _roi_tuple(self):
1117
+ if not self.chk_roi.isChecked():
1118
+ return None
1119
+ return (int(self.spin_x.value()), int(self.spin_y.value()),
1120
+ int(self.spin_w.value()), int(self.spin_h.value()))
1121
+
1122
+ def _refresh(self):
1123
+ if self.reader is None:
1124
+ return
1125
+
1126
+ m = self.reader.meta
1127
+ self.lbl_frame.setText(f"{self._cur+1} / {m.frames}")
1128
+
1129
+ roi = self._roi_tuple()
1130
+ debayer = bool(self.chk_debayer.isChecked())
1131
+
1132
+ try:
1133
+ img = self.reader.get_frame(
1134
+ self._cur,
1135
+ roi=roi,
1136
+ debayer=debayer,
1137
+ to_float01=True,
1138
+ force_rgb=False,
1139
+ bayer_pattern=self.cmb_bayer.currentText(), # ✅ NEW
1140
+ )
1141
+ except Exception as e:
1142
+ QMessageBox.warning(self, "SER Viewer", f"Frame read failed:\n{e}")
1143
+ return
1144
+
1145
+ # Autostretch preview (linked)
1146
+ if self.chk_autostretch.isChecked():
1147
+ try:
1148
+ if img.ndim == 2 and stretch_mono_image is not None:
1149
+ img = np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
1150
+ elif img.ndim == 3 and img.shape[2] == 3 and stretch_color_image is not None:
1151
+ # linked=True for planetary preview (you requested this)
1152
+ img = np.clip(stretch_color_image(img, target_median=0.25, linked=True), 0.0, 1.0)
1153
+ except Exception:
1154
+ # if stretch fails, fall back to raw preview
1155
+ pass
1156
+
1157
+ try:
1158
+ img = self._apply_preview_tone(img)
1159
+ except Exception:
1160
+ pass
1161
+
1162
+ # store for overlay calculations (ROI-sized if ROI is on)
1163
+ self._last_disp_arr = img
1164
+
1165
+ qimg = self._to_qimage(img)
1166
+ self._last_qimg = qimg
1167
+ self._render_last(anchor=self._viewport_center_anchor() if not self._fit_mode else None)
1168
+
1169
+ def resizeEvent(self, e):
1170
+ super().resizeEvent(e)
1171
+ if self._last_qimg is None:
1172
+ return
1173
+ if self._fit_mode:
1174
+ self._render_last()
1175
+
1176
+ def _to_qimage(self, arr: np.ndarray) -> QImage:
1177
+ a = np.clip(arr, 0.0, 1.0)
1178
+ if a.ndim == 2:
1179
+ u = (a * 255.0).astype(np.uint8)
1180
+ h, w = u.shape
1181
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1182
+
1183
+ if a.ndim == 3 and a.shape[2] >= 3:
1184
+ u = (a[..., :3] * 255.0).astype(np.uint8)
1185
+ h, w, _ = u.shape
1186
+ return QImage(u.data, w, h, w * 3, QImage.Format.Format_RGB888).copy()
1187
+
1188
+ raise ValueError(f"Unexpected image shape: {a.shape}")
1189
+
1190
+ def _roi_bounds(self):
1191
+ """
1192
+ Returns (rx, ry, rw, rh) in full-frame coords if ROI enabled,
1193
+ else (0,0, full_w, full_h) if we can infer it.
1194
+ """
1195
+ if self.reader is None:
1196
+ return (0, 0, 0, 0)
1197
+
1198
+ if self.chk_roi.isChecked():
1199
+ return (int(self.spin_x.value()), int(self.spin_y.value()),
1200
+ int(self.spin_w.value()), int(self.spin_h.value()))
1201
+ # ROI disabled: treat whole frame as ROI
1202
+ m = self.reader.meta
1203
+ return (0, 0, int(m.width), int(m.height))
1204
+
1205
+
1206
+ def _full_to_roi_space(self, rect_full):
1207
+ """
1208
+ rect_full: (x,y,w,h) in full-frame coords
1209
+ returns: (x,y,w,h) in ROI-space (0..rw,0..rh)
1210
+ """
1211
+ if rect_full is None:
1212
+ return None
1213
+
1214
+ fx, fy, fw, fh = rect_full
1215
+ rx, ry, rw, rh = self._roi_bounds()
1216
+
1217
+ # convert full -> roi space
1218
+ x = fx - rx
1219
+ y = fy - ry
1220
+ w = fw
1221
+ h = fh
1222
+
1223
+ # clamp to ROI-space bounds
1224
+ x = max(0, min(rw - 1, x))
1225
+ y = max(0, min(rh - 1, y))
1226
+ w = max(1, min(rw - x, w))
1227
+ h = max(1, min(rh - y, h))
1228
+ return (int(x), int(y), int(w), int(h))
1229
+
1230
+
1231
+ def get_source_path(self) -> str | None:
1232
+ return getattr(self.reader, "path", None) if self.reader is not None else None
1233
+
1234
+ def get_roi(self):
1235
+ return self._roi_tuple() # already returns (x,y,w,h) or None
1236
+
1237
+ def get_surface_anchor(self):
1238
+ return getattr(self, "_surface_anchor", None)
1239
+
1240
+ def get_source_spec(self):
1241
+ if self.reader is None:
1242
+ return None
1243
+
1244
+ m = getattr(self.reader, "meta", None)
1245
+ if m is not None:
1246
+ # ✅ If this is an image sequence, use the full file list
1247
+ fl = getattr(m, "file_list", None)
1248
+ if isinstance(fl, (list, tuple)) and len(fl) > 0:
1249
+ return list(fl)
1250
+
1251
+ # Otherwise fall back to the meta path (SER/AVI)
1252
+ p = getattr(m, "path", None)
1253
+ if isinstance(p, str) and p:
1254
+ return p
1255
+
1256
+ # Fallback
1257
+ return getattr(self.reader, "path", None)
1258
+