setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.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.

Potentially problematic release.


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

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,1625 @@
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
+ 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
+
327
+ sform.addRow("Tracking", self.cmb_track)
328
+ sform.addRow("Keep %", self.spin_keep)
329
+ sform.addRow("", self.lbl_anchor)
330
+ sform.addRow("", self.btn_stack)
331
+
332
+ right.addWidget(stack, 0)
333
+
334
+ right.addStretch(1)
335
+
336
+ # Keep the right panel from getting too wide
337
+ for gb in (opts, stack):
338
+ gb.setMinimumWidth(360)
339
+
340
+ # ---------- Signals ----------
341
+ self.btn_open.clicked.connect(self._open_source)
342
+ self.btn_play.clicked.connect(self._toggle_play)
343
+ self.sld.valueChanged.connect(self._on_slider_changed)
344
+
345
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_step(1/1.25))
346
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_step(1.25))
347
+ self.btn_zoom_1_1.clicked.connect(lambda: self._set_zoom(1.0, anchor=self._viewport_center_anchor()))
348
+ self.btn_zoom_fit.clicked.connect(self._set_fit_mode)
349
+
350
+ for w in (self.chk_roi, self.chk_debayer, self.chk_autostretch,
351
+ self.spin_x, self.spin_y, self.spin_w, self.spin_h,
352
+ self.sld_brightness, self.sld_gamma):
353
+ if hasattr(w, "toggled"):
354
+ w.toggled.connect(self._refresh)
355
+ if hasattr(w, "valueChanged"):
356
+ w.valueChanged.connect(self._refresh)
357
+
358
+ self.cmb_track.currentIndexChanged.connect(self._on_track_mode_changed)
359
+ self.btn_stack.clicked.connect(self._open_stacker_clicked)
360
+ self.cmb_bayer.currentIndexChanged.connect(self._refresh)
361
+ self.chk_debayer.toggled.connect(lambda v: self.cmb_bayer.setEnabled(bool(v)))
362
+ self.cmb_bayer.setEnabled(self.chk_debayer.isChecked())
363
+ self.spin_trim_start.valueChanged.connect(self._on_trim_changed)
364
+ self.spin_trim_end.valueChanged.connect(self._on_trim_changed)
365
+ self.btn_save_trimmed.clicked.connect(self._save_trimmed_ser)
366
+
367
+ self.resize(1200, 800)
368
+
369
+
370
+ #-----qsettings
371
+ def _settings(self) -> QSettings:
372
+ # Prefer app-wide QSettings if your main window provides it
373
+ if hasattr(self.parent(), "settings"):
374
+ s = getattr(self.parent(), "settings")
375
+ if isinstance(s, QSettings):
376
+ return s
377
+ # Fallback: app-global QSettings (uses org/app set in main())
378
+ return QSettings()
379
+
380
+ def _last_open_dir(self) -> str:
381
+ s = self._settings()
382
+ return s.value("serviewer/last_open_dir", "", type=str) or ""
383
+
384
+ def _set_last_open_dir(self, path: str) -> None:
385
+ try:
386
+ d = os.path.dirname(os.path.abspath(path))
387
+ except Exception:
388
+ return
389
+ if d:
390
+ s = self._settings()
391
+ s.setValue("serviewer/last_open_dir", d)
392
+
393
+
394
+ # ---------------- actions ----------------
395
+
396
+ def _apply_preview_tone(self, img: np.ndarray) -> np.ndarray:
397
+ """
398
+ Preview-only brightness + gamma.
399
+ - brightness: adds offset in [-0.25..+0.25]
400
+ - gamma: power curve in [0.30..3.00] (1.0 = no change)
401
+ Works on mono or RGB float32 [0..1].
402
+ """
403
+ if img is None:
404
+ return img
405
+
406
+ # Brightness: -100..100 -> -0.25..+0.25
407
+ b = float(self.sld_brightness.value()) / 100.0 * 0.25
408
+
409
+ # Gamma: 30..300 -> 0.30..3.00
410
+ g = float(self.sld_gamma.value()) / 100.0
411
+ if g <= 0:
412
+ g = 1.0
413
+
414
+ out = img
415
+
416
+ if abs(b) > 1e-6:
417
+ out = np.clip(out + b, 0.0, 1.0)
418
+
419
+ if abs(g - 1.0) > 1e-6:
420
+ # gamma > 1 darkens, gamma < 1 brightens
421
+ out = np.clip(np.power(np.clip(out, 0.0, 1.0), g), 0.0, 1.0)
422
+
423
+ return out
424
+
425
+ def _viewport_center_anchor(self):
426
+ vp = self.scroll.viewport()
427
+ return vp.rect().center()
428
+
429
+ def _mouse_anchor(self):
430
+ # Anchor zoom to mouse position if mouse is over the viewport, else center.
431
+ vp = self.scroll.viewport()
432
+ p = vp.mapFromGlobal(self.cursor().pos())
433
+ if vp.rect().contains(p):
434
+ return p
435
+ return vp.rect().center()
436
+
437
+ def _set_fit_mode(self):
438
+ self._fit_mode = True
439
+ self._render_last() # rerender in fit mode
440
+
441
+ def _set_zoom(self, z: float, anchor=None):
442
+ self._fit_mode = False
443
+ self._zoom = float(max(0.05, min(20.0, z)))
444
+ self._render_last(anchor=anchor)
445
+
446
+ def _zoom_step(self, factor: float):
447
+ # Anchor zoom to mouse
448
+ anchor = self._mouse_anchor()
449
+
450
+ # If coming from fit, start from the fit zoom (prevents snapping)
451
+ self._ensure_manual_zoom_from_fit()
452
+
453
+ self._set_zoom(self._zoom * factor, anchor=anchor)
454
+
455
+ def _fit_zoom_factor(self) -> float:
456
+ """
457
+ If we are in fit mode and a pixmap is displayed, return the effective zoom
458
+ relative to the *original* frame size. This is what the user is visually seeing.
459
+ """
460
+ if self._last_qimg is None:
461
+ return 1.0
462
+
463
+ pm = self.preview.pixmap()
464
+ if pm is None or pm.isNull():
465
+ return 1.0
466
+
467
+ ow = max(1, self._last_qimg.width())
468
+ oh = max(1, self._last_qimg.height())
469
+ fw = max(1, pm.width())
470
+ fh = max(1, pm.height())
471
+
472
+ # KeepAspectRatio means either width or height matches; take the smaller ratio to be safe.
473
+ return min(fw / ow, fh / oh)
474
+
475
+ def _ensure_manual_zoom_from_fit(self):
476
+ """
477
+ If we are currently in fit mode, switch to manual zoom using the current
478
+ effective fit zoom as the starting point (prevents snapping to ~1:1).
479
+ """
480
+ if self._fit_mode:
481
+ self._zoom = self._fit_zoom_factor()
482
+ self._fit_mode = False
483
+
484
+ def _roi_rect_vp(self):
485
+ """ROI QRect in viewport coords from start/end points."""
486
+ if self._roi_start is None or self._roi_end is None:
487
+ return None
488
+ x1, y1 = self._roi_start.x(), self._roi_start.y()
489
+ x2, y2 = self._roi_end.x(), self._roi_end.y()
490
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
491
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
492
+ # enforce minimum size
493
+ if (right - left) < 4 or (bottom - top) < 4:
494
+ return None
495
+ from PyQt6.QtCore import QRect
496
+ return QRect(left, top, right - left, bottom - top)
497
+
498
+ def _viewport_rect_to_display_image(self, r_vp):
499
+ """
500
+ Convert a viewport QRect (rubberband geometry) into coords in the CURRENT DISPLAYED IMAGE.
501
+ That image is exactly self._last_qimg (ROI-sized if ROI is enabled).
502
+ Returns (x,y,w,h) in _last_qimg pixel space.
503
+ """
504
+ if self._last_qimg is None:
505
+ return None
506
+ pm = self.preview.pixmap()
507
+ if pm is None or pm.isNull():
508
+ return None
509
+
510
+ # preview widget top-left inside viewport coords
511
+ wp = self.preview.pos()
512
+ lbl_left = int(wp.x())
513
+ lbl_top = int(wp.y())
514
+
515
+ lbl_w = int(self.preview.width())
516
+ lbl_h = int(self.preview.height())
517
+ if lbl_w < 2 or lbl_h < 2:
518
+ return None
519
+
520
+ # rect corners in preview-widget coords
521
+ x1 = int(r_vp.left() - lbl_left)
522
+ y1 = int(r_vp.top() - lbl_top)
523
+ x2 = int(r_vp.right() - lbl_left)
524
+ y2 = int(r_vp.bottom() - lbl_top)
525
+
526
+ # clamp to widget bounds
527
+ x1 = max(0, min(lbl_w - 1, x1))
528
+ y1 = max(0, min(lbl_h - 1, y1))
529
+ x2 = max(0, min(lbl_w - 1, x2))
530
+ y2 = max(0, min(lbl_h - 1, y2))
531
+ if x2 <= x1 or y2 <= y1:
532
+ return None
533
+
534
+ # map widget coords -> displayed image coords (_last_qimg space)
535
+ ow = max(1, self._last_qimg.width())
536
+ oh = max(1, self._last_qimg.height())
537
+
538
+ scale_x = ow / float(lbl_w)
539
+ scale_y = oh / float(lbl_h)
540
+
541
+ ix = int(round(x1 * scale_x))
542
+ iy = int(round(y1 * scale_y))
543
+ iw = int(round((x2 - x1) * scale_x))
544
+ ih = int(round((y2 - y1) * scale_y))
545
+
546
+ # clamp to image bounds
547
+ ix = max(0, min(ow - 1, ix))
548
+ iy = max(0, min(oh - 1, iy))
549
+ iw = max(1, min(ow - ix, iw))
550
+ ih = max(1, min(oh - iy, ih))
551
+
552
+ return (ix, iy, iw, ih)
553
+
554
+
555
+ def _viewport_rect_to_image_roi(self, r_vp):
556
+ """
557
+ Convert a viewport-rect (viewport coords) into an ROI in IMAGE coords:
558
+ returns (x,y,w,h) in original frame pixel space.
559
+ Works in both fit mode and manual zoom mode, with scrollbars and centering.
560
+ """
561
+ if self._last_qimg is None:
562
+ return None
563
+ pm = self.preview.pixmap()
564
+ if pm is None or pm.isNull():
565
+ return None
566
+
567
+ # Where the preview widget actually is inside the viewport (accounts for scroll + centering)
568
+ wp = self.preview.pos() # QPoint in viewport coords
569
+ lbl_left = int(wp.x())
570
+ lbl_top = int(wp.y())
571
+
572
+ lbl_w = int(self.preview.width())
573
+ lbl_h = int(self.preview.height())
574
+ if lbl_w < 2 or lbl_h < 2:
575
+ return None
576
+
577
+ # ROI corners in widget coords
578
+ x1 = int(r_vp.left() - lbl_left)
579
+ y1 = int(r_vp.top() - lbl_top)
580
+ x2 = int(r_vp.right() - lbl_left)
581
+ y2 = int(r_vp.bottom() - lbl_top)
582
+
583
+ # Clamp to widget bounds
584
+ x1 = max(0, min(lbl_w - 1, x1))
585
+ y1 = max(0, min(lbl_h - 1, y1))
586
+ x2 = max(0, min(lbl_w - 1, x2))
587
+ y2 = max(0, min(lbl_h - 1, y2))
588
+
589
+ if x2 <= x1 or y2 <= y1:
590
+ return None
591
+
592
+ # Map widget coords -> original image coords
593
+ ow = max(1, self._last_qimg.width())
594
+ oh = max(1, self._last_qimg.height())
595
+
596
+ scale_x = ow / float(lbl_w)
597
+ scale_y = oh / float(lbl_h)
598
+
599
+ ix = int(round(x1 * scale_x))
600
+ iy = int(round(y1 * scale_y))
601
+ iw = int(round((x2 - x1) * scale_x))
602
+ ih = int(round((y2 - y1) * scale_y))
603
+
604
+ # clamp to image bounds
605
+ ix = max(0, min(ow - 1, ix))
606
+ iy = max(0, min(oh - 1, iy))
607
+ iw = max(1, min(ow - ix, iw))
608
+ ih = max(1, min(oh - iy, ih))
609
+
610
+ return (ix, iy, iw, ih)
611
+
612
+ def _update_anchor_label(self):
613
+ a = getattr(self, "_surface_anchor", None)
614
+ if a is None:
615
+ self.lbl_anchor.setText("Surface anchor: (not set) • Ctrl+Shift+drag to set")
616
+ self.lbl_anchor.setStyleSheet("color:#888;")
617
+ else:
618
+ x, y, w, h = a
619
+ self.lbl_anchor.setText(f"Surface anchor: x={x}, y={y}, w={w}, h={h} • Ctrl+Shift+drag to change")
620
+ self.lbl_anchor.setStyleSheet("color:#4a4;")
621
+
622
+ def _on_track_mode_changed(self):
623
+ mode = self._track_mode_value()
624
+
625
+ # ✅ always reflect current anchor state
626
+ self._update_anchor_label()
627
+
628
+ if mode == "surface" and self._surface_anchor is None:
629
+ self.lbl_anchor.setText("Surface anchor: REQUIRED • Ctrl+Shift+drag to set")
630
+ self.lbl_anchor.setStyleSheet("color:#c66;")
631
+
632
+ self._refresh()
633
+
634
+
635
+ def _track_mode_value(self) -> str:
636
+ t = self.cmb_track.currentText().strip().lower()
637
+ if t.startswith("planet"):
638
+ return "planetary"
639
+ if t.startswith("surface"):
640
+ return "surface"
641
+ return "off"
642
+
643
+
644
+ def eventFilter(self, obj, event):
645
+ vp = self.scroll.viewport()
646
+ try:
647
+ if obj is vp:
648
+ et = event.type()
649
+
650
+ # ---- Ctrl+Wheel zoom ----
651
+ if et == QEvent.Type.Wheel:
652
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
653
+ dy = event.angleDelta().y()
654
+ if dy != 0:
655
+ factor = 1.25 if dy > 0 else (1 / 1.25)
656
+ anchor = event.position().toPoint() # viewport coords
657
+ self._ensure_manual_zoom_from_fit()
658
+ self._set_zoom(self._zoom * factor, anchor=anchor)
659
+ event.accept()
660
+ return True
661
+ return False
662
+
663
+ # ---- Left-drag pan and ROI ----
664
+ if et == QEvent.Type.MouseButtonPress:
665
+ if event.button() == Qt.MouseButton.LeftButton:
666
+ mods = event.modifiers()
667
+
668
+ is_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
669
+ is_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
670
+
671
+ if is_shift:
672
+ # Shift+Drag = ROI, Ctrl+Shift+Drag = Anchor
673
+ self._roi_dragging = True
674
+ self._roi_start = event.position().toPoint()
675
+ self._drag_mode = "anchor" if is_ctrl else "roi"
676
+
677
+ if self._rubber is not None:
678
+ self._rubber.setGeometry(QRect(self._roi_start, QSize(1, 1)))
679
+ self._rubber.show()
680
+ self._rubber.raise_()
681
+
682
+ # Optional: different color for anchor
683
+ if self._drag_mode == "anchor":
684
+ self._rubber.setStyleSheet(
685
+ "QRubberBand { border: 3px solid #00aaff; background: rgba(0,170,255,30); }"
686
+ )
687
+ else:
688
+ self._rubber.setStyleSheet(
689
+ "QRubberBand { border: 3px solid #00ff00; background: rgba(0,255,0,30); }"
690
+ )
691
+
692
+ vp.setCursor(Qt.CursorShape.CrossCursor)
693
+ event.accept()
694
+ return True
695
+
696
+ # Normal left-drag pan
697
+ self._panning = True
698
+ self._pan_start_pos = event.position().toPoint()
699
+ self._pan_start_h = self.scroll.horizontalScrollBar().value()
700
+ self._pan_start_v = self.scroll.verticalScrollBar().value()
701
+ vp.setCursor(Qt.CursorShape.ClosedHandCursor)
702
+ event.accept()
703
+ return True
704
+ if et == QEvent.Type.MouseMove:
705
+ if self._roi_dragging and self._roi_start is not None:
706
+ cur = event.position().toPoint()
707
+ if self._rubber is not None:
708
+ self._rubber.setGeometry(QRect(self._roi_start, cur).normalized())
709
+ self._rubber.raise_()
710
+ event.accept()
711
+ return True
712
+
713
+ if self._panning and self._pan_start_pos is not None:
714
+ cur = event.position().toPoint()
715
+ delta = cur - self._pan_start_pos
716
+ hbar = self.scroll.horizontalScrollBar()
717
+ vbar = self.scroll.verticalScrollBar()
718
+ hbar.setValue(self._pan_start_h - delta.x())
719
+ vbar.setValue(self._pan_start_v - delta.y())
720
+ event.accept()
721
+ return True
722
+
723
+ if et == QEvent.Type.MouseButtonRelease:
724
+ if event.button() == Qt.MouseButton.LeftButton:
725
+
726
+ # --- finish ROI/anchor rubberband drag ---
727
+ if self._roi_dragging:
728
+ self._roi_dragging = False
729
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
730
+
731
+ r_vp = None
732
+ if self._rubber is not None:
733
+ r_vp = self._rubber.geometry()
734
+ self._rubber.hide()
735
+
736
+ self._roi_start = None
737
+
738
+ if r_vp is not None and r_vp.width() >= 4 and r_vp.height() >= 4:
739
+ rect_disp = self._viewport_rect_to_display_image(r_vp) # coords in _last_qimg space (ROI-sized if ROI enabled)
740
+ if rect_disp is not None:
741
+ if self._drag_mode == "roi":
742
+ # If ROI is already enabled, the displayed image is ROI-space.
743
+ # The user is drawing a NEW ROI inside that ROI -> convert to full-frame.
744
+ if self.chk_roi.isChecked():
745
+ rx, ry, rw, rh = self._roi_bounds()
746
+ x, y, w, h = rect_disp
747
+ x_full = int(rx + x)
748
+ y_full = int(ry + y)
749
+ self.spin_x.setValue(x_full)
750
+ self.spin_y.setValue(y_full)
751
+ self.spin_w.setValue(int(w))
752
+ self.spin_h.setValue(int(h))
753
+ else:
754
+ x, y, w, h = rect_disp
755
+ self.spin_x.setValue(int(x))
756
+ self.spin_y.setValue(int(y))
757
+ self.spin_w.setValue(int(w))
758
+ self.spin_h.setValue(int(h))
759
+
760
+ self.chk_roi.setChecked(True)
761
+ self._refresh()
762
+
763
+ elif self._drag_mode == "anchor":
764
+ # Anchor is ALWAYS stored in ROI-space.
765
+ # When ROI is enabled, displayed image == ROI-space, so rect_disp is already correct.
766
+ # When ROI is disabled, ROI-space == full-frame, so rect_disp is still correct.
767
+ self._surface_anchor = tuple(int(v) for v in rect_disp)
768
+ self._update_anchor_label()
769
+ self._render_last()
770
+
771
+ self._drag_mode = None
772
+ event.accept()
773
+ return True
774
+
775
+ # --- finish panning ---
776
+ if self._panning:
777
+ self._panning = False
778
+ self._pan_start_pos = None
779
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
780
+ event.accept()
781
+ return True
782
+
783
+
784
+ if et == QEvent.Type.Leave:
785
+ if self._panning or self._roi_dragging:
786
+ self._panning = False
787
+ self._roi_dragging = False
788
+ self._pan_start_pos = None
789
+ self._roi_start = None
790
+ if self._rubber is not None:
791
+ self._rubber.hide()
792
+ self._drag_mode = None
793
+ vp.setCursor(Qt.CursorShape.ArrowCursor)
794
+ event.accept()
795
+ return True
796
+
797
+ except Exception:
798
+ pass
799
+
800
+ return super().eventFilter(obj, event)
801
+
802
+ def _open_stacker_clicked(self):
803
+ if self.reader is None:
804
+ return
805
+
806
+ source = self.get_source_spec()
807
+ if not source:
808
+ return
809
+
810
+ # Only meaningful for single-file sources; OK to pass None for sequences
811
+ ser_path = source if isinstance(source, str) else None
812
+
813
+ roi = self.get_roi()
814
+ anchor = self.get_surface_anchor()
815
+
816
+ main = self.parent() or self
817
+ current_doc = None
818
+ try:
819
+ if hasattr(main, "active_document"):
820
+ current_doc = main.active_document()
821
+ elif hasattr(main, "currentDocument"):
822
+ current_doc = main.currentDocument()
823
+ elif hasattr(main, "docman") and hasattr(main.docman, "current_document"):
824
+ current_doc = main.docman.current_document()
825
+ elif hasattr(main, "docman") and hasattr(main.docman, "current"):
826
+ current_doc = main.docman.current()
827
+ except Exception:
828
+ current_doc = None
829
+
830
+ debayer = bool(self.chk_debayer.isChecked())
831
+
832
+ # Normalize: "AUTO" means "let the loader decide"
833
+ bp = self.cmb_bayer.currentText().strip().upper()
834
+ if not debayer or bp == "AUTO":
835
+ bp = None
836
+
837
+ dlg = SERStackerDialog(
838
+ parent=self,
839
+ main=main,
840
+ source_doc=current_doc,
841
+ ser_path=ser_path,
842
+ source=source,
843
+ roi=roi,
844
+ track_mode=self._track_mode_value(),
845
+ surface_anchor=anchor,
846
+ debayer=debayer,
847
+ bayer_pattern=bp, # ✅ THIS IS THE FIX
848
+ keep_percent=float(self.spin_keep.value()),
849
+ )
850
+
851
+
852
+ dlg.stackProduced.connect(self._on_stacker_produced)
853
+ dlg.show()
854
+ dlg.raise_()
855
+ dlg.activateWindow()
856
+
857
+ def _on_stacker_produced(self, out: np.ndarray, diag: dict):
858
+ """
859
+ Viewer should NOT decide “save vs new view” long-term.
860
+ For now, we just hand it to the parent/main if it supports a hook.
861
+ """
862
+ # Try common patterns without hard dependency:
863
+ # - main window has doc manager: main.push_array_to_new_view(...)
864
+ # - or a generic method: main.open_image_from_array(...)
865
+ main = self.parent()
866
+
867
+ # 1) Example hook you can implement in MainWindow/DocManager:
868
+ if main is not None and hasattr(main, "push_array_to_new_view"):
869
+ try:
870
+ title = f"Stacked SER ({diag.get('frames_kept')}/{diag.get('frames_total')})"
871
+ main.push_array_to_new_view(out, title=title, meta={"ser_diag": diag})
872
+ return
873
+ except Exception:
874
+ pass
875
+
876
+ # 2) Fallback: show it in this viewer preview (temporary)
877
+ try:
878
+ qimg = self._to_qimage(out)
879
+ self._last_qimg = qimg
880
+ self._fit_mode = True
881
+ self._render_last()
882
+ self.lbl_info.setText(
883
+ self.lbl_info.text()
884
+ + f"<br><b>Stacked (from stacker):</b> kept {diag.get('frames_kept')} / {diag.get('frames_total')}"
885
+ )
886
+ except Exception:
887
+ pass
888
+
889
+ def _compute_planet_com_px(self, img01: np.ndarray) -> tuple[float, float] | None:
890
+ """
891
+ Compute a quick center-of-mass in *image pixel coords* of the currently displayed image (ROI-space).
892
+ Uses a simple brightness-weighted COM with background subtraction.
893
+ """
894
+ try:
895
+ if img01 is None:
896
+ return None
897
+ if img01.ndim == 3:
898
+ # simple luma (no extra deps)
899
+ m = 0.2126 * img01[..., 0] + 0.7152 * img01[..., 1] + 0.0722 * img01[..., 2]
900
+ else:
901
+ m = img01
902
+
903
+ m = np.asarray(m, dtype=np.float32)
904
+ H, W = m.shape[:2]
905
+ if H < 2 or W < 2:
906
+ return None
907
+
908
+ # Robust-ish background subtraction to focus on the planet
909
+ bg = float(np.percentile(m, 60)) # helps ignore dark background
910
+ w = np.clip(m - bg, 0.0, None)
911
+
912
+ s = float(w.sum())
913
+ if s <= 1e-8:
914
+ return None
915
+
916
+ ys = np.arange(H, dtype=np.float32)[:, None]
917
+ xs = np.arange(W, dtype=np.float32)[None, :]
918
+ cy = float((w * ys).sum() / s)
919
+ cx = float((w * xs).sum() / s)
920
+ return (cx, cy)
921
+ except Exception:
922
+ return None
923
+
924
+
925
+ def _img_xy_to_pixmap_xy(self, x: float, y: float) -> tuple[int, int] | None:
926
+ """
927
+ Map a point in ORIGINAL IMAGE pixel coords (of _last_qimg) into current pixmap coords.
928
+ In this viewer, pixmap size == preview label size in both fit and manual modes.
929
+ """
930
+ if self._last_qimg is None:
931
+ return None
932
+ pm = self.preview.pixmap()
933
+ if pm is None or pm.isNull():
934
+ return None
935
+
936
+ ow = max(1, self._last_qimg.width())
937
+ oh = max(1, self._last_qimg.height())
938
+ pw = max(1, pm.width())
939
+ ph = max(1, pm.height())
940
+
941
+ px = int(round((x / ow) * pw))
942
+ py = int(round((y / oh) * ph))
943
+ return (px, py)
944
+
945
+
946
+ def _roi_rect_to_pixmap_rect(self, rect_roi: tuple[int, int, int, int]) -> QRect | None:
947
+ """
948
+ rect_roi is ROI-space (0..roi_w,0..roi_h) but the displayed image is also ROI-sized
949
+ whenever ROI checkbox is ON (because get_frame(roi=roi) crops).
950
+ So ROI-space == displayed image pixel space. Great.
951
+ """
952
+ if rect_roi is None:
953
+ return None
954
+
955
+ x, y, w, h = [int(v) for v in rect_roi]
956
+ p1 = self._img_xy_to_pixmap_xy(x, y)
957
+ p2 = self._img_xy_to_pixmap_xy(x + w, y + h)
958
+ if p1 is None or p2 is None:
959
+ return None
960
+ x1, y1 = p1
961
+ x2, y2 = p2
962
+ left, right = (x1, x2) if x1 <= x2 else (x2, x1)
963
+ top, bottom = (y1, y2) if y1 <= y2 else (y2, y1)
964
+ return QRect(left, top, max(1, right - left), max(1, bottom - top))
965
+
966
+
967
+ def _paint_overlays_on_current_pixmap(self):
968
+ """
969
+ Draw overlays (COM crosshair and/or anchor rectangle) onto the CURRENT pixmap.
970
+ Call this at the end of _render_last().
971
+ """
972
+ pm = self.preview.pixmap()
973
+ if pm is None or pm.isNull():
974
+ return
975
+ if self._last_disp_arr is None:
976
+ return
977
+
978
+ mode = self._track_mode_value()
979
+
980
+ # Make a paintable copy
981
+ pm2 = pm.copy()
982
+ p = QPainter(pm2)
983
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
984
+
985
+ # --- Surface anchor box overlay ---
986
+ if mode == "surface" and self._surface_anchor is not None:
987
+ r = self._roi_rect_to_pixmap_rect(self._surface_anchor)
988
+ if r is not None:
989
+ pen = QPen(QColor(0, 170, 255))
990
+ pen.setWidth(3)
991
+ p.setPen(pen)
992
+ p.setBrush(QColor(0, 170, 255, 30))
993
+ p.drawRect(r)
994
+
995
+ # --- Planetary COM crosshair overlay ---
996
+ if mode == "planetary":
997
+ com = self._compute_planet_com_px(self._last_disp_arr)
998
+ if com is not None:
999
+ cx, cy = com
1000
+ qpt = self._img_xy_to_pixmap_xy(cx, cy)
1001
+ if qpt is not None:
1002
+ px, py = qpt
1003
+
1004
+ pen = QPen(QColor(255, 220, 0)) # bright yellow
1005
+ pen.setWidth(3)
1006
+ p.setPen(pen)
1007
+
1008
+ # crosshair size in pixmap pixels (constant visibility)
1009
+ r = 10
1010
+ p.drawLine(px - r, py, px + r, py)
1011
+ p.drawLine(px, py - r, px, py + r)
1012
+
1013
+ # small center dot
1014
+ p.setBrush(QColor(255, 220, 0))
1015
+ p.drawEllipse(px - 2, py - 2, 4, 4)
1016
+
1017
+ p.end()
1018
+
1019
+ self.preview.setPixmap(pm2)
1020
+
1021
+
1022
+ def _render_last(self, anchor=None):
1023
+ if self._last_qimg is None:
1024
+ return
1025
+
1026
+ pm = QPixmap.fromImage(self._last_qimg)
1027
+ if pm.isNull():
1028
+ return
1029
+
1030
+ if self._fit_mode:
1031
+ # Fit: scale pixmap to viewport, and size the label to the scaled pixmap.
1032
+ vp = self.scroll.viewport().size()
1033
+ if vp.width() < 5 or vp.height() < 5:
1034
+ return
1035
+ pm2 = pm.scaled(vp, Qt.AspectRatioMode.KeepAspectRatio,
1036
+ Qt.TransformationMode.SmoothTransformation)
1037
+ self.preview.setPixmap(pm2)
1038
+ self.preview.resize(pm2.size()) # in fit mode, label == pixmap size
1039
+ self._paint_overlays_on_current_pixmap()
1040
+ return
1041
+
1042
+ # Manual zoom: label becomes the scaled size so scrollbars are correct/stable
1043
+ w = max(1, int(pm.width() * self._zoom))
1044
+ h = max(1, int(pm.height() * self._zoom))
1045
+ pm2 = pm.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio,
1046
+ Qt.TransformationMode.SmoothTransformation)
1047
+
1048
+ # Preserve current view position (anchor before/after)
1049
+ # Preserve current view position (anchor before/after)
1050
+ if anchor is None:
1051
+ anchor = self._viewport_center_anchor()
1052
+
1053
+ hbar = self.scroll.horizontalScrollBar()
1054
+ vbar = self.scroll.verticalScrollBar()
1055
+
1056
+ # old content size
1057
+ old_w = max(1, self.preview.width())
1058
+ old_h = max(1, self.preview.height())
1059
+
1060
+ # anchor point in CONTENT coords before change
1061
+ ax = hbar.value() + anchor.x()
1062
+ ay = vbar.value() + anchor.y()
1063
+
1064
+ # fractional anchor position in old content
1065
+ fx = ax / old_w
1066
+ fy = ay / old_h
1067
+
1068
+ self.preview.setPixmap(pm2)
1069
+ self.preview.resize(pm2.size())
1070
+
1071
+ # new content size
1072
+ new_w = max(1, self.preview.width())
1073
+ new_h = max(1, self.preview.height())
1074
+
1075
+ # restore scrollbars so anchor stays put
1076
+ hbar.setValue(int(fx * new_w - anchor.x()))
1077
+ vbar.setValue(int(fy * new_h - anchor.y()))
1078
+ self._paint_overlays_on_current_pixmap()
1079
+
1080
+
1081
+
1082
+ def _open_source(self):
1083
+ start_dir = self._last_open_dir()
1084
+
1085
+ # Let user either:
1086
+ # - pick a single SER/AVI
1087
+ # - OR multi-select images for a sequence
1088
+ dlg = QFileDialog(self, "Open Planetary Frames")
1089
+ if start_dir:
1090
+ dlg.setDirectory(start_dir)
1091
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
1092
+ dlg.setNameFilters([
1093
+ "Planetary Sources (*.ser *.avi *.mp4 *.mov *.mkv *.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp)",
1094
+ "SER Videos (*.ser)",
1095
+ "AVI/Video (*.avi *.mp4 *.mov *.mkv)",
1096
+ "Images (*.png *.tif *.tiff *.jpg *.jpeg *.bmp *.webp)",
1097
+ "All Files (*)",
1098
+ ])
1099
+
1100
+ if not dlg.exec():
1101
+ return
1102
+
1103
+ files = dlg.selectedFiles()
1104
+ if not files:
1105
+ return
1106
+
1107
+ # Heuristic:
1108
+ # - If exactly one file and it's .ser/.avi/etc -> open as that
1109
+ # - If multiple files -> treat as image sequence (sorted)
1110
+ files = [os.fspath(f) for f in files]
1111
+ files_sorted = sorted(files, key=lambda p: os.path.basename(p).lower())
1112
+ self._source_spec = files_sorted[0] if len(files_sorted) == 1 else files_sorted
1113
+
1114
+ try:
1115
+ if self.reader is not None:
1116
+ self.reader.close()
1117
+ except Exception:
1118
+ pass
1119
+ self.reader = None
1120
+
1121
+ try:
1122
+ if len(files_sorted) == 1:
1123
+ src = open_planetary_source(files_sorted[0], cache_items=10)
1124
+ self._set_last_open_dir(files_sorted[0])
1125
+ else:
1126
+ src = open_planetary_source(files_sorted, cache_items=10)
1127
+ self._set_last_open_dir(files_sorted[0])
1128
+
1129
+ self.reader = src
1130
+
1131
+ except Exception as e:
1132
+ QMessageBox.critical(self, "SER Viewer", f"Failed to open:\n{e}")
1133
+ self.reader = None
1134
+ return
1135
+
1136
+ m = self.reader.meta
1137
+ base = os.path.basename(m.path or (files_sorted[0] if files_sorted else ""))
1138
+
1139
+ # Nice info string
1140
+ src_kind = getattr(m, "source_kind", "unknown")
1141
+ extra = ""
1142
+ if src_kind == "sequence":
1143
+ extra = f" • sequence={m.frames}"
1144
+ elif src_kind == "avi":
1145
+ extra = f" • video={m.frames}"
1146
+ elif src_kind == "ser":
1147
+ extra = f" • frames={m.frames}"
1148
+ else:
1149
+ extra = f" • frames={m.frames}"
1150
+
1151
+ self.lbl_info.setText(
1152
+ f"<b>{base}</b><br>"
1153
+ f"{m.width}×{m.height}{extra} • depth={m.pixel_depth}-bit • format={m.color_name}"
1154
+ + (" • timestamps" if getattr(m, "has_timestamps", False) else "")
1155
+ )
1156
+
1157
+ self._cur = 0
1158
+ self.sld.setEnabled(True)
1159
+ self.sld.setRange(0, max(0, m.frames - 1))
1160
+ self.sld.setValue(0)
1161
+
1162
+ self.spin_trim_start.blockSignals(True)
1163
+ self.spin_trim_end.blockSignals(True)
1164
+ self.spin_trim_start.setRange(0, max(0, m.frames - 1))
1165
+ self.spin_trim_end.setRange(0, max(0, m.frames - 1))
1166
+ self.spin_trim_start.setValue(0)
1167
+ self.spin_trim_end.setValue(max(0, m.frames - 1))
1168
+ self.spin_trim_start.blockSignals(False)
1169
+ self.spin_trim_end.blockSignals(False)
1170
+
1171
+ self.btn_save_trimmed.setEnabled(m.frames > 0)
1172
+
1173
+
1174
+ # Set ROI defaults to centered box
1175
+ cx = max(0, (m.width // 2) - 256)
1176
+ cy = max(0, (m.height // 2) - 256)
1177
+ self.spin_x.setValue(cx)
1178
+ self.spin_y.setValue(cy)
1179
+ self.spin_w.setValue(min(512, m.width))
1180
+ self.spin_h.setValue(min(512, m.height))
1181
+
1182
+ # Debayer only makes sense for SER Bayer; but leaving enabled is fine (no-op elsewhere)
1183
+ self.btn_play.setEnabled(True)
1184
+ self.btn_stack.setEnabled(True) # (see note below about stacker input)
1185
+ self._surface_anchor = None
1186
+ self._update_anchor_label()
1187
+ self.btn_play.setText("Play")
1188
+ self._playing = False
1189
+
1190
+ self._refresh()
1191
+
1192
+
1193
+ def _toggle_play(self):
1194
+ if self.reader is None:
1195
+ return
1196
+ self._playing = not self._playing
1197
+ self.btn_play.setText("Pause" if self._playing else "Play")
1198
+ if self._playing:
1199
+ self._timer.start()
1200
+ else:
1201
+ self._timer.stop()
1202
+
1203
+ def _tick_playback(self):
1204
+ if self.reader is None:
1205
+ return
1206
+ if self._cur >= self.reader.meta.frames - 1:
1207
+ self._cur = 0
1208
+ else:
1209
+ self._cur += 1
1210
+ self.sld.blockSignals(True)
1211
+ self.sld.setValue(self._cur)
1212
+ self.sld.blockSignals(False)
1213
+ self._refresh()
1214
+
1215
+ def _on_slider_changed(self, v: int):
1216
+ self._cur = int(v)
1217
+ self._refresh()
1218
+
1219
+ # ---------------- rendering ----------------
1220
+
1221
+ def _roi_tuple(self):
1222
+ if not self.chk_roi.isChecked():
1223
+ return None
1224
+ return (int(self.spin_x.value()), int(self.spin_y.value()),
1225
+ int(self.spin_w.value()), int(self.spin_h.value()))
1226
+
1227
+ def _on_trim_changed(self):
1228
+ if self.reader is None:
1229
+ return
1230
+ n = max(0, int(self.reader.meta.frames) - 1)
1231
+ a = int(self.spin_trim_start.value())
1232
+ b = int(self.spin_trim_end.value())
1233
+ a = max(0, min(n, a))
1234
+ b = max(0, min(n, b))
1235
+ if a > b:
1236
+ # keep it intuitive: clamp end to start
1237
+ b = a
1238
+ self.spin_trim_end.blockSignals(True)
1239
+ self.spin_trim_end.setValue(b)
1240
+ self.spin_trim_end.blockSignals(False)
1241
+
1242
+ def _save_trimmed_ser(self):
1243
+ if self.reader is None:
1244
+ return
1245
+
1246
+ start = int(self.spin_trim_start.value())
1247
+ end = int(self.spin_trim_end.value())
1248
+ if end < start:
1249
+ end = start
1250
+
1251
+ src = self.get_source_spec()
1252
+ if isinstance(src, str) and src:
1253
+ base_dir = os.path.dirname(src)
1254
+ base_name = os.path.splitext(os.path.basename(src))[0]
1255
+ else:
1256
+ base_dir = self._last_open_dir() or os.getcwd()
1257
+ base_name = "trimmed"
1258
+
1259
+ default_path = os.path.join(base_dir, f"{base_name}_trim_{start:05d}-{end:05d}.ser")
1260
+
1261
+ out_path, _ = QFileDialog.getSaveFileName(
1262
+ self,
1263
+ "Save Trimmed SER",
1264
+ default_path,
1265
+ "SER Videos (*.ser)"
1266
+ )
1267
+ if not out_path:
1268
+ return
1269
+ if not out_path.lower().endswith(".ser"):
1270
+ out_path += ".ser"
1271
+
1272
+ # Use the user's current debayer selection to decide output format
1273
+ debayer = bool(self.chk_debayer.isChecked())
1274
+ bp = self.cmb_bayer.currentText().strip().upper()
1275
+ if (not debayer) or (bp == "AUTO"):
1276
+ bp = None # means: don't force, export RGB
1277
+ else:
1278
+ # match serloader's accepted forms: "RGGB" -> "BAYER_RGGB" etc handled there
1279
+ bp = bp # keep short name; serloader normalizes it
1280
+
1281
+ total = int(end - start + 1)
1282
+
1283
+ # Disable UI controls during export (prevents state changes mid-write)
1284
+ self.btn_save_trimmed.setEnabled(False)
1285
+ self.btn_open.setEnabled(False)
1286
+ self.btn_play.setEnabled(False)
1287
+ self.btn_stack.setEnabled(False)
1288
+
1289
+ # Progress dialog
1290
+ pd = QProgressDialog("Exporting trimmed SER…", "Cancel", 0, total, self)
1291
+ pd.setWindowTitle("Saving Trimmed SER")
1292
+ pd.setWindowModality(Qt.WindowModality.WindowModal)
1293
+ pd.setAutoClose(False)
1294
+ pd.setAutoReset(False)
1295
+ pd.setMinimumDuration(0)
1296
+ pd.setValue(0)
1297
+ pd.show()
1298
+
1299
+ # Thread + worker
1300
+ thread = QThread(self)
1301
+ worker = _TrimExportWorker(
1302
+ self.reader,
1303
+ out_path,
1304
+ start,
1305
+ end,
1306
+ bayer_pattern=bp,
1307
+ store_raw_mosaic_if_forced=True, # key: makes Bayer SER if bp is set
1308
+ progress_every=100,
1309
+ )
1310
+ worker.moveToThread(thread)
1311
+
1312
+ # Keep references so they don't get GC'd
1313
+ self._trim_thread = thread
1314
+ self._trim_worker = worker
1315
+ self._trim_progress = pd
1316
+
1317
+ # Cancel hook
1318
+ def _on_cancel():
1319
+ try:
1320
+ worker.request_cancel()
1321
+ pd.setLabelText("Canceling… (finishing current frame)")
1322
+ pd.setCancelButtonText("Canceling…")
1323
+ pd.setEnabled(False) # prevents repeated clicks
1324
+ except Exception:
1325
+ pass
1326
+ pd.canceled.connect(_on_cancel)
1327
+
1328
+ # Progress updates (runs on GUI thread)
1329
+ @pyqtSlot(int, int)
1330
+ def _on_progress(done: int, tot: int):
1331
+ try:
1332
+ pd.setMaximum(int(tot))
1333
+ pd.setValue(int(done))
1334
+ pd.setLabelText(f"Exporting trimmed SER… {done}/{tot}")
1335
+ except Exception:
1336
+ pass
1337
+ worker.progress.connect(_on_progress)
1338
+
1339
+ # Finish / fail / canceled cleanup
1340
+ def _cleanup_ui():
1341
+ try:
1342
+ pd.close()
1343
+ except Exception:
1344
+ pass
1345
+ self.btn_save_trimmed.setEnabled(True)
1346
+ self.btn_open.setEnabled(True)
1347
+ self.btn_play.setEnabled(self.reader is not None)
1348
+ self.btn_stack.setEnabled(self.reader is not None)
1349
+
1350
+ # release refs
1351
+ self._trim_progress = None
1352
+ self._trim_worker = None
1353
+ self._trim_thread = None
1354
+
1355
+ @pyqtSlot(str)
1356
+ def _on_finished(path: str):
1357
+ try:
1358
+ _cleanup_ui()
1359
+
1360
+ # Ask whether to open the newly saved SER
1361
+ resp = QMessageBox.question(
1362
+ self,
1363
+ "Trim",
1364
+ f"Saved trimmed SER:\n{path}\n\nFrames: {start}..{end} ({total})\n\nOpen it now?",
1365
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1366
+ QMessageBox.StandardButton.Yes,
1367
+ )
1368
+
1369
+ if resp == QMessageBox.StandardButton.Yes:
1370
+ # Open it immediately in the same viewer
1371
+ try:
1372
+ # Close existing source first
1373
+ if self.reader is not None:
1374
+ try:
1375
+ self.reader.close()
1376
+ except Exception:
1377
+ pass
1378
+ self.reader = None
1379
+
1380
+ # Open new file
1381
+ src = open_planetary_source(path, cache_items=10)
1382
+ self.reader = src
1383
+ self._source_spec = path
1384
+ self._set_last_open_dir(path)
1385
+
1386
+ # Update UI to match _open_source() behavior
1387
+ m = self.reader.meta
1388
+ base = os.path.basename(m.path or path)
1389
+
1390
+ src_kind = getattr(m, "source_kind", "unknown")
1391
+ if src_kind == "sequence":
1392
+ extra = f" • sequence={m.frames}"
1393
+ elif src_kind == "avi":
1394
+ extra = f" • video={m.frames}"
1395
+ else:
1396
+ extra = f" • frames={m.frames}"
1397
+
1398
+ self.lbl_info.setText(
1399
+ f"<b>{base}</b><br>"
1400
+ f"{m.width}×{m.height}{extra} • depth={m.pixel_depth}-bit • format={m.color_name}"
1401
+ + (" • timestamps" if getattr(m, "has_timestamps", False) else "")
1402
+ )
1403
+
1404
+ self._cur = 0
1405
+ self.sld.setEnabled(True)
1406
+ self.sld.setRange(0, max(0, m.frames - 1))
1407
+ self.sld.setValue(0)
1408
+
1409
+ self.spin_trim_start.blockSignals(True)
1410
+ self.spin_trim_end.blockSignals(True)
1411
+ self.spin_trim_start.setRange(0, max(0, m.frames - 1))
1412
+ self.spin_trim_end.setRange(0, max(0, m.frames - 1))
1413
+ self.spin_trim_start.setValue(0)
1414
+ self.spin_trim_end.setValue(max(0, m.frames - 1))
1415
+ self.spin_trim_start.blockSignals(False)
1416
+ self.spin_trim_end.blockSignals(False)
1417
+
1418
+ self.btn_save_trimmed.setEnabled(m.frames > 0)
1419
+
1420
+ # ROI defaults centered
1421
+ cx = max(0, (m.width // 2) - 256)
1422
+ cy = max(0, (m.height // 2) - 256)
1423
+ self.spin_x.setValue(cx)
1424
+ self.spin_y.setValue(cy)
1425
+ self.spin_w.setValue(min(512, m.width))
1426
+ self.spin_h.setValue(min(512, m.height))
1427
+
1428
+ self.btn_play.setEnabled(True)
1429
+ self.btn_stack.setEnabled(True)
1430
+ self._surface_anchor = None
1431
+ self._update_anchor_label()
1432
+ self.btn_play.setText("Play")
1433
+ self._playing = False
1434
+
1435
+ self._fit_mode = True
1436
+ self._refresh()
1437
+
1438
+ except Exception as e:
1439
+ QMessageBox.warning(self, "Trim", f"Saved, but failed to open:\n{e}")
1440
+
1441
+ else:
1442
+ # Just inform (optional; you can remove this if you prefer quieter UX)
1443
+ QMessageBox.information(
1444
+ self,
1445
+ "Trim",
1446
+ f"Saved trimmed SER:\n{path}\n\nFrames: {start}..{end} ({total})"
1447
+ )
1448
+
1449
+ finally:
1450
+ try:
1451
+ thread.quit()
1452
+ thread.wait(2000)
1453
+ except Exception:
1454
+ pass
1455
+
1456
+
1457
+ @pyqtSlot(str)
1458
+ def _on_failed(err: str):
1459
+ try:
1460
+ _cleanup_ui()
1461
+ QMessageBox.critical(self, "Trim", f"Failed to save trimmed SER:\n{err}")
1462
+ finally:
1463
+ try:
1464
+ thread.quit()
1465
+ thread.wait(2000)
1466
+ except Exception:
1467
+ pass
1468
+
1469
+ @pyqtSlot()
1470
+ def _on_canceled():
1471
+ try:
1472
+ _cleanup_ui()
1473
+ QMessageBox.information(self, "Trim", "Export canceled.")
1474
+ finally:
1475
+ try:
1476
+ thread.quit()
1477
+ thread.wait(2000)
1478
+ except Exception:
1479
+ pass
1480
+
1481
+ worker.finished.connect(_on_finished)
1482
+ worker.failed.connect(_on_failed)
1483
+ worker.canceled.connect(_on_canceled)
1484
+
1485
+ thread.started.connect(worker.run)
1486
+ thread.start()
1487
+
1488
+
1489
+ def _refresh(self):
1490
+ if self.reader is None:
1491
+ return
1492
+
1493
+ m = self.reader.meta
1494
+ self.lbl_frame.setText(f"{self._cur+1} / {m.frames}")
1495
+
1496
+ roi = self._roi_tuple()
1497
+ debayer = bool(self.chk_debayer.isChecked())
1498
+
1499
+ try:
1500
+ img = self.reader.get_frame(
1501
+ self._cur,
1502
+ roi=roi,
1503
+ debayer=debayer,
1504
+ to_float01=True,
1505
+ force_rgb=False,
1506
+ bayer_pattern=self.cmb_bayer.currentText(), # ✅ NEW
1507
+ )
1508
+ except Exception as e:
1509
+ QMessageBox.warning(self, "SER Viewer", f"Frame read failed:\n{e}")
1510
+ return
1511
+
1512
+ # Autostretch preview (linked)
1513
+ if self.chk_autostretch.isChecked():
1514
+ try:
1515
+ if img.ndim == 2 and stretch_mono_image is not None:
1516
+ img = np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
1517
+ elif img.ndim == 3 and img.shape[2] == 3 and stretch_color_image is not None:
1518
+ # linked=True for planetary preview (you requested this)
1519
+ img = np.clip(stretch_color_image(img, target_median=0.25, linked=True), 0.0, 1.0)
1520
+ except Exception:
1521
+ # if stretch fails, fall back to raw preview
1522
+ pass
1523
+
1524
+ try:
1525
+ img = self._apply_preview_tone(img)
1526
+ except Exception:
1527
+ pass
1528
+
1529
+ # store for overlay calculations (ROI-sized if ROI is on)
1530
+ self._last_disp_arr = img
1531
+
1532
+ qimg = self._to_qimage(img)
1533
+ self._last_qimg = qimg
1534
+ self._render_last(anchor=self._viewport_center_anchor() if not self._fit_mode else None)
1535
+
1536
+ def resizeEvent(self, e):
1537
+ super().resizeEvent(e)
1538
+ if self._last_qimg is None:
1539
+ return
1540
+ if self._fit_mode:
1541
+ self._render_last()
1542
+
1543
+ def _to_qimage(self, arr: np.ndarray) -> QImage:
1544
+ a = np.clip(arr, 0.0, 1.0)
1545
+ if a.ndim == 2:
1546
+ u = (a * 255.0).astype(np.uint8)
1547
+ h, w = u.shape
1548
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1549
+
1550
+ if a.ndim == 3 and a.shape[2] >= 3:
1551
+ u = (a[..., :3] * 255.0).astype(np.uint8)
1552
+ h, w, _ = u.shape
1553
+ return QImage(u.data, w, h, w * 3, QImage.Format.Format_RGB888).copy()
1554
+
1555
+ raise ValueError(f"Unexpected image shape: {a.shape}")
1556
+
1557
+ def _roi_bounds(self):
1558
+ """
1559
+ Returns (rx, ry, rw, rh) in full-frame coords if ROI enabled,
1560
+ else (0,0, full_w, full_h) if we can infer it.
1561
+ """
1562
+ if self.reader is None:
1563
+ return (0, 0, 0, 0)
1564
+
1565
+ if self.chk_roi.isChecked():
1566
+ return (int(self.spin_x.value()), int(self.spin_y.value()),
1567
+ int(self.spin_w.value()), int(self.spin_h.value()))
1568
+ # ROI disabled: treat whole frame as ROI
1569
+ m = self.reader.meta
1570
+ return (0, 0, int(m.width), int(m.height))
1571
+
1572
+
1573
+ def _full_to_roi_space(self, rect_full):
1574
+ """
1575
+ rect_full: (x,y,w,h) in full-frame coords
1576
+ returns: (x,y,w,h) in ROI-space (0..rw,0..rh)
1577
+ """
1578
+ if rect_full is None:
1579
+ return None
1580
+
1581
+ fx, fy, fw, fh = rect_full
1582
+ rx, ry, rw, rh = self._roi_bounds()
1583
+
1584
+ # convert full -> roi space
1585
+ x = fx - rx
1586
+ y = fy - ry
1587
+ w = fw
1588
+ h = fh
1589
+
1590
+ # clamp to ROI-space bounds
1591
+ x = max(0, min(rw - 1, x))
1592
+ y = max(0, min(rh - 1, y))
1593
+ w = max(1, min(rw - x, w))
1594
+ h = max(1, min(rh - y, h))
1595
+ return (int(x), int(y), int(w), int(h))
1596
+
1597
+
1598
+ def get_source_path(self) -> str | None:
1599
+ return getattr(self.reader, "path", None) if self.reader is not None else None
1600
+
1601
+ def get_roi(self):
1602
+ return self._roi_tuple() # already returns (x,y,w,h) or None
1603
+
1604
+ def get_surface_anchor(self):
1605
+ return getattr(self, "_surface_anchor", None)
1606
+
1607
+ def get_source_spec(self):
1608
+ if self.reader is None:
1609
+ return None
1610
+
1611
+ m = getattr(self.reader, "meta", None)
1612
+ if m is not None:
1613
+ # ✅ If this is an image sequence, use the full file list
1614
+ fl = getattr(m, "file_list", None)
1615
+ if isinstance(fl, (list, tuple)) and len(fl) > 0:
1616
+ return list(fl)
1617
+
1618
+ # Otherwise fall back to the meta path (SER/AVI)
1619
+ p = getattr(m, "path", None)
1620
+ if isinstance(p, str) and p:
1621
+ return p
1622
+
1623
+ # Fallback
1624
+ return getattr(self.reader, "path", None)
1625
+