setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

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