setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__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/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,1773 @@
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, QWidget
13
+ )
14
+
15
+ from setiastro.saspro.imageops.serloader import open_planetary_source, PlanetaryFrameSource
16
+ import threading
17
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
18
+ from PyQt6.QtWidgets import QProgressDialog
19
+
20
+
21
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
22
+ from setiastro.saspro.ser_stack_config import SERStackConfig
23
+ from setiastro.saspro.ser_stacker import stack_ser
24
+ from setiastro.saspro.ser_stacker_dialog import SERStackerDialog
25
+
26
+ # Use your stretch functions for DISPLAY
27
+ try:
28
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
29
+ except Exception:
30
+ stretch_mono_image = None
31
+ stretch_color_image = None
32
+
33
+ class _TrimExportWorker(QObject):
34
+ progress = pyqtSignal(int, int) # done, total
35
+ finished = pyqtSignal(str) # out_path
36
+ failed = pyqtSignal(str) # error text
37
+ canceled = pyqtSignal()
38
+
39
+ def __init__(
40
+ self,
41
+ src: PlanetaryFrameSource,
42
+ out_path: str,
43
+ start: int,
44
+ end: int,
45
+ *,
46
+ bayer_pattern: str | None,
47
+ store_raw_mosaic_if_forced: bool,
48
+ progress_every: int = 10,
49
+ ):
50
+ super().__init__()
51
+ self._src = src
52
+ self._out_path = out_path
53
+ self._start = int(start)
54
+ self._end = int(end)
55
+ self._bp = bayer_pattern
56
+ self._store_raw = bool(store_raw_mosaic_if_forced)
57
+ self._progress_every = int(progress_every)
58
+
59
+ self._cancel_evt = threading.Event()
60
+
61
+ def request_cancel(self) -> None:
62
+ self._cancel_evt.set()
63
+
64
+ @pyqtSlot()
65
+ def run(self) -> None:
66
+ try:
67
+ from setiastro.saspro.imageops.serloader import export_trimmed_to_ser
68
+
69
+ # Progress callback executed from worker thread.
70
+ # It emits a Qt signal (thread-safe), and checks cancel.
71
+ def _cb(done: int, total: int) -> None:
72
+ if self._cancel_evt.is_set():
73
+ # Abort export ASAP (exporter swallows callback errors,
74
+ # so we also raise a hard exception to stop loops).
75
+ raise RuntimeError("CANCELLED_BY_USER")
76
+ self.progress.emit(int(done), int(total))
77
+
78
+ export_trimmed_to_ser(
79
+ self._src,
80
+ self._out_path,
81
+ self._start,
82
+ self._end,
83
+ bayer_pattern=self._bp,
84
+ store_raw_mosaic_if_forced=self._store_raw,
85
+ progress_cb=_cb,
86
+ progress_every=self._progress_every,
87
+ )
88
+
89
+ # Ensure UI sees final state even if last callback was throttled
90
+ self.progress.emit(int(self._end - self._start + 1), int(self._end - self._start + 1))
91
+ self.finished.emit(self._out_path)
92
+
93
+ except Exception as e:
94
+ msg = str(e) if e is not None else "Unknown error"
95
+ if "CANCELLED_BY_USER" in msg:
96
+ self.canceled.emit()
97
+ else:
98
+ self.failed.emit(msg)
99
+
100
+
101
+ class SERViewer(QDialog):
102
+ """
103
+ Minimal SER viewer:
104
+ - Open SER
105
+ - Slider to scrub frames
106
+ - Play/pause
107
+ - ROI controls (x,y,w,h + enable)
108
+ - Debayer toggle (for Bayer SER)
109
+ - Linked autostretch toggle (preview only)
110
+ """
111
+
112
+ def __init__(self, parent=None):
113
+ super().__init__(parent)
114
+ self.setWindowTitle("Planetary Stacker Viewer")
115
+ self.setWindowFlag(Qt.WindowType.Window, True)
116
+ self.setWindowModality(Qt.WindowModality.NonModal)
117
+ try:
118
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
119
+ except Exception:
120
+ pass
121
+ self._panning = False
122
+ self._pan_start_pos = None # QPoint in viewport coords
123
+ self._pan_start_h = 0
124
+ self._pan_start_v = 0
125
+ self.reader: PlanetaryFrameSource | None = None
126
+ self._cur = 0
127
+ self._playing = False
128
+ self._roi_dragging = False
129
+ self._roi_start = None # QPoint (viewport coords)
130
+ self._roi_end = None # QPoint (viewport coords)
131
+ self._rubber = None
132
+ self._timer = QTimer(self)
133
+ self._timer.setInterval(33) # ~30fps scrub/play
134
+ self._timer.timeout.connect(self._tick_playback)
135
+ self._drag_mode = None # None / "roi" / "anchor"
136
+ self._surface_anchor = None # (x,y,w,h) in ROI-space
137
+ self._source_spec = None # str or list[str]
138
+ self._zoom = 1.0
139
+ self._fit_mode = True
140
+ self._last_qimg: QImage | None = None
141
+ self._last_disp_arr: np.ndarray | None = None # the float [0..1] image we displayed (after stretch + tone)
142
+ self._last_overlay = None # dict with overlay info for _render_last()
143
+
144
+ self._build_ui()
145
+
146
+
147
+ # ---------------- UI ----------------
148
+
149
+ def _build_ui(self):
150
+ # Root: left (viewer) + right (controls)
151
+ root = QHBoxLayout(self)
152
+ root.setContentsMargins(8, 8, 8, 8)
153
+ root.setSpacing(8)
154
+
155
+ # ---------- LEFT: playback + scrubber + preview + zoom ----------
156
+ left = QVBoxLayout()
157
+ left.setSpacing(8)
158
+ root.addLayout(left, 1)
159
+
160
+ # Top controls (left)
161
+ top = QHBoxLayout()
162
+ self.btn_open = QPushButton("Open SER/AVI/Frames…", self)
163
+ self.btn_play = QPushButton("Play", self)
164
+ self.btn_play.setEnabled(False)
165
+
166
+ top.addWidget(self.btn_open)
167
+ top.addWidget(self.btn_play)
168
+ top.addStretch(1)
169
+ left.addLayout(top)
170
+
171
+ self.lbl_info = QLabel("No SER loaded.", self)
172
+ self.lbl_info.setStyleSheet("color:#888;")
173
+ self.lbl_info.setWordWrap(True)
174
+ left.addWidget(self.lbl_info)
175
+
176
+ # Scrubber (left)
177
+ scrub = QHBoxLayout()
178
+ self.sld = QSlider(Qt.Orientation.Horizontal, self)
179
+ self.sld.setRange(0, 0)
180
+ self.sld.setEnabled(False)
181
+ self.lbl_frame = QLabel("0 / 0", self)
182
+ scrub.addWidget(self.sld, 1)
183
+ scrub.addWidget(self.lbl_frame, 0)
184
+ left.addLayout(scrub)
185
+
186
+ # Trim Options (right)
187
+ trim = QGroupBox("Trim", self)
188
+ tform = QFormLayout(trim)
189
+
190
+ self.spin_trim_start = QSpinBox(self)
191
+ self.spin_trim_end = QSpinBox(self)
192
+ self.spin_trim_start.setRange(0, 0)
193
+ self.spin_trim_end.setRange(0, 0)
194
+
195
+ self.btn_save_trimmed = QPushButton("Save Trimmed SER…", self)
196
+ self.btn_save_trimmed.setEnabled(False)
197
+
198
+ tform.addRow("Start frame", self.spin_trim_start)
199
+ tform.addRow("End frame", self.spin_trim_end)
200
+ tform.addRow("", self.btn_save_trimmed)
201
+
202
+
203
+
204
+
205
+ # Preview area (left)
206
+ self.scroll = QScrollArea(self)
207
+ # IMPORTANT: for sane zoom + scrollbars, do NOT let the scroll area auto-resize the widget
208
+ self.scroll.setWidgetResizable(False)
209
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
210
+ self.scroll.viewport().installEventFilter(self)
211
+ self.scroll.viewport().setMouseTracking(True)
212
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
213
+
214
+ # Rubber band for Shift+drag ROI (thick, bright green, always visible)
215
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self.scroll.viewport())
216
+ self._rubber.setStyleSheet(
217
+ "QRubberBand {"
218
+ " border: 3px solid #00ff00;"
219
+ " background: rgba(0,255,0,30);"
220
+ "}"
221
+ )
222
+ self._rubber.hide()
223
+
224
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
225
+ self.preview.setMinimumSize(640, 360)
226
+ self.scroll.setWidget(self.preview)
227
+ left.addWidget(self.scroll, 1)
228
+
229
+ # Zoom buttons (NOW under preview, centered)
230
+ zoom_row = QHBoxLayout()
231
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
232
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
233
+ self.btn_zoom_1_1 = themed_toolbtn("zoom-original", "1:1")
234
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
235
+
236
+ zoom_row.addStretch(1)
237
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_1_1, self.btn_zoom_fit):
238
+ zoom_row.addWidget(b)
239
+ zoom_row.addStretch(1)
240
+ left.addLayout(zoom_row)
241
+
242
+ # ---------- RIGHT: options + stacking ----------
243
+ right = QVBoxLayout()
244
+ right.setSpacing(8)
245
+ root.addLayout(right, 0)
246
+
247
+ # Preview Options (right)
248
+ opts = QGroupBox("Preview Options", self)
249
+ form = QFormLayout(opts)
250
+ right.addWidget(trim, 0)
251
+ self.chk_roi = QCheckBox("Use ROI (crop for preview)", self)
252
+
253
+ self.chk_debayer = QCheckBox("Debayer (Bayer SER)", self)
254
+ self.chk_debayer.setChecked(True)
255
+ self.cmb_bayer = QComboBox(self)
256
+ self.cmb_bayer.addItems(["AUTO", "RGGB", "GRBG", "GBRG", "BGGR"])
257
+ self.cmb_bayer.setCurrentText("AUTO") # ✅ default for raw mosaic AVI
258
+
259
+ self.chk_autostretch = QCheckBox("Autostretch preview (linked)", self)
260
+ self.chk_autostretch.setChecked(False)
261
+
262
+ # ROI controls
263
+ self.spin_x = QSpinBox(self); self.spin_x.setRange(0, 999999)
264
+ self.spin_y = QSpinBox(self); self.spin_y.setRange(0, 999999)
265
+ self.spin_w = QSpinBox(self); self.spin_w.setRange(1, 999999); self.spin_w.setValue(512)
266
+ self.spin_h = QSpinBox(self); self.spin_h.setRange(1, 999999); self.spin_h.setValue(512)
267
+
268
+ form.addRow("", self.chk_roi)
269
+
270
+ row1 = QHBoxLayout()
271
+ row1.setContentsMargins(0, 0, 0, 0)
272
+ row1.addWidget(QLabel("x:", self)); row1.addWidget(self.spin_x)
273
+ row1.addWidget(QLabel("y:", self)); row1.addWidget(self.spin_y)
274
+ form.addRow("ROI origin", row1)
275
+
276
+ row2 = QHBoxLayout()
277
+ row2.setContentsMargins(0, 0, 0, 0)
278
+ row2.addWidget(QLabel("w:", self)); row2.addWidget(self.spin_w)
279
+ row2.addWidget(QLabel("h:", self)); row2.addWidget(self.spin_h)
280
+ form.addRow("ROI size", row2)
281
+
282
+ form.addRow("", self.chk_debayer)
283
+ form.addRow("Bayer pattern", self.cmb_bayer)
284
+ form.addRow("", self.chk_autostretch)
285
+
286
+ # --- Preview tone controls (DISPLAY ONLY) ---
287
+ self.sld_brightness = QSlider(Qt.Orientation.Horizontal, self)
288
+ self.sld_brightness.setRange(-100, 100) # maps to -0.25 .. +0.25
289
+ self.sld_brightness.setValue(0)
290
+ self.sld_brightness.setToolTip("Preview brightness (display only)")
291
+
292
+ self.sld_gamma = QSlider(Qt.Orientation.Horizontal, self)
293
+ self.sld_gamma.setRange(30, 300) # 0.30 .. 3.00
294
+ self.sld_gamma.setValue(100) # 1.00
295
+ self.sld_gamma.setToolTip("Preview gamma (display only)")
296
+
297
+ form.addRow("Brightness", self.sld_brightness)
298
+ form.addRow("Gamma", self.sld_gamma)
299
+
300
+ right.addWidget(opts, 0)
301
+
302
+ # Stacking Options (right)
303
+ stack = QGroupBox("Stacking Options", self)
304
+ sform = QFormLayout(stack)
305
+
306
+ self.cmb_track = QComboBox(self)
307
+ self.cmb_track.addItems(["Planetary", "Surface", "Off"]) # map to config
308
+ self.cmb_track.setCurrentText("Planetary")
309
+
310
+ self.spin_keep = QDoubleSpinBox(self)
311
+ self.spin_keep.setRange(0.1, 100.0)
312
+ self.spin_keep.setDecimals(1)
313
+ self.spin_keep.setSingleStep(1.0)
314
+ self.spin_keep.setValue(20.0)
315
+
316
+ self.lbl_anchor = QLabel("Surface anchor: (not set)", self)
317
+ self.lbl_anchor.setStyleSheet("color:#888;")
318
+ self.lbl_anchor.setWordWrap(True)
319
+ self.lbl_anchor.setToolTip(
320
+ "Surface tracking needs an anchor patch.\n"
321
+ "Ctrl+Shift+drag to define it (within ROI)."
322
+ )
323
+
324
+ self.btn_stack = QPushButton("Open Stacker…", self)
325
+ self.btn_stack.setEnabled(False) # enabled once SER loaded
326
+ self.chk_planet_norm = QCheckBox("Normalize for planetary centroid detect")
327
+ self.chk_planet_norm.setChecked(True)
328
+ self.chk_planet_norm.setToolTip("Detection-only normalization (does not change stacking pixels). Helps dim planets.")
329
+
330
+ self.spin_planet_thresh = QDoubleSpinBox()
331
+ self.spin_planet_thresh.setRange(50.0, 99.9)
332
+ self.spin_planet_thresh.setDecimals(1)
333
+ self.spin_planet_thresh.setSingleStep(0.5)
334
+ self.spin_planet_thresh.setValue(92.0)
335
+ self.spin_planet_thresh.setToolTip("Percentile used to threshold the blob for centroid detection.")
336
+ self.spin_planet_min = QDoubleSpinBox()
337
+ self.spin_planet_min.setRange(0.0, 0.5) # abs floor in [0..1]
338
+ self.spin_planet_min.setDecimals(3)
339
+ self.spin_planet_min.setSingleStep(0.005)
340
+ self.spin_planet_min.setValue(0.02)
341
+ self.spin_planet_min.setToolTip(
342
+ "Minimum normalized intensity (0..1) allowed for detection thresholding.\n"
343
+ "If percentile threshold is too low on dim planets, this prevents the mask from vanishing.\n"
344
+ "Typical: 0.01–0.05."
345
+ )
346
+
347
+ self.spin_planet_smooth = QDoubleSpinBox()
348
+ self.spin_planet_smooth.setRange(0.0, 10.0)
349
+ self.spin_planet_smooth.setDecimals(2)
350
+ self.spin_planet_smooth.setSingleStep(0.25)
351
+ self.spin_planet_smooth.setValue(1.5)
352
+ self.spin_planet_smooth.setToolTip("Gaussian blur sigma used before thresholding. 1.0–2.0 is typical.")
353
+
354
+ self.spin_norm_lo = QDoubleSpinBox(); self.spin_norm_lo.setRange(0.0, 20.0); self.spin_norm_lo.setValue(1.0)
355
+ self.spin_norm_hi = QDoubleSpinBox(); self.spin_norm_hi.setRange(80.0, 100.0); self.spin_norm_hi.setValue(99.5)
356
+
357
+ # -----------------------------
358
+ # Advanced detection settings
359
+ # -----------------------------
360
+ adv = QGroupBox("Advanced detection settings", self)
361
+ adv.setCheckable(True)
362
+ adv.setChecked(False)
363
+
364
+ adv_body = QWidget(adv) # <- content container
365
+ adv_form = QFormLayout(adv_body)
366
+ adv_form.setContentsMargins(8, 8, 8, 8)
367
+ adv_form.setVerticalSpacing(6)
368
+ adv_form.setHorizontalSpacing(10)
369
+
370
+ adv_form.addRow("", self.chk_planet_norm)
371
+ adv_form.addRow("Planet detect thresh (%)", self.spin_planet_thresh)
372
+ adv_form.addRow("Norm low pct", self.spin_norm_lo)
373
+ adv_form.addRow("Norm high pct", self.spin_norm_hi)
374
+ adv_form.addRow("Planet min val", self.spin_planet_min)
375
+ adv_form.addRow("Planet smooth σ", self.spin_planet_smooth)
376
+
377
+ # Put the body into the groupbox layout
378
+ adv_layout = QVBoxLayout(adv)
379
+ adv_layout.setContentsMargins(8, 8, 8, 8)
380
+ adv_layout.addWidget(adv_body)
381
+
382
+ # show/hide only the body
383
+ adv_body.setVisible(False)
384
+ adv.toggled.connect(adv_body.setVisible)
385
+
386
+ sform.addRow("Tracking", self.cmb_track)
387
+ sform.addRow("Keep %", self.spin_keep)
388
+
389
+ # instead of adding the detection rows directly:
390
+ # sform.addRow("", self.chk_planet_norm)
391
+ # sform.addRow("Planet detect thresh (%)", self.spin_planet_thresh)
392
+ # sform.addRow("Norm low pct", self.spin_norm_lo)
393
+ # sform.addRow("Norm high pct", self.spin_norm_hi)
394
+ # sform.addRow("Planet min val", self.spin_planet_min)
395
+ # sform.addRow("Planet smooth σ", self.spin_planet_smooth)
396
+
397
+ # add the advanced groupbox as a single row spanning the form
398
+ sform.addRow(adv)
399
+
400
+ sform.addRow("", self.lbl_anchor)
401
+ sform.addRow("", self.btn_stack)
402
+
403
+ right.addWidget(stack, 0)
404
+
405
+ right.addStretch(1)
406
+
407
+ # Keep the right panel from getting too wide
408
+ for gb in (opts, stack):
409
+ gb.setMinimumWidth(360)
410
+
411
+ # ---------- Signals ----------
412
+ self.btn_open.clicked.connect(self._open_source)
413
+ self.btn_play.clicked.connect(self._toggle_play)
414
+ self.sld.valueChanged.connect(self._on_slider_changed)
415
+
416
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_step(1/1.25))
417
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_step(1.25))
418
+ self.btn_zoom_1_1.clicked.connect(lambda: self._set_zoom(1.0, anchor=self._viewport_center_anchor()))
419
+ self.btn_zoom_fit.clicked.connect(self._set_fit_mode)
420
+
421
+ for w in (self.chk_roi, self.chk_debayer, self.chk_autostretch,
422
+ self.spin_x, self.spin_y, self.spin_w, self.spin_h,
423
+ self.sld_brightness, self.sld_gamma):
424
+ if hasattr(w, "toggled"):
425
+ w.toggled.connect(self._refresh)
426
+ if hasattr(w, "valueChanged"):
427
+ w.valueChanged.connect(self._refresh)
428
+ for s in (self.spin_x, self.spin_y, self.spin_w, self.spin_h):
429
+ s.valueChanged.connect(self._sanitize_roi_controls)
430
+ self.cmb_track.currentIndexChanged.connect(self._on_track_mode_changed)
431
+ self.btn_stack.clicked.connect(self._open_stacker_clicked)
432
+ self.cmb_bayer.currentIndexChanged.connect(self._refresh)
433
+ self.chk_debayer.toggled.connect(lambda v: self.cmb_bayer.setEnabled(bool(v)))
434
+ self.cmb_bayer.setEnabled(self.chk_debayer.isChecked())
435
+ self.spin_trim_start.valueChanged.connect(self._on_trim_changed)
436
+ self.spin_trim_end.valueChanged.connect(self._on_trim_changed)
437
+ self.btn_save_trimmed.clicked.connect(self._save_trimmed_ser)
438
+
439
+ self.resize(1200, 800)
440
+
441
+
442
+ #-----qsettings
443
+ def _settings(self) -> QSettings:
444
+ # Prefer app-wide QSettings if your main window provides it
445
+ if hasattr(self.parent(), "settings"):
446
+ s = getattr(self.parent(), "settings")
447
+ if isinstance(s, QSettings):
448
+ return s
449
+ # Fallback: app-global QSettings (uses org/app set in main())
450
+ return QSettings()
451
+
452
+ def _last_open_dir(self) -> str:
453
+ s = self._settings()
454
+ return s.value("serviewer/last_open_dir", "", type=str) or ""
455
+
456
+ def _set_last_open_dir(self, path: str) -> None:
457
+ try:
458
+ d = os.path.dirname(os.path.abspath(path))
459
+ except Exception:
460
+ return
461
+ if d:
462
+ s = self._settings()
463
+ s.setValue("serviewer/last_open_dir", d)
464
+
465
+
466
+ # ---------------- actions ----------------
467
+
468
+ def _apply_preview_tone(self, img: np.ndarray) -> np.ndarray:
469
+ """
470
+ Preview-only brightness + gamma.
471
+ - brightness: adds offset in [-0.25..+0.25]
472
+ - gamma: power curve in [0.30..3.00] (1.0 = no change)
473
+ Works on mono or RGB float32 [0..1].
474
+ """
475
+ if img is None:
476
+ return img
477
+
478
+ # Brightness: -100..100 -> -0.25..+0.25
479
+ b = float(self.sld_brightness.value()) / 100.0 * 0.25
480
+
481
+ # Gamma: 30..300 -> 0.30..3.00
482
+ g = float(self.sld_gamma.value()) / 100.0
483
+ if g <= 0:
484
+ g = 1.0
485
+
486
+ out = img
487
+
488
+ if abs(b) > 1e-6:
489
+ out = np.clip(out + b, 0.0, 1.0)
490
+
491
+ if abs(g - 1.0) > 1e-6:
492
+ # gamma > 1 darkens, gamma < 1 brightens
493
+ out = np.clip(np.power(np.clip(out, 0.0, 1.0), g), 0.0, 1.0)
494
+
495
+ return out
496
+
497
+ def _viewport_center_anchor(self):
498
+ vp = self.scroll.viewport()
499
+ return vp.rect().center()
500
+
501
+ def _mouse_anchor(self):
502
+ # Anchor zoom to mouse position if mouse is over the viewport, else center.
503
+ vp = self.scroll.viewport()
504
+ p = vp.mapFromGlobal(self.cursor().pos())
505
+ if vp.rect().contains(p):
506
+ return p
507
+ return vp.rect().center()
508
+
509
+ def _set_fit_mode(self):
510
+ self._fit_mode = True
511
+ self._render_last() # rerender in fit mode
512
+
513
+ def _set_zoom(self, z: float, anchor=None):
514
+ self._fit_mode = False
515
+ self._zoom = float(max(0.05, min(20.0, z)))
516
+ self._render_last(anchor=anchor)
517
+
518
+ def _zoom_step(self, factor: float):
519
+ # Anchor zoom to mouse
520
+ anchor = self._mouse_anchor()
521
+
522
+ # If coming from fit, start from the fit zoom (prevents snapping)
523
+ self._ensure_manual_zoom_from_fit()
524
+
525
+ self._set_zoom(self._zoom * factor, anchor=anchor)
526
+
527
+ def _fit_zoom_factor(self) -> float:
528
+ """
529
+ If we are in fit mode and a pixmap is displayed, return the effective zoom
530
+ relative to the *original* frame size. This is what the user is visually seeing.
531
+ """
532
+ if self._last_qimg is None:
533
+ return 1.0
534
+
535
+ pm = self.preview.pixmap()
536
+ if pm is None or pm.isNull():
537
+ return 1.0
538
+
539
+ ow = max(1, self._last_qimg.width())
540
+ oh = max(1, self._last_qimg.height())
541
+ fw = max(1, pm.width())
542
+ fh = max(1, pm.height())
543
+
544
+ # KeepAspectRatio means either width or height matches; take the smaller ratio to be safe.
545
+ return min(fw / ow, fh / oh)
546
+
547
+ def _ensure_manual_zoom_from_fit(self):
548
+ """
549
+ If we are currently in fit mode, switch to manual zoom using the current
550
+ effective fit zoom as the starting point (prevents snapping to ~1:1).
551
+ """
552
+ if self._fit_mode:
553
+ self._zoom = self._fit_zoom_factor()
554
+ self._fit_mode = False
555
+
556
+ def _roi_rect_vp(self):
557
+ """ROI QRect in viewport coords from start/end points."""
558
+ if self._roi_start is None or self._roi_end is None:
559
+ return None
560
+ x1, y1 = self._roi_start.x(), self._roi_start.y()
561
+ x2, y2 = self._roi_end.x(), self._roi_end.y()
562
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
563
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
564
+ # enforce minimum size
565
+ if (right - left) < 4 or (bottom - top) < 4:
566
+ return None
567
+ from PyQt6.QtCore import QRect
568
+ return QRect(left, top, right - left, bottom - top)
569
+
570
+ def _viewport_rect_to_display_image(self, r_vp):
571
+ """
572
+ Convert a viewport QRect (rubberband geometry) into coords in the CURRENT DISPLAYED IMAGE.
573
+ That image is exactly self._last_qimg (ROI-sized if ROI is enabled).
574
+ Returns (x,y,w,h) in _last_qimg pixel space.
575
+ """
576
+ if self._last_qimg is None:
577
+ return None
578
+ pm = self.preview.pixmap()
579
+ if pm is None or pm.isNull():
580
+ return None
581
+
582
+ # preview widget top-left inside viewport coords
583
+ wp = self.preview.pos()
584
+ lbl_left = int(wp.x())
585
+ lbl_top = int(wp.y())
586
+
587
+ lbl_w = int(self.preview.width())
588
+ lbl_h = int(self.preview.height())
589
+ if lbl_w < 2 or lbl_h < 2:
590
+ return None
591
+
592
+ # rect corners in preview-widget coords
593
+ x1 = int(r_vp.left() - lbl_left)
594
+ y1 = int(r_vp.top() - lbl_top)
595
+ x2 = int(r_vp.right() - lbl_left)
596
+ y2 = int(r_vp.bottom() - lbl_top)
597
+
598
+ # clamp to widget bounds
599
+ x1 = max(0, min(lbl_w - 1, x1))
600
+ y1 = max(0, min(lbl_h - 1, y1))
601
+ x2 = max(0, min(lbl_w - 1, x2))
602
+ y2 = max(0, min(lbl_h - 1, y2))
603
+ if x2 <= x1 or y2 <= y1:
604
+ return None
605
+
606
+ # map widget coords -> displayed image coords (_last_qimg space)
607
+ ow = max(1, self._last_qimg.width())
608
+ oh = max(1, self._last_qimg.height())
609
+
610
+ scale_x = ow / float(lbl_w)
611
+ scale_y = oh / float(lbl_h)
612
+
613
+ ix = int(round(x1 * scale_x))
614
+ iy = int(round(y1 * scale_y))
615
+ iw = int(round((x2 - x1) * scale_x))
616
+ ih = int(round((y2 - y1) * scale_y))
617
+
618
+ # clamp to image bounds
619
+ ix = max(0, min(ow - 1, ix))
620
+ iy = max(0, min(oh - 1, iy))
621
+ iw = max(1, min(ow - ix, iw))
622
+ ih = max(1, min(oh - iy, ih))
623
+
624
+ return (ix, iy, iw, ih)
625
+
626
+
627
+ def _viewport_rect_to_image_roi(self, r_vp):
628
+ """
629
+ Convert a viewport-rect (viewport coords) into an ROI in IMAGE coords:
630
+ returns (x,y,w,h) in original frame pixel space.
631
+ Works in both fit mode and manual zoom mode, with scrollbars and centering.
632
+ """
633
+ if self._last_qimg is None:
634
+ return None
635
+ pm = self.preview.pixmap()
636
+ if pm is None or pm.isNull():
637
+ return None
638
+
639
+ # Where the preview widget actually is inside the viewport (accounts for scroll + centering)
640
+ wp = self.preview.pos() # QPoint in viewport coords
641
+ lbl_left = int(wp.x())
642
+ lbl_top = int(wp.y())
643
+
644
+ lbl_w = int(self.preview.width())
645
+ lbl_h = int(self.preview.height())
646
+ if lbl_w < 2 or lbl_h < 2:
647
+ return None
648
+
649
+ # ROI corners in widget coords
650
+ x1 = int(r_vp.left() - lbl_left)
651
+ y1 = int(r_vp.top() - lbl_top)
652
+ x2 = int(r_vp.right() - lbl_left)
653
+ y2 = int(r_vp.bottom() - lbl_top)
654
+
655
+ # Clamp to widget bounds
656
+ x1 = max(0, min(lbl_w - 1, x1))
657
+ y1 = max(0, min(lbl_h - 1, y1))
658
+ x2 = max(0, min(lbl_w - 1, x2))
659
+ y2 = max(0, min(lbl_h - 1, y2))
660
+
661
+ if x2 <= x1 or y2 <= y1:
662
+ return None
663
+
664
+ # Map widget coords -> original image coords
665
+ ow = max(1, self._last_qimg.width())
666
+ oh = max(1, self._last_qimg.height())
667
+
668
+ scale_x = ow / float(lbl_w)
669
+ scale_y = oh / float(lbl_h)
670
+
671
+ ix = int(round(x1 * scale_x))
672
+ iy = int(round(y1 * scale_y))
673
+ iw = int(round((x2 - x1) * scale_x))
674
+ ih = int(round((y2 - y1) * scale_y))
675
+
676
+ # clamp to image bounds
677
+ ix = max(0, min(ow - 1, ix))
678
+ iy = max(0, min(oh - 1, iy))
679
+ iw = max(1, min(ow - ix, iw))
680
+ ih = max(1, min(oh - iy, ih))
681
+
682
+ return (ix, iy, iw, ih)
683
+
684
+ def _update_anchor_label(self):
685
+ a = getattr(self, "_surface_anchor", None)
686
+ if a is None:
687
+ self.lbl_anchor.setText("Surface anchor: (not set) • Ctrl+Shift+drag to set")
688
+ self.lbl_anchor.setStyleSheet("color:#888;")
689
+ else:
690
+ x, y, w, h = a
691
+ self.lbl_anchor.setText(f"Surface anchor: x={x}, y={y}, w={w}, h={h} • Ctrl+Shift+drag to change")
692
+ self.lbl_anchor.setStyleSheet("color:#4a4;")
693
+
694
+ def _on_track_mode_changed(self):
695
+ mode = self._track_mode_value()
696
+
697
+ # ✅ always reflect current anchor state
698
+ self._update_anchor_label()
699
+
700
+ if mode == "surface" and self._surface_anchor is None:
701
+ self.lbl_anchor.setText("Surface anchor: REQUIRED • Ctrl+Shift+drag to set")
702
+ self.lbl_anchor.setStyleSheet("color:#c66;")
703
+
704
+ self._refresh()
705
+
706
+
707
+ def _track_mode_value(self) -> str:
708
+ t = self.cmb_track.currentText().strip().lower()
709
+ if t.startswith("planet"):
710
+ return "planetary"
711
+ if t.startswith("surface"):
712
+ return "surface"
713
+ return "off"
714
+
715
+
716
+ def eventFilter(self, obj, event):
717
+ vp = self.scroll.viewport()
718
+ try:
719
+ if obj is vp:
720
+ et = event.type()
721
+
722
+ # ---- Ctrl+Wheel zoom ----
723
+ if et == QEvent.Type.Wheel:
724
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
725
+ dy = event.angleDelta().y()
726
+ if dy != 0:
727
+ factor = 1.25 if dy > 0 else (1 / 1.25)
728
+ anchor = event.position().toPoint() # viewport coords
729
+ self._ensure_manual_zoom_from_fit()
730
+ self._set_zoom(self._zoom * factor, anchor=anchor)
731
+ event.accept()
732
+ return True
733
+ return False
734
+
735
+ # ---- Left-drag pan and ROI ----
736
+ if et == QEvent.Type.MouseButtonPress:
737
+ if event.button() == Qt.MouseButton.LeftButton:
738
+ mods = event.modifiers()
739
+
740
+ is_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
741
+ is_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
742
+
743
+ if is_shift:
744
+ # Shift+Drag = ROI, Ctrl+Shift+Drag = Anchor
745
+ self._roi_dragging = True
746
+ self._roi_start = event.position().toPoint()
747
+ self._drag_mode = "anchor" if is_ctrl else "roi"
748
+
749
+ if self._rubber is not None:
750
+ self._rubber.setGeometry(QRect(self._roi_start, QSize(1, 1)))
751
+ self._rubber.show()
752
+ self._rubber.raise_()
753
+
754
+ # Optional: different color for anchor
755
+ if self._drag_mode == "anchor":
756
+ self._rubber.setStyleSheet(
757
+ "QRubberBand { border: 3px solid #00aaff; background: rgba(0,170,255,30); }"
758
+ )
759
+ else:
760
+ self._rubber.setStyleSheet(
761
+ "QRubberBand { border: 3px solid #00ff00; background: rgba(0,255,0,30); }"
762
+ )
763
+
764
+ vp.setCursor(Qt.CursorShape.CrossCursor)
765
+ event.accept()
766
+ return True
767
+
768
+ # Normal left-drag pan
769
+ self._panning = True
770
+ self._pan_start_pos = event.position().toPoint()
771
+ self._pan_start_h = self.scroll.horizontalScrollBar().value()
772
+ self._pan_start_v = self.scroll.verticalScrollBar().value()
773
+ vp.setCursor(Qt.CursorShape.ClosedHandCursor)
774
+ event.accept()
775
+ return True
776
+ if et == QEvent.Type.MouseMove:
777
+ if self._roi_dragging and self._roi_start is not None:
778
+ cur = event.position().toPoint()
779
+ if self._rubber is not None:
780
+ self._rubber.setGeometry(QRect(self._roi_start, cur).normalized())
781
+ self._rubber.raise_()
782
+ event.accept()
783
+ return True
784
+
785
+ if self._panning and self._pan_start_pos is not None:
786
+ cur = event.position().toPoint()
787
+ delta = cur - self._pan_start_pos
788
+ hbar = self.scroll.horizontalScrollBar()
789
+ vbar = self.scroll.verticalScrollBar()
790
+ hbar.setValue(self._pan_start_h - delta.x())
791
+ vbar.setValue(self._pan_start_v - delta.y())
792
+ event.accept()
793
+ return True
794
+
795
+ if et == QEvent.Type.MouseButtonRelease:
796
+ if event.button() == Qt.MouseButton.LeftButton:
797
+
798
+ # --- finish ROI/anchor rubberband drag ---
799
+ if self._roi_dragging:
800
+ self._roi_dragging = False
801
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
802
+
803
+ r_vp = None
804
+ if self._rubber is not None:
805
+ r_vp = self._rubber.geometry()
806
+ self._rubber.hide()
807
+
808
+ self._roi_start = None
809
+
810
+ if r_vp is not None and r_vp.width() >= 4 and r_vp.height() >= 4:
811
+ rect_disp = self._viewport_rect_to_display_image(r_vp) # coords in _last_qimg space (ROI-sized if ROI enabled)
812
+ if rect_disp is not None:
813
+ if self._drag_mode == "roi":
814
+ # If ROI is already enabled, the displayed image is ROI-space.
815
+ # The user is drawing a NEW ROI inside that ROI -> convert to full-frame.
816
+ if self.chk_roi.isChecked():
817
+ rx, ry, rw, rh = self._roi_bounds()
818
+ x, y, w, h = rect_disp
819
+ x_full = int(rx + x)
820
+ y_full = int(ry + y)
821
+ w = int(w)
822
+ h = int(h)
823
+
824
+ x_full, y_full, w, h = self._even_roi(x_full, y_full, w, h)
825
+
826
+ self.spin_x.setValue(x_full)
827
+ self.spin_y.setValue(y_full)
828
+ self.spin_w.setValue(w)
829
+ self.spin_h.setValue(h)
830
+ else:
831
+ x, y, w, h = rect_disp
832
+ x, y, w, h = self._even_roi(int(x), int(y), int(w), int(h))
833
+
834
+ self.spin_x.setValue(x)
835
+ self.spin_y.setValue(y)
836
+ self.spin_w.setValue(w)
837
+ self.spin_h.setValue(h)
838
+
839
+ self.chk_roi.setChecked(True)
840
+ self._refresh()
841
+
842
+ elif self._drag_mode == "anchor":
843
+ # Anchor is ALWAYS stored in ROI-space.
844
+ # When ROI is enabled, displayed image == ROI-space, so rect_disp is already correct.
845
+ # When ROI is disabled, ROI-space == full-frame, so rect_disp is still correct.
846
+ self._surface_anchor = tuple(int(v) for v in rect_disp)
847
+ self._update_anchor_label()
848
+ self._render_last()
849
+
850
+ self._drag_mode = None
851
+ event.accept()
852
+ return True
853
+
854
+ # --- finish panning ---
855
+ if self._panning:
856
+ self._panning = False
857
+ self._pan_start_pos = None
858
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
859
+ event.accept()
860
+ return True
861
+
862
+
863
+ if et == QEvent.Type.Leave:
864
+ if self._panning or self._roi_dragging:
865
+ self._panning = False
866
+ self._roi_dragging = False
867
+ self._pan_start_pos = None
868
+ self._roi_start = None
869
+ if self._rubber is not None:
870
+ self._rubber.hide()
871
+ self._drag_mode = None
872
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
873
+ event.accept()
874
+ return True
875
+
876
+ except Exception:
877
+ pass
878
+
879
+ return super().eventFilter(obj, event)
880
+
881
+ def _open_stacker_clicked(self):
882
+ if self.reader is None:
883
+ return
884
+
885
+ source = self.get_source_spec()
886
+ if not source:
887
+ return
888
+
889
+ # Only meaningful for single-file sources; OK to pass None for sequences
890
+ ser_path = source if isinstance(source, str) else None
891
+
892
+ roi = self.get_roi()
893
+ anchor = self.get_surface_anchor()
894
+
895
+ main = self.parent() or self
896
+ current_doc = None
897
+ try:
898
+ if hasattr(main, "active_document"):
899
+ current_doc = main.active_document()
900
+ elif hasattr(main, "currentDocument"):
901
+ current_doc = main.currentDocument()
902
+ elif hasattr(main, "docman") and hasattr(main.docman, "current_document"):
903
+ current_doc = main.docman.current_document()
904
+ elif hasattr(main, "docman") and hasattr(main.docman, "current"):
905
+ current_doc = main.docman.current()
906
+ except Exception:
907
+ current_doc = None
908
+
909
+ debayer = bool(self.chk_debayer.isChecked())
910
+
911
+ # Normalize: "AUTO" means "let the loader decide"
912
+ bp = self.cmb_bayer.currentText().strip().upper()
913
+ if not debayer or bp == "AUTO":
914
+ bp = None
915
+
916
+ dlg = SERStackerDialog(
917
+ parent=self,
918
+ main=main,
919
+ source_doc=current_doc,
920
+ ser_path=ser_path,
921
+ source=source,
922
+ roi=roi,
923
+ track_mode=self._track_mode_value(),
924
+ surface_anchor=anchor,
925
+ debayer=debayer,
926
+ bayer_pattern=bp,
927
+ keep_percent=float(self.spin_keep.value()),
928
+
929
+ # ✅ planetary detect knobs
930
+ planet_min_val=float(self.spin_planet_min.value()),
931
+ planet_use_norm=bool(self.chk_planet_norm.isChecked()),
932
+ planet_norm_hi_pct=float(self.spin_norm_hi.value()), # <-- you already have this
933
+ planet_thresh_pct=float(self.spin_planet_thresh.value()),
934
+ planet_smooth_sigma=float(self.spin_planet_smooth.value()),
935
+ )
936
+
937
+ dlg.stackProduced.connect(self._on_stacker_produced)
938
+ dlg.show()
939
+ dlg.raise_()
940
+ dlg.activateWindow()
941
+
942
+ def _on_stacker_produced(self, out: np.ndarray, diag: dict):
943
+ """
944
+ Viewer should NOT decide “save vs new view” long-term.
945
+ For now, we just hand it to the parent/main if it supports a hook.
946
+ """
947
+ # Try common patterns without hard dependency:
948
+ # - main window has doc manager: main.push_array_to_new_view(...)
949
+ # - or a generic method: main.open_image_from_array(...)
950
+ main = self.parent()
951
+
952
+ # 1) Example hook you can implement in MainWindow/DocManager:
953
+ if main is not None and hasattr(main, "push_array_to_new_view"):
954
+ try:
955
+ title = f"Stacked SER ({diag.get('frames_kept')}/{diag.get('frames_total')})"
956
+ main.push_array_to_new_view(out, title=title, meta={"ser_diag": diag})
957
+ return
958
+ except Exception:
959
+ pass
960
+
961
+ # 2) Fallback: show it in this viewer preview (temporary)
962
+ try:
963
+ qimg = self._to_qimage(out)
964
+ self._last_qimg = qimg
965
+ self._fit_mode = True
966
+ self._render_last()
967
+ self.lbl_info.setText(
968
+ self.lbl_info.text()
969
+ + f"<br><b>Stacked (from stacker):</b> kept {diag.get('frames_kept')} / {diag.get('frames_total')}"
970
+ )
971
+ except Exception:
972
+ pass
973
+
974
+ def _compute_planet_com_px(self, img01: np.ndarray) -> tuple[float, float] | None:
975
+ """
976
+ Compute a quick center-of-mass in *image pixel coords* of the currently displayed image (ROI-space).
977
+ Uses a simple brightness-weighted COM with background subtraction.
978
+ """
979
+ try:
980
+ if img01 is None:
981
+ return None
982
+ if img01.ndim == 3:
983
+ # simple luma (no extra deps)
984
+ m = 0.2126 * img01[..., 0] + 0.7152 * img01[..., 1] + 0.0722 * img01[..., 2]
985
+ else:
986
+ m = img01
987
+
988
+ m = np.asarray(m, dtype=np.float32)
989
+ H, W = m.shape[:2]
990
+ if H < 2 or W < 2:
991
+ return None
992
+
993
+ # Robust-ish background subtraction to focus on the planet
994
+ bg = float(np.percentile(m, 60)) # helps ignore dark background
995
+ w = np.clip(m - bg, 0.0, None)
996
+
997
+ s = float(w.sum())
998
+ if s <= 1e-8:
999
+ return None
1000
+
1001
+ ys = np.arange(H, dtype=np.float32)[:, None]
1002
+ xs = np.arange(W, dtype=np.float32)[None, :]
1003
+ cy = float((w * ys).sum() / s)
1004
+ cx = float((w * xs).sum() / s)
1005
+ return (cx, cy)
1006
+ except Exception:
1007
+ return None
1008
+
1009
+
1010
+ def _img_xy_to_pixmap_xy(self, x: float, y: float) -> tuple[int, int] | None:
1011
+ """
1012
+ Map a point in ORIGINAL IMAGE pixel coords (of _last_qimg) into current pixmap coords.
1013
+ In this viewer, pixmap size == preview label size in both fit and manual modes.
1014
+ """
1015
+ if self._last_qimg is None:
1016
+ return None
1017
+ pm = self.preview.pixmap()
1018
+ if pm is None or pm.isNull():
1019
+ return None
1020
+
1021
+ ow = max(1, self._last_qimg.width())
1022
+ oh = max(1, self._last_qimg.height())
1023
+ pw = max(1, pm.width())
1024
+ ph = max(1, pm.height())
1025
+
1026
+ px = int(round((x / ow) * pw))
1027
+ py = int(round((y / oh) * ph))
1028
+ return (px, py)
1029
+
1030
+
1031
+ def _roi_rect_to_pixmap_rect(self, rect_roi: tuple[int, int, int, int]) -> QRect | None:
1032
+ """
1033
+ rect_roi is ROI-space (0..roi_w,0..roi_h) but the displayed image is also ROI-sized
1034
+ whenever ROI checkbox is ON (because get_frame(roi=roi) crops).
1035
+ So ROI-space == displayed image pixel space. Great.
1036
+ """
1037
+ if rect_roi is None:
1038
+ return None
1039
+
1040
+ x, y, w, h = [int(v) for v in rect_roi]
1041
+ p1 = self._img_xy_to_pixmap_xy(x, y)
1042
+ p2 = self._img_xy_to_pixmap_xy(x + w, y + h)
1043
+ if p1 is None or p2 is None:
1044
+ return None
1045
+ x1, y1 = p1
1046
+ x2, y2 = p2
1047
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
1048
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
1049
+ return QRect(left, top, max(1, right - left), max(1, bottom - top))
1050
+
1051
+
1052
+ def _paint_overlays_on_current_pixmap(self):
1053
+ """
1054
+ Draw overlays (COM crosshair and/or anchor rectangle) onto the CURRENT pixmap.
1055
+ Call this at the end of _render_last().
1056
+ """
1057
+ pm = self.preview.pixmap()
1058
+ if pm is None or pm.isNull():
1059
+ return
1060
+ if self._last_disp_arr is None:
1061
+ return
1062
+
1063
+ mode = self._track_mode_value()
1064
+
1065
+ # Make a paintable copy
1066
+ pm2 = pm.copy()
1067
+ p = QPainter(pm2)
1068
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1069
+
1070
+ # --- Surface anchor box overlay ---
1071
+ if mode == "surface" and self._surface_anchor is not None:
1072
+ r = self._roi_rect_to_pixmap_rect(self._surface_anchor)
1073
+ if r is not None:
1074
+ pen = QPen(QColor(0, 170, 255))
1075
+ pen.setWidth(3)
1076
+ p.setPen(pen)
1077
+ p.setBrush(QColor(0, 170, 255, 30))
1078
+ p.drawRect(r)
1079
+
1080
+ # --- Planetary COM crosshair overlay ---
1081
+ if mode == "planetary":
1082
+ com = self._compute_planet_com_px(self._last_disp_arr)
1083
+ if com is not None:
1084
+ cx, cy = com
1085
+ qpt = self._img_xy_to_pixmap_xy(cx, cy)
1086
+ if qpt is not None:
1087
+ px, py = qpt
1088
+
1089
+ pen = QPen(QColor(255, 220, 0)) # bright yellow
1090
+ pen.setWidth(3)
1091
+ p.setPen(pen)
1092
+
1093
+ # crosshair size in pixmap pixels (constant visibility)
1094
+ r = 10
1095
+ p.drawLine(px - r, py, px + r, py)
1096
+ p.drawLine(px, py - r, px, py + r)
1097
+
1098
+ # small center dot
1099
+ p.setBrush(QColor(255, 220, 0))
1100
+ p.drawEllipse(px - 2, py - 2, 4, 4)
1101
+
1102
+ p.end()
1103
+
1104
+ self.preview.setPixmap(pm2)
1105
+
1106
+
1107
+ def _render_last(self, anchor=None):
1108
+ if self._last_qimg is None:
1109
+ return
1110
+
1111
+ pm = QPixmap.fromImage(self._last_qimg)
1112
+ if pm.isNull():
1113
+ return
1114
+
1115
+ if self._fit_mode:
1116
+ # Fit: scale pixmap to viewport, and size the label to the scaled pixmap.
1117
+ vp = self.scroll.viewport().size()
1118
+ if vp.width() < 5 or vp.height() < 5:
1119
+ return
1120
+ pm2 = pm.scaled(vp, Qt.AspectRatioMode.KeepAspectRatio,
1121
+ Qt.TransformationMode.SmoothTransformation)
1122
+ self.preview.setPixmap(pm2)
1123
+ self.preview.resize(pm2.size()) # in fit mode, label == pixmap size
1124
+ self._paint_overlays_on_current_pixmap()
1125
+ return
1126
+
1127
+ # Manual zoom: label becomes the scaled size so scrollbars are correct/stable
1128
+ w = max(1, int(pm.width() * self._zoom))
1129
+ h = max(1, int(pm.height() * self._zoom))
1130
+ pm2 = pm.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio,
1131
+ Qt.TransformationMode.SmoothTransformation)
1132
+
1133
+ # Preserve current view position (anchor before/after)
1134
+ # Preserve current view position (anchor before/after)
1135
+ if anchor is None:
1136
+ anchor = self._viewport_center_anchor()
1137
+
1138
+ hbar = self.scroll.horizontalScrollBar()
1139
+ vbar = self.scroll.verticalScrollBar()
1140
+
1141
+ # old content size
1142
+ old_w = max(1, self.preview.width())
1143
+ old_h = max(1, self.preview.height())
1144
+
1145
+ # anchor point in CONTENT coords before change
1146
+ ax = hbar.value() + anchor.x()
1147
+ ay = vbar.value() + anchor.y()
1148
+
1149
+ # fractional anchor position in old content
1150
+ fx = ax / old_w
1151
+ fy = ay / old_h
1152
+
1153
+ self.preview.setPixmap(pm2)
1154
+ self.preview.resize(pm2.size())
1155
+
1156
+ # new content size
1157
+ new_w = max(1, self.preview.width())
1158
+ new_h = max(1, self.preview.height())
1159
+
1160
+ # restore scrollbars so anchor stays put
1161
+ hbar.setValue(int(fx * new_w - anchor.x()))
1162
+ vbar.setValue(int(fy * new_h - anchor.y()))
1163
+ self._paint_overlays_on_current_pixmap()
1164
+
1165
+
1166
+
1167
+ def _open_source(self):
1168
+ start_dir = self._last_open_dir()
1169
+
1170
+ # Let user either:
1171
+ # - pick a single SER/AVI
1172
+ # - OR multi-select images for a sequence
1173
+ dlg = QFileDialog(self, "Open Planetary Frames")
1174
+ if start_dir:
1175
+ dlg.setDirectory(start_dir)
1176
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
1177
+ dlg.setNameFilters([
1178
+ "Planetary Sources (*.ser *.avi *.mp4 *.mov *.mkv *.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp *.fit *.fits)",
1179
+ "SER Videos (*.ser)",
1180
+ "AVI/Video (*.avi *.mp4 *.mov *.mkv)",
1181
+ "Images (*.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp *.fit *.fits)",
1182
+ "All Files (*)",
1183
+ ])
1184
+
1185
+ if not dlg.exec():
1186
+ return
1187
+
1188
+ files = dlg.selectedFiles()
1189
+ if not files:
1190
+ return
1191
+
1192
+ # Heuristic:
1193
+ # - If exactly one file and it's .ser/.avi/etc -> open as that
1194
+ # - If multiple files -> treat as image sequence (sorted)
1195
+ files = [os.fspath(f) for f in files]
1196
+ files_sorted = sorted(files, key=lambda p: os.path.basename(p).lower())
1197
+ self._source_spec = files_sorted[0] if len(files_sorted) == 1 else files_sorted
1198
+
1199
+ try:
1200
+ if self.reader is not None:
1201
+ self.reader.close()
1202
+ except Exception:
1203
+ pass
1204
+ self.reader = None
1205
+
1206
+ try:
1207
+ if len(files_sorted) == 1:
1208
+ src = open_planetary_source(files_sorted[0], cache_items=10)
1209
+ self._set_last_open_dir(files_sorted[0])
1210
+ else:
1211
+ src = open_planetary_source(files_sorted, cache_items=10)
1212
+ self._set_last_open_dir(files_sorted[0])
1213
+
1214
+ self.reader = src
1215
+
1216
+ except Exception as e:
1217
+ QMessageBox.critical(self, "SER Viewer", f"Failed to open:\n{e}")
1218
+ self.reader = None
1219
+ return
1220
+
1221
+ m = self.reader.meta
1222
+ base = os.path.basename(m.path or (files_sorted[0] if files_sorted else ""))
1223
+
1224
+ # Nice info string
1225
+ src_kind = getattr(m, "source_kind", "unknown")
1226
+ extra = ""
1227
+ if src_kind == "sequence":
1228
+ extra = f" • sequence={m.frames}"
1229
+ elif src_kind == "avi":
1230
+ extra = f" • video={m.frames}"
1231
+ elif src_kind == "ser":
1232
+ extra = f" • frames={m.frames}"
1233
+ else:
1234
+ extra = f" • frames={m.frames}"
1235
+
1236
+ self.lbl_info.setText(
1237
+ f"<b>{base}</b><br>"
1238
+ f"{m.width}×{m.height}{extra} • depth={m.pixel_depth}-bit • format={m.color_name}"
1239
+ + (" • timestamps" if getattr(m, "has_timestamps", False) else "")
1240
+ )
1241
+
1242
+ self._cur = 0
1243
+ self.sld.setEnabled(True)
1244
+ self.sld.setRange(0, max(0, m.frames - 1))
1245
+ self.sld.setValue(0)
1246
+
1247
+ self.spin_trim_start.blockSignals(True)
1248
+ self.spin_trim_end.blockSignals(True)
1249
+ self.spin_trim_start.setRange(0, max(0, m.frames - 1))
1250
+ self.spin_trim_end.setRange(0, max(0, m.frames - 1))
1251
+ self.spin_trim_start.setValue(0)
1252
+ self.spin_trim_end.setValue(max(0, m.frames - 1))
1253
+ self.spin_trim_start.blockSignals(False)
1254
+ self.spin_trim_end.blockSignals(False)
1255
+
1256
+ self.btn_save_trimmed.setEnabled(m.frames > 0)
1257
+
1258
+
1259
+ # Set ROI defaults to centered box
1260
+ cx = max(0, (m.width // 2) - 256)
1261
+ cy = max(0, (m.height // 2) - 256)
1262
+ self.spin_x.setValue(cx)
1263
+ self.spin_y.setValue(cy)
1264
+ self.spin_w.setValue(min(512, m.width))
1265
+ self.spin_h.setValue(min(512, m.height))
1266
+
1267
+ # Debayer only makes sense for SER Bayer; but leaving enabled is fine (no-op elsewhere)
1268
+ self.btn_play.setEnabled(True)
1269
+ self.btn_stack.setEnabled(True) # (see note below about stacker input)
1270
+ self._surface_anchor = None
1271
+ self._update_anchor_label()
1272
+ self.btn_play.setText("Play")
1273
+ self._playing = False
1274
+
1275
+ self._refresh()
1276
+
1277
+
1278
+ def _toggle_play(self):
1279
+ if self.reader is None:
1280
+ return
1281
+ self._playing = not self._playing
1282
+ self.btn_play.setText("Pause" if self._playing else "Play")
1283
+ if self._playing:
1284
+ self._timer.start()
1285
+ else:
1286
+ self._timer.stop()
1287
+
1288
+ def _tick_playback(self):
1289
+ if self.reader is None:
1290
+ return
1291
+ if self._cur >= self.reader.meta.frames - 1:
1292
+ self._cur = 0
1293
+ else:
1294
+ self._cur += 1
1295
+ self.sld.blockSignals(True)
1296
+ self.sld.setValue(self._cur)
1297
+ self.sld.blockSignals(False)
1298
+ self._refresh()
1299
+
1300
+ def _on_slider_changed(self, v: int):
1301
+ self._cur = int(v)
1302
+ self._refresh()
1303
+
1304
+ # ---------------- rendering ----------------
1305
+ def _even_roi(self, x: int, y: int, w: int, h: int):
1306
+ """Force ROI x,y,w,h to even numbers (preserves Bayer phase)."""
1307
+ if self.reader is None:
1308
+ return x, y, w, h
1309
+
1310
+ m = self.reader.meta
1311
+ W = int(m.width)
1312
+ H = int(m.height)
1313
+
1314
+ # Clamp first
1315
+ x = max(0, min(W - 1, int(x)))
1316
+ y = max(0, min(H - 1, int(y)))
1317
+ w = max(1, int(w))
1318
+ h = max(1, int(h))
1319
+
1320
+ # Make origin even (keep Bayer phase)
1321
+ x &= ~1
1322
+ y &= ~1
1323
+
1324
+ # Make size even
1325
+ w &= ~1
1326
+ h &= ~1
1327
+ if w < 2: w = 2
1328
+ if h < 2: h = 2
1329
+
1330
+ # Fit inside image (keep evenness)
1331
+ if x + w > W:
1332
+ w = (W - x) & ~1
1333
+ if w < 2:
1334
+ x = max(0, (W - 2) & ~1)
1335
+ w = 2
1336
+ if y + h > H:
1337
+ h = (H - y) & ~1
1338
+ if h < 2:
1339
+ y = max(0, (H - 2) & ~1)
1340
+ h = 2
1341
+
1342
+ return int(x), int(y), int(w), int(h)
1343
+
1344
+ def _sanitize_roi_controls(self):
1345
+ if self.reader is None:
1346
+ return
1347
+ x = int(self.spin_x.value()); y = int(self.spin_y.value())
1348
+ w = int(self.spin_w.value()); h = int(self.spin_h.value())
1349
+ ex, ey, ew, eh = self._even_roi(x, y, w, h)
1350
+ if (ex, ey, ew, eh) != (x, y, w, h):
1351
+ self.spin_x.blockSignals(True); self.spin_y.blockSignals(True)
1352
+ self.spin_w.blockSignals(True); self.spin_h.blockSignals(True)
1353
+ self.spin_x.setValue(ex); self.spin_y.setValue(ey)
1354
+ self.spin_w.setValue(ew); self.spin_h.setValue(eh)
1355
+ self.spin_x.blockSignals(False); self.spin_y.blockSignals(False)
1356
+ self.spin_w.blockSignals(False); self.spin_h.blockSignals(False)
1357
+
1358
+ def _roi_tuple(self):
1359
+ if not self.chk_roi.isChecked():
1360
+ return None
1361
+ x, y, w, h = (int(self.spin_x.value()), int(self.spin_y.value()),
1362
+ int(self.spin_w.value()), int(self.spin_h.value()))
1363
+ return self._even_roi(x, y, w, h)
1364
+
1365
+ def _on_trim_changed(self):
1366
+ if self.reader is None:
1367
+ return
1368
+ n = max(0, int(self.reader.meta.frames) - 1)
1369
+ a = int(self.spin_trim_start.value())
1370
+ b = int(self.spin_trim_end.value())
1371
+ a = max(0, min(n, a))
1372
+ b = max(0, min(n, b))
1373
+ if a > b:
1374
+ # keep it intuitive: clamp end to start
1375
+ b = a
1376
+ self.spin_trim_end.blockSignals(True)
1377
+ self.spin_trim_end.setValue(b)
1378
+ self.spin_trim_end.blockSignals(False)
1379
+
1380
+ def _save_trimmed_ser(self):
1381
+ if self.reader is None:
1382
+ return
1383
+
1384
+ start = int(self.spin_trim_start.value())
1385
+ end = int(self.spin_trim_end.value())
1386
+ if end < start:
1387
+ end = start
1388
+
1389
+ src = self.get_source_spec()
1390
+ if isinstance(src, str) and src:
1391
+ base_dir = os.path.dirname(src)
1392
+ base_name = os.path.splitext(os.path.basename(src))[0]
1393
+ else:
1394
+ base_dir = self._last_open_dir() or os.getcwd()
1395
+ base_name = "trimmed"
1396
+
1397
+ default_path = os.path.join(base_dir, f"{base_name}_trim_{start:05d}-{end:05d}.ser")
1398
+
1399
+ out_path, _ = QFileDialog.getSaveFileName(
1400
+ self,
1401
+ "Save Trimmed SER",
1402
+ default_path,
1403
+ "SER Videos (*.ser)"
1404
+ )
1405
+ if not out_path:
1406
+ return
1407
+ if not out_path.lower().endswith(".ser"):
1408
+ out_path += ".ser"
1409
+
1410
+ # Use the user's current debayer selection to decide output format
1411
+ debayer = bool(self.chk_debayer.isChecked())
1412
+ bp = self.cmb_bayer.currentText().strip().upper()
1413
+ if (not debayer) or (bp == "AUTO"):
1414
+ bp = None # means: don't force, export RGB
1415
+ else:
1416
+ # match serloader's accepted forms: "RGGB" -> "BAYER_RGGB" etc handled there
1417
+ bp = bp # keep short name; serloader normalizes it
1418
+
1419
+ total = int(end - start + 1)
1420
+
1421
+ # Disable UI controls during export (prevents state changes mid-write)
1422
+ self.btn_save_trimmed.setEnabled(False)
1423
+ self.btn_open.setEnabled(False)
1424
+ self.btn_play.setEnabled(False)
1425
+ self.btn_stack.setEnabled(False)
1426
+
1427
+ # Progress dialog
1428
+ pd = QProgressDialog("Exporting trimmed SER…", "Cancel", 0, total, self)
1429
+ pd.setWindowTitle("Saving Trimmed SER")
1430
+ pd.setWindowModality(Qt.WindowModality.WindowModal)
1431
+ pd.setAutoClose(False)
1432
+ pd.setAutoReset(False)
1433
+ pd.setMinimumDuration(0)
1434
+ pd.setValue(0)
1435
+ pd.show()
1436
+
1437
+ # Thread + worker
1438
+ thread = QThread(self)
1439
+ worker = _TrimExportWorker(
1440
+ self.reader,
1441
+ out_path,
1442
+ start,
1443
+ end,
1444
+ bayer_pattern=bp,
1445
+ store_raw_mosaic_if_forced=True, # key: makes Bayer SER if bp is set
1446
+ progress_every=100,
1447
+ )
1448
+ worker.moveToThread(thread)
1449
+
1450
+ # Keep references so they don't get GC'd
1451
+ self._trim_thread = thread
1452
+ self._trim_worker = worker
1453
+ self._trim_progress = pd
1454
+
1455
+ # Cancel hook
1456
+ def _on_cancel():
1457
+ try:
1458
+ worker.request_cancel()
1459
+ pd.setLabelText("Canceling… (finishing current frame)")
1460
+ pd.setCancelButtonText("Canceling…")
1461
+ pd.setEnabled(False) # prevents repeated clicks
1462
+ except Exception:
1463
+ pass
1464
+ pd.canceled.connect(_on_cancel)
1465
+
1466
+ # Progress updates (runs on GUI thread)
1467
+ @pyqtSlot(int, int)
1468
+ def _on_progress(done: int, tot: int):
1469
+ try:
1470
+ pd.setMaximum(int(tot))
1471
+ pd.setValue(int(done))
1472
+ pd.setLabelText(f"Exporting trimmed SER… {done}/{tot}")
1473
+ except Exception:
1474
+ pass
1475
+ worker.progress.connect(_on_progress)
1476
+
1477
+ # Finish / fail / canceled cleanup
1478
+ def _cleanup_ui():
1479
+ try:
1480
+ pd.close()
1481
+ except Exception:
1482
+ pass
1483
+ self.btn_save_trimmed.setEnabled(True)
1484
+ self.btn_open.setEnabled(True)
1485
+ self.btn_play.setEnabled(self.reader is not None)
1486
+ self.btn_stack.setEnabled(self.reader is not None)
1487
+
1488
+ # release refs
1489
+ self._trim_progress = None
1490
+ self._trim_worker = None
1491
+ self._trim_thread = None
1492
+
1493
+ @pyqtSlot(str)
1494
+ def _on_finished(path: str):
1495
+ try:
1496
+ _cleanup_ui()
1497
+
1498
+ # Ask whether to open the newly saved SER
1499
+ resp = QMessageBox.question(
1500
+ self,
1501
+ "Trim",
1502
+ f"Saved trimmed SER:\n{path}\n\nFrames: {start}..{end} ({total})\n\nOpen it now?",
1503
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1504
+ QMessageBox.StandardButton.Yes,
1505
+ )
1506
+
1507
+ if resp == QMessageBox.StandardButton.Yes:
1508
+ # Open it immediately in the same viewer
1509
+ try:
1510
+ # Close existing source first
1511
+ if self.reader is not None:
1512
+ try:
1513
+ self.reader.close()
1514
+ except Exception:
1515
+ pass
1516
+ self.reader = None
1517
+
1518
+ # Open new file
1519
+ src = open_planetary_source(path, cache_items=10)
1520
+ self.reader = src
1521
+ self._source_spec = path
1522
+ self._set_last_open_dir(path)
1523
+
1524
+ # Update UI to match _open_source() behavior
1525
+ m = self.reader.meta
1526
+ base = os.path.basename(m.path or path)
1527
+
1528
+ src_kind = getattr(m, "source_kind", "unknown")
1529
+ if src_kind == "sequence":
1530
+ extra = f" • sequence={m.frames}"
1531
+ elif src_kind == "avi":
1532
+ extra = f" • video={m.frames}"
1533
+ else:
1534
+ extra = f" • frames={m.frames}"
1535
+
1536
+ self.lbl_info.setText(
1537
+ f"<b>{base}</b><br>"
1538
+ f"{m.width}×{m.height}{extra} • depth={m.pixel_depth}-bit • format={m.color_name}"
1539
+ + (" • timestamps" if getattr(m, "has_timestamps", False) else "")
1540
+ )
1541
+
1542
+ self._cur = 0
1543
+ self.sld.setEnabled(True)
1544
+ self.sld.setRange(0, max(0, m.frames - 1))
1545
+ self.sld.setValue(0)
1546
+
1547
+ self.spin_trim_start.blockSignals(True)
1548
+ self.spin_trim_end.blockSignals(True)
1549
+ self.spin_trim_start.setRange(0, max(0, m.frames - 1))
1550
+ self.spin_trim_end.setRange(0, max(0, m.frames - 1))
1551
+ self.spin_trim_start.setValue(0)
1552
+ self.spin_trim_end.setValue(max(0, m.frames - 1))
1553
+ self.spin_trim_start.blockSignals(False)
1554
+ self.spin_trim_end.blockSignals(False)
1555
+
1556
+ self.btn_save_trimmed.setEnabled(m.frames > 0)
1557
+
1558
+ # ROI defaults centered
1559
+ cx = max(0, (m.width // 2) - 256)
1560
+ cy = max(0, (m.height // 2) - 256)
1561
+ self.spin_x.setValue(cx)
1562
+ self.spin_y.setValue(cy)
1563
+ self.spin_w.setValue(min(512, m.width))
1564
+ self.spin_h.setValue(min(512, m.height))
1565
+
1566
+ self.btn_play.setEnabled(True)
1567
+ self.btn_stack.setEnabled(True)
1568
+ self._surface_anchor = None
1569
+ self._update_anchor_label()
1570
+ self.btn_play.setText("Play")
1571
+ self._playing = False
1572
+
1573
+ self._fit_mode = True
1574
+ self._refresh()
1575
+
1576
+ except Exception as e:
1577
+ QMessageBox.warning(self, "Trim", f"Saved, but failed to open:\n{e}")
1578
+
1579
+ else:
1580
+ # Just inform (optional; you can remove this if you prefer quieter UX)
1581
+ QMessageBox.information(
1582
+ self,
1583
+ "Trim",
1584
+ f"Saved trimmed SER:\n{path}\n\nFrames: {start}..{end} ({total})"
1585
+ )
1586
+
1587
+ finally:
1588
+ try:
1589
+ thread.quit()
1590
+ thread.wait(2000)
1591
+ except Exception:
1592
+ pass
1593
+
1594
+
1595
+ @pyqtSlot(str)
1596
+ def _on_failed(err: str):
1597
+ try:
1598
+ _cleanup_ui()
1599
+ QMessageBox.critical(self, "Trim", f"Failed to save trimmed SER:\n{err}")
1600
+ finally:
1601
+ try:
1602
+ thread.quit()
1603
+ thread.wait(2000)
1604
+ except Exception:
1605
+ pass
1606
+
1607
+ @pyqtSlot()
1608
+ def _on_canceled():
1609
+ try:
1610
+ _cleanup_ui()
1611
+ QMessageBox.information(self, "Trim", "Export canceled.")
1612
+ finally:
1613
+ try:
1614
+ thread.quit()
1615
+ thread.wait(2000)
1616
+ except Exception:
1617
+ pass
1618
+
1619
+ worker.finished.connect(_on_finished)
1620
+ worker.failed.connect(_on_failed)
1621
+ worker.canceled.connect(_on_canceled)
1622
+
1623
+ thread.started.connect(worker.run)
1624
+ thread.start()
1625
+
1626
+
1627
+ def _refresh(self):
1628
+ if self.reader is None:
1629
+ return
1630
+
1631
+ m = self.reader.meta
1632
+ self.lbl_frame.setText(f"{self._cur+1} / {m.frames}")
1633
+
1634
+ roi = self._roi_tuple()
1635
+ debayer = bool(self.chk_debayer.isChecked())
1636
+
1637
+ try:
1638
+ img = self.reader.get_frame(
1639
+ self._cur,
1640
+ roi=roi,
1641
+ debayer=debayer,
1642
+ to_float01=True,
1643
+ force_rgb=False,
1644
+ bayer_pattern=self.cmb_bayer.currentText(), # ✅ NEW
1645
+ )
1646
+ except Exception as e:
1647
+ QMessageBox.warning(self, "SER Viewer", f"Frame read failed:\n{e}")
1648
+ return
1649
+
1650
+ # Autostretch preview (linked)
1651
+ if self.chk_autostretch.isChecked():
1652
+ try:
1653
+ if img.ndim == 2 and stretch_mono_image is not None:
1654
+ img = np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
1655
+ elif img.ndim == 3 and img.shape[2] == 3 and stretch_color_image is not None:
1656
+ # linked=True for planetary preview (you requested this)
1657
+ img = np.clip(stretch_color_image(img, target_median=0.25, linked=True), 0.0, 1.0)
1658
+ except Exception:
1659
+ # if stretch fails, fall back to raw preview
1660
+ pass
1661
+
1662
+ try:
1663
+ img = self._apply_preview_tone(img)
1664
+ except Exception:
1665
+ pass
1666
+
1667
+ # store for overlay calculations (ROI-sized if ROI is on)
1668
+ self._last_disp_arr = img
1669
+
1670
+ qimg = self._to_qimage(img)
1671
+ self._last_qimg = qimg
1672
+ self._render_last(anchor=self._viewport_center_anchor() if not self._fit_mode else None)
1673
+
1674
+ def resizeEvent(self, e):
1675
+ super().resizeEvent(e)
1676
+ if self._last_qimg is None:
1677
+ return
1678
+ if self._fit_mode:
1679
+ self._render_last()
1680
+
1681
+ def _to_qimage(self, arr: np.ndarray) -> QImage:
1682
+ a = np.clip(arr, 0.0, 1.0)
1683
+
1684
+ if a.ndim == 2:
1685
+ u = (a * 255.0).astype(np.uint8, copy=False)
1686
+ # ✅ FITS/memmap correction: ensure C-contiguous rows
1687
+ if not u.flags["C_CONTIGUOUS"]:
1688
+ u = np.ascontiguousarray(u)
1689
+
1690
+ h, w = u.shape
1691
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1692
+
1693
+ if a.ndim == 3 and a.shape[2] >= 3:
1694
+ u = (a[..., :3] * 255.0).astype(np.uint8, copy=False)
1695
+ # ✅ FITS/memmap correction: ensure C-contiguous packed RGB
1696
+ if not u.flags["C_CONTIGUOUS"]:
1697
+ u = np.ascontiguousarray(u)
1698
+
1699
+ h, w, _ = u.shape
1700
+ return QImage(u.data, w, h, w * 3, QImage.Format.Format_RGB888).copy()
1701
+
1702
+ raise ValueError(f"Unexpected image shape: {a.shape}")
1703
+
1704
+
1705
+ def _roi_bounds(self):
1706
+ """
1707
+ Returns (rx, ry, rw, rh) in full-frame coords if ROI enabled,
1708
+ else (0,0, full_w, full_h) if we can infer it.
1709
+ """
1710
+ if self.reader is None:
1711
+ return (0, 0, 0, 0)
1712
+
1713
+ if self.chk_roi.isChecked():
1714
+ return (int(self.spin_x.value()), int(self.spin_y.value()),
1715
+ int(self.spin_w.value()), int(self.spin_h.value()))
1716
+ # ROI disabled: treat whole frame as ROI
1717
+ m = self.reader.meta
1718
+ return (0, 0, int(m.width), int(m.height))
1719
+
1720
+
1721
+ def _full_to_roi_space(self, rect_full):
1722
+ """
1723
+ rect_full: (x,y,w,h) in full-frame coords
1724
+ returns: (x,y,w,h) in ROI-space (0..rw,0..rh)
1725
+ """
1726
+ if rect_full is None:
1727
+ return None
1728
+
1729
+ fx, fy, fw, fh = rect_full
1730
+ rx, ry, rw, rh = self._roi_bounds()
1731
+
1732
+ # convert full -> roi space
1733
+ x = fx - rx
1734
+ y = fy - ry
1735
+ w = fw
1736
+ h = fh
1737
+
1738
+ # clamp to ROI-space bounds
1739
+ x = max(0, min(rw - 1, x))
1740
+ y = max(0, min(rh - 1, y))
1741
+ w = max(1, min(rw - x, w))
1742
+ h = max(1, min(rh - y, h))
1743
+ return (int(x), int(y), int(w), int(h))
1744
+
1745
+
1746
+ def get_source_path(self) -> str | None:
1747
+ return getattr(self.reader, "path", None) if self.reader is not None else None
1748
+
1749
+ def get_roi(self):
1750
+ return self._roi_tuple() # already returns (x,y,w,h) or None
1751
+
1752
+ def get_surface_anchor(self):
1753
+ return getattr(self, "_surface_anchor", None)
1754
+
1755
+ def get_source_spec(self):
1756
+ if self.reader is None:
1757
+ return None
1758
+
1759
+ m = getattr(self.reader, "meta", None)
1760
+ if m is not None:
1761
+ # ✅ If this is an image sequence, use the full file list
1762
+ fl = getattr(m, "file_list", None)
1763
+ if isinstance(fl, (list, tuple)) and len(fl) > 0:
1764
+ return list(fl)
1765
+
1766
+ # Otherwise fall back to the meta path (SER/AVI)
1767
+ p = getattr(m, "path", None)
1768
+ if isinstance(p, str) and p:
1769
+ return p
1770
+
1771
+ # Fallback
1772
+ return getattr(self.reader, "path", None)
1773
+