setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,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
|
+
|