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,1838 @@
1
+ # src/setiastro/saspro/ser_stacker_dialog.py
2
+ from __future__ import annotations
3
+ import os
4
+ import traceback
5
+ import numpy as np
6
+
7
+ from typing import Optional, Union, Sequence
8
+
9
+ SourceSpec = Union[str, Sequence[str]]
10
+
11
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QRectF, QEvent, QTimer
12
+ from PyQt6.QtWidgets import (
13
+ QWidget, QSpinBox, QMessageBox,
14
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QGroupBox,
15
+ QFormLayout, QComboBox, QDoubleSpinBox, QCheckBox, QTextEdit, QProgressBar,
16
+ QScrollArea, QSlider, QToolButton
17
+ )
18
+
19
+ from PyQt6.QtGui import QPainter, QPen, QColor, QImage, QPixmap
20
+
21
+ from setiastro.saspro.ser_stack_config import SERStackConfig
22
+
23
+ from setiastro.saspro.ser_stacker import stack_ser, analyze_ser, AnalyzeResult
24
+ from setiastro.saspro.ser_stacker import _shift_image
25
+
26
+ def _source_basename_from_source(source: SourceSpec | None) -> str:
27
+ """
28
+ Best-effort base name from the SER source:
29
+ - if source is "path/to/file.ser" -> "file"
30
+ - if source is [paths...] -> first path stem
31
+ - else -> ""
32
+ """
33
+ try:
34
+ if isinstance(source, str) and source.strip():
35
+ p = source.strip()
36
+ base = os.path.basename(p)
37
+ stem, _ = os.path.splitext(base)
38
+ return stem.strip()
39
+ if isinstance(source, (list, tuple)) and len(source) > 0:
40
+ first = source[0]
41
+ if isinstance(first, str) and first.strip():
42
+ base = os.path.basename(first.strip())
43
+ stem, _ = os.path.splitext(base)
44
+ return stem.strip()
45
+ except Exception:
46
+ pass
47
+ return ""
48
+
49
+
50
+ def _derive_view_base_title(main, doc) -> str:
51
+ """
52
+ Prefer the active view's title (respecting per-view rename/override),
53
+ fallback to the document display name, then to doc.name, and finally 'Image'.
54
+ Also strips any decorations if available.
55
+ """
56
+ # 1) Ask main for a subwindow for this document, if it exposes a helper
57
+ try:
58
+ if hasattr(main, "_subwindow_for_document"):
59
+ sw = main._subwindow_for_document(doc)
60
+ if sw:
61
+ w = sw.widget() if hasattr(sw, "widget") else sw
62
+ if hasattr(w, "_effective_title"):
63
+ t = w._effective_title() or ""
64
+ else:
65
+ t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
66
+ if hasattr(w, "_strip_decorations"):
67
+ t, _ = w._strip_decorations(t)
68
+ if t.strip():
69
+ return t.strip()
70
+ except Exception:
71
+ pass
72
+
73
+ # 2) Try scanning MDI for a subwindow whose widget holds this document
74
+ try:
75
+ mdi = (getattr(main, "mdi_area", None)
76
+ or getattr(main, "mdiArea", None)
77
+ or getattr(main, "mdi", None))
78
+ if mdi and hasattr(mdi, "subWindowList"):
79
+ for sw in mdi.subWindowList():
80
+ w = sw.widget()
81
+ if getattr(w, "document", None) is doc:
82
+ t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
83
+ if hasattr(w, "_strip_decorations"):
84
+ t, _ = w._strip_decorations(t)
85
+ if t.strip():
86
+ return t.strip()
87
+ except Exception:
88
+ pass
89
+
90
+ # 3) Fallback to document's display name (then name, then generic)
91
+ try:
92
+ if hasattr(doc, "display_name"):
93
+ t = doc.display_name()
94
+ if t and t.strip():
95
+ return t.strip()
96
+ except Exception:
97
+ pass
98
+
99
+ return (getattr(doc, "name", "") or "Image").strip()
100
+
101
+
102
+ def _push_as_new_doc(
103
+ main,
104
+ source_doc,
105
+ arr: np.ndarray,
106
+ *,
107
+ title_suffix: str = "_stack",
108
+ source: str = "Planetary Stacker",
109
+ source_path: SourceSpec | None = None,
110
+ ):
111
+ dm = getattr(main, "docman", None)
112
+ if not dm or not hasattr(dm, "open_array"):
113
+ return None
114
+
115
+ try:
116
+ # --- Base title ---
117
+ base = ""
118
+ if source_doc is not None:
119
+ base = _derive_view_base_title(main, source_doc) or ""
120
+ if not base:
121
+ base = _source_basename_from_source(source_path) or ""
122
+ if not base:
123
+ base = "Stack"
124
+
125
+ # Avoid double suffix
126
+ suf = title_suffix or ""
127
+ if suf and base.lower().endswith(suf.lower()):
128
+ title = base
129
+ else:
130
+ title = f"{base}{suf}"
131
+
132
+ x = np.asarray(arr)
133
+ # keep mono mono
134
+ if x.ndim == 3 and x.shape[2] == 1:
135
+ x = x[..., 0]
136
+ x = x.astype(np.float32, copy=False)
137
+
138
+ meta = {
139
+ "bit_depth": "32-bit floating point",
140
+ "is_mono": bool(x.ndim == 2),
141
+ "source": source,
142
+ }
143
+
144
+ newdoc = dm.open_array(x, metadata=meta, title=title)
145
+
146
+ if hasattr(main, "_spawn_subwindow_for"):
147
+ main._spawn_subwindow_for(newdoc)
148
+
149
+ return newdoc
150
+ except Exception:
151
+ return None
152
+
153
+
154
+ class APEditorDialog(QDialog):
155
+ """
156
+ AP editor (AutoStakkert-ish):
157
+ - Scrollable preview (fits to window by default)
158
+ - Zoom controls (+/-/slider, Fit, 1:1)
159
+ - Constant on-screen AP box thickness (draw boxes after scaling)
160
+ - Left click: add AP
161
+ - Right click: delete nearest AP
162
+ """
163
+ def __init__(
164
+ self,
165
+ parent=None,
166
+ *,
167
+ ref_img01: np.ndarray,
168
+ ap_size: int,
169
+ ap_spacing: int,
170
+ ap_min_mean: float,
171
+ initial_centers: np.ndarray | None = None
172
+ ):
173
+ super().__init__(parent)
174
+ self.setWindowTitle("Edit Alignment Points (APs)")
175
+ self.setModal(True)
176
+ self.resize(1000, 750)
177
+
178
+ self._ref = np.asarray(ref_img01, dtype=np.float32)
179
+ self._H, self._W = self._ref.shape[:2]
180
+
181
+ self._ap_size = int(ap_size)
182
+ self._ap_spacing = int(ap_spacing)
183
+ self._ap_min_mean = float(ap_min_mean)
184
+
185
+ self._centers = None if initial_centers is None else np.asarray(initial_centers, dtype=np.int32).copy()
186
+
187
+ # zoom state
188
+ self._zoom = 1.0
189
+ self._fit_pending = True # do initial "fit to window" after first show
190
+
191
+ # ---- Build UI ---------------------------------------------------------
192
+ outer = QVBoxLayout(self)
193
+ outer.setContentsMargins(10, 10, 10, 10)
194
+ outer.setSpacing(8)
195
+
196
+ # scroll area + pix label
197
+ self._pix = QLabel(self)
198
+ self._pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
199
+ self._pix.setMouseTracking(True)
200
+ self._pix.setStyleSheet("background:#111;") # makes the viewport look sane
201
+
202
+ self._scroll = QScrollArea(self)
203
+ self._scroll.setWidgetResizable(False) # we control label size
204
+ self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
205
+ self._scroll.setWidget(self._pix)
206
+
207
+ outer.addWidget(self._scroll, 1)
208
+
209
+ # Zoom row (under preview)
210
+ zoom_row = QHBoxLayout()
211
+ self.btn_zoom_out = QPushButton("–", self)
212
+ self.btn_zoom_in = QPushButton("+", self)
213
+ self.btn_fit = QPushButton("Fit", self)
214
+ self.btn_100 = QPushButton("1:1", self)
215
+
216
+ self.sld_zoom = QSlider(Qt.Orientation.Horizontal, self)
217
+ self.sld_zoom.setRange(10, 400) # percent
218
+ self.sld_zoom.setValue(100)
219
+ self.lbl_zoom = QLabel("100%", self)
220
+ self.lbl_zoom.setStyleSheet("color:#aaa; min-width:60px;")
221
+
222
+ self.btn_zoom_out.setFixedWidth(34)
223
+ self.btn_zoom_in.setFixedWidth(34)
224
+
225
+ zoom_row.addWidget(QLabel("Zoom:", self))
226
+ zoom_row.addWidget(self.btn_zoom_out)
227
+ zoom_row.addWidget(self.sld_zoom, 1)
228
+ zoom_row.addWidget(self.btn_zoom_in)
229
+ zoom_row.addWidget(self.lbl_zoom)
230
+ zoom_row.addSpacing(10)
231
+ zoom_row.addWidget(self.btn_fit)
232
+ zoom_row.addWidget(self.btn_100)
233
+
234
+ outer.addLayout(zoom_row)
235
+
236
+ # hint + buttons
237
+ self._lbl_hint = QLabel("Left click: add AP | Right click: delete nearest AP | Ctrl+Wheel: zoom", self)
238
+ self._lbl_hint.setStyleSheet("color:#aaa;")
239
+ outer.addWidget(self._lbl_hint, 0)
240
+
241
+ # --- AP settings (in-dialog) ---
242
+ ap_row = QHBoxLayout()
243
+
244
+ self.lbl_ap = QLabel("AP:", self)
245
+ self.lbl_ap.setStyleSheet("color:#aaa;")
246
+
247
+ self.spin_ap_size = QSpinBox(self)
248
+ self.spin_ap_size.setRange(16, 256)
249
+ self.spin_ap_size.setSingleStep(8)
250
+ self.spin_ap_size.setValue(int(self._ap_size))
251
+
252
+ self.spin_ap_spacing = QSpinBox(self)
253
+ self.spin_ap_spacing.setRange(8, 256)
254
+ self.spin_ap_spacing.setSingleStep(8)
255
+ self.spin_ap_spacing.setValue(int(self._ap_spacing))
256
+ self.spin_ap_min_mean = QDoubleSpinBox(self)
257
+ self.spin_ap_min_mean.setRange(0.0, 1.0)
258
+ self.spin_ap_min_mean.setDecimals(3)
259
+ self.spin_ap_min_mean.setSingleStep(0.005)
260
+ self.spin_ap_min_mean.setValue(float(self._ap_min_mean))
261
+ self.spin_ap_min_mean.setToolTip("Minimum mean intensity (0..1) required for an AP tile to be placed.")
262
+
263
+ ap_row.addWidget(self.lbl_ap)
264
+ ap_row.addSpacing(6)
265
+ ap_row.addWidget(QLabel("Size", self))
266
+ ap_row.addWidget(self.spin_ap_size)
267
+ ap_row.addSpacing(10)
268
+ ap_row.addWidget(QLabel("Spacing", self))
269
+ ap_row.addWidget(self.spin_ap_spacing)
270
+ ap_row.addSpacing(10)
271
+ ap_row.addWidget(QLabel("Min mean", self))
272
+ ap_row.addWidget(self.spin_ap_min_mean)
273
+ ap_row.addStretch(1)
274
+
275
+ outer.addLayout(ap_row, 0)
276
+
277
+ btn_row = QHBoxLayout()
278
+ self.btn_auto = QPushButton("Auto-place", self)
279
+ self.btn_clear = QPushButton("Clear", self)
280
+ self.btn_ok = QPushButton("OK", self)
281
+ self.btn_cancel = QPushButton("Cancel", self)
282
+ btn_row.addWidget(self.btn_auto)
283
+ btn_row.addWidget(self.btn_clear)
284
+ btn_row.addStretch(1)
285
+ btn_row.addWidget(self.btn_ok)
286
+ btn_row.addWidget(self.btn_cancel)
287
+ outer.addLayout(btn_row)
288
+
289
+ # signals
290
+ self.btn_cancel.clicked.connect(self.reject)
291
+ self.btn_ok.clicked.connect(self.accept)
292
+ self.btn_auto.clicked.connect(self._do_autoplace)
293
+ self.btn_clear.clicked.connect(self._do_clear)
294
+
295
+ self.btn_fit.clicked.connect(self._fit_to_window)
296
+ self.btn_100.clicked.connect(lambda: self._set_zoom(1.0))
297
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
298
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
299
+ self.sld_zoom.valueChanged.connect(self._on_zoom_slider)
300
+
301
+ # intercept mouse clicks on label
302
+ self._pix.mousePressEvent = self._on_mouse_press # type: ignore
303
+
304
+ self._ap_debounce = QTimer(self)
305
+ self._ap_debounce.setSingleShot(True)
306
+ self._ap_debounce.setInterval(250) # ms
307
+ self._ap_debounce.timeout.connect(self._apply_ap_params_and_relayout)
308
+ self.spin_ap_min_mean.valueChanged.connect(self._schedule_ap_relayout)
309
+
310
+ # apply redraw when changed
311
+ self.spin_ap_size.valueChanged.connect(self._schedule_ap_relayout)
312
+ self.spin_ap_spacing.valueChanged.connect(self._schedule_ap_relayout)
313
+
314
+ # enable Ctrl+Wheel zoom on the scroll area's viewport
315
+ self._scroll.viewport().installEventFilter(self)
316
+
317
+ # precompute display base image (uint8) once
318
+ self._base_u8 = self._make_display_u8(self._ref)
319
+
320
+ # init centers
321
+ if self._centers is None:
322
+ self._do_autoplace()
323
+
324
+ # first render
325
+ self._render()
326
+
327
+ def ap_size(self) -> int:
328
+ return int(self._ap_size)
329
+
330
+ def ap_spacing(self) -> int:
331
+ return int(self._ap_spacing)
332
+
333
+ def ap_min_mean(self) -> float:
334
+ return float(self._ap_min_mean)
335
+
336
+ def _schedule_ap_relayout(self):
337
+ # Restart the timer each change
338
+ try:
339
+ self._ap_debounce.start()
340
+ except Exception:
341
+ # fallback: apply immediately if timer fails for some reason
342
+ self._apply_ap_params_and_relayout()
343
+
344
+ def _apply_ap_params_and_relayout(self):
345
+ # Commit params
346
+ self._ap_size = int(self.spin_ap_size.value())
347
+ self._ap_spacing = int(self.spin_ap_spacing.value())
348
+ self._ap_min_mean = float(self.spin_ap_min_mean.value())
349
+
350
+ # Re-autoplace using the updated params
351
+ self._do_autoplace()
352
+
353
+
354
+
355
+ def showEvent(self, e):
356
+ super().showEvent(e)
357
+ if self._fit_pending:
358
+ self._fit_pending = False
359
+ self._fit_to_window()
360
+
361
+ def resizeEvent(self, e):
362
+ super().resizeEvent(e)
363
+ # keep a "fit" feel when the dialog is resized, but don't fight the user
364
+ # only auto-fit if they're near fit zoom already
365
+ # (comment this out if you *never* want auto-adjust)
366
+ # self._fit_to_window()
367
+ self._render()
368
+
369
+ def eventFilter(self, obj, event):
370
+ # Ctrl+wheel zoom
371
+ try:
372
+ if obj is self._scroll.viewport():
373
+ if event.type() == QEvent.Type.Wheel:
374
+ if bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier):
375
+ delta = event.angleDelta().y()
376
+ if delta > 0:
377
+ self._set_zoom(self._zoom * 1.15)
378
+ elif delta < 0:
379
+ self._set_zoom(self._zoom / 1.15)
380
+ return True
381
+ except Exception:
382
+ pass
383
+ return super().eventFilter(obj, event)
384
+
385
+ def ap_centers(self) -> np.ndarray:
386
+ if self._centers is None:
387
+ return np.zeros((0, 2), dtype=np.int32)
388
+ return self._centers
389
+
390
+ # ---------- image prep ----------
391
+ @staticmethod
392
+ def _make_display_u8(img01: np.ndarray) -> np.ndarray:
393
+ mono = img01 if img01.ndim == 2 else img01[..., 0]
394
+ mono = np.clip(mono, 0.0, 1.0)
395
+
396
+ lo = float(np.percentile(mono, 1.0))
397
+ hi = float(np.percentile(mono, 99.5))
398
+ if hi <= lo + 1e-8:
399
+ hi = lo + 1e-3
400
+
401
+ v = (mono - lo) / (hi - lo)
402
+ v = np.clip(v, 0.0, 1.0)
403
+ return (v * 255.0 + 0.5).astype(np.uint8)
404
+
405
+ # ---------- zoom helpers ----------
406
+ def _on_zoom_slider(self, value: int):
407
+ z = float(value) / 100.0
408
+ self._set_zoom(z)
409
+
410
+ def _set_zoom(self, z: float):
411
+ z = float(z)
412
+ z = max(0.10, min(4.00, z)) # clamp 10%..400%
413
+ self._zoom = z
414
+
415
+ block = self.sld_zoom.blockSignals(True)
416
+ try:
417
+ self.sld_zoom.setValue(int(round(z * 100.0)))
418
+ finally:
419
+ self.sld_zoom.blockSignals(block)
420
+
421
+ self.lbl_zoom.setText(f"{int(round(z * 100.0))}%")
422
+ self._render()
423
+
424
+ def _fit_to_window(self):
425
+ # fit image into scroll viewport with a little padding
426
+ vw = max(1, self._scroll.viewport().width() - 10)
427
+ vh = max(1, self._scroll.viewport().height() - 10)
428
+ if self._W <= 0 or self._H <= 0:
429
+ return
430
+ z = min(vw / float(self._W), vh / float(self._H))
431
+ self._set_zoom(z)
432
+
433
+ def _on_ap_params_changed(self):
434
+ # Update internal params
435
+ self._ap_size = int(self.spin_ap_size.value())
436
+ self._ap_spacing = int(self.spin_ap_spacing.value())
437
+
438
+ # Just redraw boxes (does not re-place points automatically)
439
+ self._render()
440
+
441
+
442
+ # ---------- drawing ----------
443
+ def _render(self):
444
+ u8 = self._base_u8
445
+ h, w = u8.shape[:2]
446
+
447
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
448
+ base_pm = QPixmap.fromImage(qimg.copy()) # copy so backing store persists
449
+
450
+ # scale to display zoom (keeps UI sane)
451
+ zw = max(1, int(round(w * self._zoom)))
452
+ zh = max(1, int(round(h * self._zoom)))
453
+ pm = base_pm.scaled(zw, zh, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
454
+
455
+ # draw AP boxes in *display coords* so thickness doesn't scale
456
+ p = QPainter(pm)
457
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
458
+
459
+ s_img = int(max(8, self._ap_size))
460
+ half_img = s_img // 2
461
+ s_disp = max(2, int(round(s_img * self._zoom)))
462
+ half_disp = s_disp // 2
463
+
464
+ pen = QPen(QColor(0, 255, 0), 2) # constant on-screen thickness
465
+ p.setPen(pen)
466
+
467
+ if self._centers is not None and self._centers.size > 0:
468
+ for cx, cy in self._centers.tolist():
469
+ x = int(round(cx * self._zoom))
470
+ y = int(round(cy * self._zoom))
471
+ p.drawRect(int(x - half_disp), int(y - half_disp), int(s_disp), int(s_disp))
472
+
473
+ p.end()
474
+
475
+ self._pix.setPixmap(pm)
476
+ self._pix.setFixedSize(pm.size())
477
+
478
+ # ---------- actions ----------
479
+ def _do_autoplace(self):
480
+ from setiastro.saspro.ser_stacker import _autoplace_aps # reuse exact logic
481
+ self._centers = _autoplace_aps(self._ref, self._ap_size, self._ap_spacing, self._ap_min_mean)
482
+ self._render()
483
+
484
+ def _do_clear(self):
485
+ self._centers = np.zeros((0, 2), dtype=np.int32)
486
+ self._render()
487
+
488
+ # ---------- mouse handling ----------
489
+ def _on_mouse_press(self, ev):
490
+ pm = self._pix.pixmap()
491
+ if pm is None:
492
+ return
493
+
494
+ # display coords in the label
495
+ dx = float(ev.position().x())
496
+ dy = float(ev.position().y())
497
+
498
+ # map to image coords
499
+ ix = int(round(dx / max(1e-6, self._zoom)))
500
+ iy = int(round(dy / max(1e-6, self._zoom)))
501
+
502
+ # clamp to image bounds
503
+ ix = max(0, min(self._W - 1, ix))
504
+ iy = max(0, min(self._H - 1, iy))
505
+
506
+ if ev.button() == Qt.MouseButton.LeftButton:
507
+ self._add_point(ix, iy)
508
+ elif ev.button() == Qt.MouseButton.RightButton:
509
+ self._delete_nearest(ix, iy)
510
+
511
+ def _add_point(self, x: int, y: int):
512
+ s = int(max(8, self._ap_size))
513
+ half = s // 2
514
+
515
+ # ensure AP box fits fully
516
+ x = max(half, min(self._W - 1 - half, x))
517
+ y = max(half, min(self._H - 1 - half, y))
518
+
519
+ if self._centers is None or self._centers.size == 0:
520
+ self._centers = np.asarray([[x, y]], dtype=np.int32)
521
+ else:
522
+ self._centers = np.vstack([self._centers, np.asarray([[x, y]], dtype=np.int32)])
523
+ self._render()
524
+
525
+ def _delete_nearest(self, x: int, y: int):
526
+ if self._centers is None or self._centers.size == 0:
527
+ return
528
+
529
+ pts = self._centers.astype(np.float32)
530
+ d2 = (pts[:, 0] - float(x)) ** 2 + (pts[:, 1] - float(y)) ** 2
531
+ j = int(np.argmin(d2))
532
+
533
+ # radius in image pixels (so behavior is stable regardless of zoom)
534
+ radius = max(10.0, float(self._ap_size) * 0.6)
535
+ if float(d2[j]) <= radius * radius:
536
+ self._centers = np.delete(self._centers, j, axis=0)
537
+ self._render()
538
+
539
+ class QualityGraph(QWidget):
540
+ """
541
+ AS-style quality plot (sorted curve expected):
542
+ - Curve: q[0] best ... q[N-1] worst
543
+ - Vertical cutoff line at keep_k
544
+ - Midrange horizontal line (min/max midpoint)
545
+ - True median horizontal line labeled 'Med'
546
+ - Click / drag adjusts keep line and emits keepChanged(k, N)
547
+ """
548
+ keepChanged = pyqtSignal(int, int) # keep_k, total_N
549
+
550
+ def __init__(self, parent=None):
551
+ super().__init__(parent)
552
+ self._q: np.ndarray | None = None
553
+ self._keep_k: int | None = None
554
+ self.setMinimumHeight(160)
555
+ self._dragging = False
556
+
557
+ def set_data(self, q: np.ndarray | None, keep_k: int | None = None):
558
+ self._q = None if q is None else np.asarray(q, dtype=np.float32)
559
+ self._keep_k = keep_k
560
+ self.update()
561
+
562
+ def _plot_rect(self):
563
+ # room for labels
564
+ return self.rect().adjusted(34, 10, -10, -22)
565
+
566
+ def _x_to_keep_k(self, x: float) -> int | None:
567
+ if self._q is None or self._q.size < 2:
568
+ return None
569
+ r = self._plot_rect()
570
+ if r.width() <= 1:
571
+ return None
572
+ N = int(self._q.size)
573
+
574
+ # clamp x to plot rect
575
+ xx = max(float(r.left()), min(float(r.right()), float(x)))
576
+
577
+ # map x back to index i in [0..N-1]
578
+ t = (xx - float(r.left())) / float(max(1, r.width()))
579
+ i = int(round(t * float(N - 1)))
580
+ i = max(0, min(N - 1, i))
581
+
582
+ # keep_k is count of frames kept => i=0 means keep 1, i=N-1 means keep N
583
+ return int(i + 1)
584
+
585
+ def mousePressEvent(self, ev):
586
+ if ev.button() != Qt.MouseButton.LeftButton:
587
+ return super().mousePressEvent(ev)
588
+ self._dragging = True
589
+ k = self._x_to_keep_k(ev.position().x())
590
+ if k is not None:
591
+ self._keep_k = int(k)
592
+ self.update()
593
+ self.keepChanged.emit(int(k), int(self._q.size)) # type: ignore
594
+ ev.accept()
595
+
596
+ def mouseMoveEvent(self, ev):
597
+ if not self._dragging:
598
+ return super().mouseMoveEvent(ev)
599
+ k = self._x_to_keep_k(ev.position().x())
600
+ if k is not None:
601
+ if self._keep_k != int(k):
602
+ self._keep_k = int(k)
603
+ self.update()
604
+ self.keepChanged.emit(int(k), int(self._q.size)) # type: ignore
605
+ ev.accept()
606
+
607
+ def mouseReleaseEvent(self, ev):
608
+ if ev.button() == Qt.MouseButton.LeftButton:
609
+ self._dragging = False
610
+ ev.accept()
611
+ return
612
+ return super().mouseReleaseEvent(ev)
613
+
614
+ def paintEvent(self, e):
615
+ p = QPainter(self)
616
+ p.fillRect(self.rect(), QColor(20, 20, 20))
617
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
618
+
619
+ r = self._plot_rect()
620
+
621
+ # frame
622
+ p.setPen(QPen(QColor(80, 80, 80), 1))
623
+ p.drawRect(r)
624
+
625
+ if self._q is None or self._q.size < 2:
626
+ p.setPen(QPen(QColor(160, 160, 160), 1))
627
+ p.drawText(r, Qt.AlignmentFlag.AlignCenter, "Analyze to see quality graph")
628
+ p.end()
629
+ return
630
+
631
+ q = self._q
632
+ N = int(q.size)
633
+
634
+ qmin = float(np.min(q))
635
+ qmax = float(np.max(q))
636
+ if qmax <= qmin + 1e-12:
637
+ qmax = qmin + 1e-6
638
+
639
+ def y_for(val: float) -> float:
640
+ return r.bottom() - ((val - qmin) / (qmax - qmin)) * r.height()
641
+
642
+ # ---- horizontal reference lines ----
643
+ # 1) midrange (between min/max) - dashed
644
+ qmid = qmin + 0.5 * (qmax - qmin)
645
+ ymid = y_for(qmid)
646
+ pen_mid = QPen(QColor(120, 120, 120), 1)
647
+ pen_mid.setStyle(Qt.PenStyle.DashLine)
648
+ p.setPen(pen_mid)
649
+ p.drawLine(int(r.left()), int(ymid), int(r.right()), int(ymid))
650
+
651
+ # 2) true median of q - dotted (or dash-dot)
652
+ qmed = float(np.median(q))
653
+ ymed = y_for(qmed)
654
+ pen_med = QPen(QColor(160, 160, 160), 1)
655
+ pen_med.setStyle(Qt.PenStyle.DotLine)
656
+ p.setPen(pen_med)
657
+ p.drawLine(int(r.left()), int(ymed), int(r.right()), int(ymed))
658
+
659
+ # small "Med" label on the right of the median line
660
+ p.setPen(QPen(QColor(180, 180, 180), 1))
661
+ p.drawText(int(r.right()) - 34, int(ymed) - 2, "Med")
662
+
663
+ # ---- curve ----
664
+ p.setPen(QPen(QColor(0, 220, 0), 2))
665
+ lastx = lasty = None
666
+ for i in range(N):
667
+ x = r.left() + (i / (N - 1)) * r.width()
668
+ y = y_for(float(q[i]))
669
+ if lastx is not None:
670
+ p.drawLine(int(lastx), int(lasty), int(x), int(y))
671
+ lastx, lasty = x, y
672
+
673
+ # ---- cutoff line ----
674
+ if self._keep_k is not None and N > 1:
675
+ k = int(max(1, min(N, int(self._keep_k))))
676
+ xcut = r.left() + ((k - 1) / (N - 1)) * r.width()
677
+ p.setPen(QPen(QColor(255, 220, 0), 2))
678
+ p.drawLine(int(xcut), int(r.top()), int(xcut), int(r.bottom()))
679
+
680
+ # ---- labels ----
681
+ p.setPen(QPen(QColor(180, 180, 180), 1))
682
+ p.drawText(
683
+ self.rect().adjusted(6, 0, 0, 0),
684
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
685
+ "Best",
686
+ )
687
+ p.drawText(
688
+ self.rect().adjusted(0, 0, -6, 0),
689
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom,
690
+ "Worst",
691
+ )
692
+
693
+ # y labels: max, mid, median, min
694
+ p.drawText(4, int(r.top()) + 10, f"{qmax:.3g}")
695
+ p.drawText(4, int(ymid) + 4, f"{qmid:.3g}") # midrange
696
+ p.drawText(4, int(ymed) + 4, f"{qmed:.3g}") # true median
697
+ p.drawText(4, int(r.bottom()), f"{qmin:.3g}")
698
+
699
+ p.end()
700
+
701
+ class _AnalyzeWorker(QThread):
702
+ progress = pyqtSignal(int, int, str) # done, total, phase
703
+ finished_ok = pyqtSignal(object)
704
+ failed = pyqtSignal(str)
705
+
706
+ def __init__(self, cfg: SERStackConfig, *, debayer: bool, to_rgb: bool, ref_mode: str, ref_count: int):
707
+ super().__init__()
708
+ self.cfg = cfg
709
+ self.debayer = bool(debayer)
710
+ self.to_rgb = bool(to_rgb)
711
+ self.ref_mode = ref_mode
712
+ self.ref_count = int(ref_count)
713
+ self._cancel = False
714
+ self._worker_realign: _ReAlignWorker | None = None
715
+
716
+ def run(self):
717
+ try:
718
+ def cb(done: int, total: int, phase: str):
719
+ self.progress.emit(int(done), int(total), str(phase))
720
+
721
+ ar = analyze_ser(
722
+ self.cfg,
723
+ debayer=self.debayer,
724
+ to_rgb=self.to_rgb,
725
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None),
726
+ ref_mode=self.ref_mode,
727
+ ref_count=self.ref_count,
728
+ progress_cb=cb,
729
+ )
730
+ self.finished_ok.emit(ar)
731
+ except Exception as e:
732
+ msg = f"{e}\n\n{traceback.format_exc()}"
733
+ self.failed.emit(msg)
734
+
735
+ class _StackWorker(QThread):
736
+ progress = pyqtSignal(int, int, str) # ✅ add this
737
+ finished_ok = pyqtSignal(object, object) # out(np.ndarray), diag(dict)
738
+ failed = pyqtSignal(str)
739
+
740
+ def __init__(self, cfg: SERStackConfig, analysis: AnalyzeResult | None, *, debayer: bool, to_rgb: bool):
741
+ super().__init__()
742
+ self.cfg = cfg
743
+ self.analysis = analysis
744
+ self.debayer = bool(debayer)
745
+ self.to_rgb = bool(to_rgb)
746
+
747
+ def run(self):
748
+ try:
749
+ print(f"tracking mode = {getattr(self.cfg, 'track_mode', 'planetary')}")
750
+ def cb(done: int, total: int, phase: str):
751
+ self.progress.emit(int(done), int(total), str(phase))
752
+
753
+ out, diag = stack_ser(
754
+ self.cfg.source,
755
+ roi=self.cfg.roi,
756
+ debayer=self.debayer,
757
+ to_rgb=self.to_rgb,
758
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None), # ✅ add this
759
+ keep_percent=float(getattr(self.cfg, "keep_percent", 20.0)),
760
+ track_mode=str(getattr(self.cfg, "track_mode", "planetary")),
761
+ surface_anchor=getattr(self.cfg, "surface_anchor", None),
762
+ analysis=self.analysis,
763
+ local_warp=True,
764
+ progress_cb=cb,
765
+ drizzle_scale=float(getattr(self.cfg, "drizzle_scale", 1.0)),
766
+ drizzle_pixfrac=float(getattr(self.cfg, "drizzle_pixfrac", 0.80)),
767
+ drizzle_kernel=str(getattr(self.cfg, "drizzle_kernel", "gaussian")),
768
+ drizzle_sigma=float(getattr(self.cfg, "drizzle_sigma", 0.0)),
769
+ keep_mask=getattr(self.cfg, "keep_mask", None),
770
+ )
771
+
772
+
773
+ self.finished_ok.emit(out, diag)
774
+ except Exception as e:
775
+ msg = f"{e}\n\n{traceback.format_exc()}"
776
+ self.failed.emit(msg)
777
+
778
+
779
+ class _ReAlignWorker(QThread):
780
+ progress = pyqtSignal(int, int, str) # done, total, phase
781
+ finished_ok = pyqtSignal(object) # updated AnalyzeResult
782
+ failed = pyqtSignal(str)
783
+
784
+ def __init__(self, cfg: SERStackConfig, analysis: AnalyzeResult, *, debayer: bool, to_rgb: bool):
785
+ super().__init__()
786
+ self.cfg = cfg
787
+ self.analysis = analysis
788
+ self.debayer = bool(debayer)
789
+ self.to_rgb = bool(to_rgb)
790
+
791
+ def run(self):
792
+ try:
793
+ def cb(done: int, total: int, phase: str):
794
+ self.progress.emit(int(done), int(total), str(phase))
795
+
796
+ from setiastro.saspro.ser_stacker import realign_ser # you’ll add this below
797
+ out_analysis = realign_ser(
798
+ self.cfg,
799
+ self.analysis,
800
+ debayer=self.debayer,
801
+ to_rgb=self.to_rgb,
802
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None),
803
+ progress_cb=cb,
804
+ )
805
+ self.finished_ok.emit(out_analysis)
806
+ except Exception as e:
807
+ self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
808
+
809
+
810
+ class SERStackerDialog(QDialog):
811
+ """
812
+ Dedicated stacking UI (AutoStakkert-like direction):
813
+ - Keeps viewer separate from stacking.
814
+ - V1: track mode, keep %, uses ROI + optional surface anchor from viewer.
815
+ - Later: alignment points (manual/auto), quality graph, drizzle, etc.
816
+ """
817
+
818
+ # Main app can connect this to "push to new view"
819
+ stackProduced = pyqtSignal(object, object) # out(np.ndarray), diag(dict)
820
+
821
+ def __init__(
822
+ self,
823
+ parent=None,
824
+ *,
825
+ main,
826
+ source_doc=None,
827
+ ser_path: Optional[str] = None, # ✅ typed + default
828
+ source: Optional[SourceSpec] = None,
829
+ roi=None,
830
+ track_mode: str = "planetary",
831
+ surface_anchor=None,
832
+ debayer: bool = True,
833
+ keep_percent: float = 20.0,
834
+ bayer_pattern: Optional[str] = None,
835
+ ):
836
+ super().__init__(parent)
837
+ self.setWindowTitle("Planetary Stacker - Beta")
838
+ self.setWindowFlag(Qt.WindowType.Window, True)
839
+ self.setWindowModality(Qt.WindowModality.NonModal)
840
+ self.setModal(False)
841
+ self._bayer_pattern = bayer_pattern
842
+ self._keep_mask = None # np.ndarray bool shape (N,) or None
843
+ # ---- Normalize inputs ------------------------------------------------
844
+ # If caller provided only `source`, treat string-source as ser_path too.
845
+ if source is None:
846
+ source = ser_path
847
+
848
+ # If source is a single path string and ser_path is missing, fill it.
849
+ if ser_path is None and isinstance(source, str) and source:
850
+ ser_path = source
851
+
852
+ if source is None:
853
+ raise ValueError("SERStackerDialog requires source (path or list of paths).")
854
+
855
+ self._main = main
856
+ self._source = source
857
+ self._source_doc = source_doc
858
+ self.setMinimumWidth(980) # or 1024 if you want it beefier
859
+ self.resize(1040, 720) # initial size (width, height)
860
+ # IMPORTANT: now _ser_path is never empty for the common SER case
861
+ self._ser_path = ser_path
862
+
863
+ self._track_mode = track_mode
864
+ self._roi = roi
865
+ self._surface_anchor = surface_anchor
866
+ self._debayer = bool(debayer)
867
+ self._keep_percent = float(keep_percent)
868
+
869
+ self._analysis = None
870
+ self._worker_analyze = None
871
+ self._worker = None
872
+ self._last_out = None
873
+ self._last_diag = None
874
+ try:
875
+ if isinstance(self._source, (list, tuple)):
876
+ self._append_log(f"Source: sequence ({len(self._source)} frames) first={self._source[0]}")
877
+ else:
878
+ self._append_log(f"Source: {self._source}")
879
+ except Exception:
880
+ pass
881
+
882
+ self._build_ui()
883
+
884
+ # defaults
885
+ self.cmb_track.setCurrentText(
886
+ "Planetary" if track_mode == "planetary" else ("Surface" if track_mode == "surface" else "Off")
887
+ )
888
+ self.spin_keep.setValue(float(keep_percent))
889
+ self.chk_debayer.setChecked(bool(debayer))
890
+ self._update_anchor_warning()
891
+ try:
892
+ if isinstance(self._source, (list, tuple)):
893
+ self._append_log(f"Source: sequence ({len(self._source)} frames)")
894
+ else:
895
+ self._append_log(f"Source: {self._source}")
896
+ except Exception:
897
+ self._append_log("Source: (unknown)")
898
+ self._append_log(f"ROI: {roi if roi is not None else '(full frame)'}")
899
+ if track_mode == "surface":
900
+ self._append_log(f"Surface anchor (ROI-space): {surface_anchor}")
901
+
902
+ # ---------------- UI ----------------
903
+ def _build_ui(self):
904
+ # ----- Dialog layout -----
905
+ outer = QVBoxLayout(self)
906
+ outer.setContentsMargins(10, 10, 10, 10)
907
+ outer.setSpacing(8)
908
+
909
+ # Split into two columns so we don't exceed monitor height:
910
+ # Left: settings/analyze/actions/progress
911
+ # Right: quality graph + log
912
+ cols = QHBoxLayout()
913
+ cols.setSpacing(10)
914
+ outer.addLayout(cols, 1)
915
+
916
+ left = QVBoxLayout()
917
+ left.setSpacing(8)
918
+ right = QVBoxLayout()
919
+ right.setSpacing(8)
920
+
921
+ cols.addLayout(left, 0)
922
+ cols.addLayout(right, 1)
923
+
924
+ # =========================
925
+ # LEFT COLUMN
926
+ # =========================
927
+
928
+ # --- Stack Settings ---
929
+ gb = QGroupBox("Stack Settings", self)
930
+ form = QFormLayout(gb)
931
+
932
+ self.cmb_track = QComboBox(self)
933
+ self.cmb_track.addItems(["Planetary", "Surface", "Off"])
934
+
935
+ self.spin_keep = QDoubleSpinBox(self)
936
+ self.spin_keep.setRange(0.1, 100.0)
937
+ self.spin_keep.setDecimals(1)
938
+ self.spin_keep.setSingleStep(1.0)
939
+ self.spin_keep.setValue(20.0)
940
+
941
+ self.chk_debayer = QCheckBox("Debayer (Bayer SER)", self)
942
+ self.chk_debayer.setChecked(True)
943
+
944
+ self.lbl_anchor = QLabel("", self)
945
+ self.lbl_anchor.setWordWrap(True)
946
+
947
+ form.addRow("Tracking", self.cmb_track)
948
+ form.addRow("Keep %", self.spin_keep)
949
+ form.addRow("", self.chk_debayer)
950
+ form.addRow("Surface anchor", self.lbl_anchor)
951
+
952
+ left.addWidget(gb, 0)
953
+
954
+ # --- Drizzle ---
955
+ gbD = QGroupBox("Drizzle", self)
956
+ fD = QFormLayout(gbD)
957
+
958
+ self.spin_pixfrac = QDoubleSpinBox(self)
959
+ self.spin_pixfrac.setRange(0.30, 1.00)
960
+ self.spin_pixfrac.setDecimals(2)
961
+ self.spin_pixfrac.setSingleStep(0.05)
962
+ self.spin_pixfrac.setValue(0.80)
963
+
964
+ self.cmb_kernel = QComboBox(self)
965
+ self.cmb_kernel.addItems(["Gaussian", "Circle", "Square"])
966
+
967
+ self.spin_sigma = QDoubleSpinBox(self)
968
+ self.spin_sigma.setRange(0.00, 10.00)
969
+ self.spin_sigma.setDecimals(2)
970
+ self.spin_sigma.setSingleStep(0.05)
971
+ self.spin_sigma.setValue(0.00) # 0 = auto
972
+ self.spin_sigma.setToolTip("Gaussian sigma in output pixels (0 = auto from pixfrac)")
973
+
974
+ # scale row: combo + info button in same row
975
+ scale_row = QHBoxLayout()
976
+ scale_row.setContentsMargins(0, 0, 0, 0)
977
+
978
+ self.cmb_drizzle = QComboBox(self)
979
+ self.cmb_drizzle.addItems(["Off (1x)", "1.5x", "2x"])
980
+
981
+ self.btn_drizzle_info = QToolButton(self)
982
+ self.btn_drizzle_info.setText("?")
983
+ self.btn_drizzle_info.setToolTip("Drizzle info")
984
+ self.btn_drizzle_info.setFixedSize(22, 22)
985
+
986
+ scale_row.addWidget(self.cmb_drizzle, 1)
987
+ scale_row.addWidget(self.btn_drizzle_info, 0)
988
+
989
+ scale_row_w = QWidget(self)
990
+ scale_row_w.setLayout(scale_row)
991
+
992
+ fD.addRow("Scale", scale_row_w)
993
+ fD.addRow("Pixfrac", self.spin_pixfrac)
994
+ fD.addRow("Kernel", self.cmb_kernel)
995
+ fD.addRow("Sigma", self.spin_sigma)
996
+
997
+ def _sync_drizzle_ui():
998
+ t = self.cmb_drizzle.currentText()
999
+ off = "Off" in t
1000
+ self.spin_pixfrac.setEnabled(not off)
1001
+ self.cmb_kernel.setEnabled(not off)
1002
+
1003
+ k = self.cmb_kernel.currentText().lower()
1004
+ is_gauss = ("gaussian" in k)
1005
+ self.spin_sigma.setEnabled((not off) and is_gauss)
1006
+
1007
+ # sensible defaults when enabling drizzle
1008
+ if off:
1009
+ return
1010
+ if "1.5" in t:
1011
+ if abs(self.spin_pixfrac.value() - 0.80) < 1e-6 or self.spin_pixfrac.value() in (0.70,):
1012
+ self.spin_pixfrac.setValue(0.80)
1013
+ elif "2" in t:
1014
+ if abs(self.spin_pixfrac.value() - 0.70) < 1e-6 or self.spin_pixfrac.value() in (0.80,):
1015
+ self.spin_pixfrac.setValue(0.70)
1016
+
1017
+ self.cmb_drizzle.currentIndexChanged.connect(lambda _=None: _sync_drizzle_ui())
1018
+ self.cmb_kernel.currentIndexChanged.connect(lambda _=None: _sync_drizzle_ui())
1019
+ _sync_drizzle_ui()
1020
+
1021
+ def _show_drizzle_info():
1022
+ QMessageBox.information(
1023
+ self,
1024
+ "Drizzle Info",
1025
+ "Drizzle increases output resolution by resampling and re-depositing pixels.\n\n"
1026
+ "Compute cost:\n"
1027
+ "• 1.5× drizzle ≈ 225% compute (2.25×)\n"
1028
+ "• 2× drizzle ≈ 400% compute (4×)\n\n"
1029
+ "Pixfrac (drop shrink):\n"
1030
+ "• Controls how large each input pixel’s “drop” is in the output grid.\n"
1031
+ "• Lower pixfrac = tighter drops (sharper, but can create gaps/noise).\n"
1032
+ "• Higher pixfrac = smoother coverage (less noise, slightly softer).\n\n"
1033
+ "When drizzle helps:\n"
1034
+ "• Best when you are under-sampled and you have good alignment / many frames.\n"
1035
+ "• Helps most with stable seeing and lots of usable frames.\n\n"
1036
+ "When drizzle may NOT help:\n"
1037
+ "• If you’re already well-sampled (common around f/10–f/20 depending on pixel size),\n"
1038
+ " gains can be minimal.\n"
1039
+ "• If seeing is very poor, drizzle often just magnifies blur/noise.\n\n"
1040
+ "Tip: Start with 1.5× and pixfrac ~0.8. If coverage looks sparse/noisy, increase pixfrac."
1041
+ )
1042
+
1043
+ self.btn_drizzle_info.clicked.connect(_show_drizzle_info)
1044
+
1045
+ left.addWidget(gbD, 0)
1046
+
1047
+ # --- Analyze settings (no graph in left column anymore) ---
1048
+ gbA = QGroupBox("Analyze", self)
1049
+ fA = QFormLayout(gbA)
1050
+
1051
+ self.cmb_ref = QComboBox(self)
1052
+ self.cmb_ref.addItems(["Best frame", "Best stack (N)"])
1053
+
1054
+ self.spin_refN = QSpinBox(self)
1055
+ self.spin_refN.setRange(2, 200)
1056
+ self.spin_refN.setValue(10)
1057
+
1058
+ self.spin_ap_min = QDoubleSpinBox(self)
1059
+ self.spin_ap_min.setRange(0.0, 1.0)
1060
+ self.spin_ap_min.setDecimals(3)
1061
+ self.spin_ap_min.setSingleStep(0.005)
1062
+ self.spin_ap_min.setValue(0.03)
1063
+ fA.addRow("AP min mean (0..1)", self.spin_ap_min)
1064
+
1065
+ self.btn_edit_aps = QPushButton("(2) Edit APs…", self)
1066
+ self.btn_edit_aps.setEnabled(False)
1067
+ fA.addRow("", self.btn_edit_aps)
1068
+
1069
+ self.spin_ap_size = QSpinBox(self)
1070
+ self.spin_ap_size.setRange(16, 256)
1071
+ self.spin_ap_size.setSingleStep(8)
1072
+ self.spin_ap_size.setValue(64)
1073
+
1074
+ self.spin_ap_spacing = QSpinBox(self)
1075
+ self.spin_ap_spacing.setRange(8, 256)
1076
+ self.spin_ap_spacing.setSingleStep(8)
1077
+ self.spin_ap_spacing.setValue(48)
1078
+
1079
+ fA.addRow("Reference", self.cmb_ref)
1080
+ fA.addRow("Ref stack N", self.spin_refN)
1081
+
1082
+ self.cmb_ap_scale = QComboBox(self)
1083
+ self.cmb_ap_scale.addItems(["Single", "Multi-scale (2× / 1× / ½×)"])
1084
+ fA.addRow("AP scale", self.cmb_ap_scale)
1085
+
1086
+ self.chk_ssd_bruteforce = QCheckBox("SSD refine: brute force (slower, can rescue tough data)", self)
1087
+ self.chk_ssd_bruteforce.setChecked(False)
1088
+ fA.addRow("", self.chk_ssd_bruteforce)
1089
+
1090
+ fA.addRow("AP size (px)", self.spin_ap_size)
1091
+ fA.addRow("AP spacing (px)", self.spin_ap_spacing)
1092
+
1093
+ left.addWidget(gbA, 0)
1094
+
1095
+ # --- Action buttons ---
1096
+ row = QHBoxLayout()
1097
+ self.btn_analyze = QPushButton("(1) Analyze", self)
1098
+ self.btn_analyze.setEnabled(True)
1099
+ self.btn_blink = QPushButton("(3) Blink Keepers", self) # ✅ new
1100
+ self.btn_stack = QPushButton("(4) Stack Now", self)
1101
+ self.btn_close = QPushButton("Close", self)
1102
+
1103
+ row.addWidget(self.btn_analyze)
1104
+ row.addStretch(1)
1105
+ row.addWidget(self.btn_blink)
1106
+ row.addStretch(1)
1107
+ row.addWidget(self.btn_stack)
1108
+ row.addWidget(self.btn_close)
1109
+
1110
+ left.addLayout(row, 0)
1111
+
1112
+ # --- Progress ---
1113
+ self.prog = QProgressBar(self)
1114
+ self.prog.setRange(0, 0)
1115
+ self.prog.setVisible(False)
1116
+ left.addWidget(self.prog, 0)
1117
+
1118
+ self.lbl_prog = QLabel("", self)
1119
+ self.lbl_prog.setStyleSheet("color:#aaa;")
1120
+ self.lbl_prog.setVisible(False)
1121
+ left.addWidget(self.lbl_prog, 0)
1122
+
1123
+ left.addStretch(1)
1124
+
1125
+ # =========================
1126
+ # RIGHT COLUMN
1127
+ # =========================
1128
+
1129
+ # --- Quality Graph ---
1130
+ gbQ = QGroupBox("Quality", self)
1131
+ vQ = QVBoxLayout(gbQ)
1132
+ vQ.setContentsMargins(8, 8, 8, 8)
1133
+ vQ.setSpacing(6)
1134
+
1135
+ self.graph = QualityGraph(self)
1136
+ self.graph.setMinimumHeight(180)
1137
+ self.graph.setMinimumWidth(480) # keeps the right column from scrunching
1138
+
1139
+ # small hint under the graph
1140
+ self.lbl_graph_hint = QLabel("Tip: click the graph to set Keep cutoff.", self)
1141
+ self.lbl_graph_hint.setStyleSheet("color:#888; font-size:11px;")
1142
+ self.lbl_graph_hint.setWordWrap(True)
1143
+
1144
+ vQ.addWidget(self.graph, 1)
1145
+ vQ.addWidget(self.lbl_graph_hint, 0)
1146
+
1147
+ right.addWidget(gbQ, 1)
1148
+
1149
+ # --- Log ---
1150
+ gbL = QGroupBox("Log", self)
1151
+ vL = QVBoxLayout(gbL)
1152
+ vL.setContentsMargins(8, 8, 8, 8)
1153
+
1154
+ self.log = QTextEdit(self)
1155
+ self.log.setReadOnly(True)
1156
+ self.log.setMinimumHeight(140)
1157
+ self.log.setPlaceholderText("Log…")
1158
+
1159
+ vL.addWidget(self.log, 1)
1160
+ right.addWidget(gbL, 1)
1161
+
1162
+ # =========================
1163
+ # Signals / wiring
1164
+ # =========================
1165
+
1166
+ self.btn_close.clicked.connect(self.close)
1167
+ self.btn_stack.clicked.connect(self._start_stack)
1168
+ self.btn_blink.clicked.connect(self._blink_keepers)
1169
+ self.cmb_track.currentIndexChanged.connect(self._update_anchor_warning)
1170
+ self.btn_analyze.clicked.connect(self._start_analyze)
1171
+ self.btn_edit_aps.clicked.connect(self._edit_aps)
1172
+ self.spin_keep.valueChanged.connect(self._on_keep_changed)
1173
+
1174
+ # Keep % edits update the cutoff line
1175
+ self.spin_keep.valueChanged.connect(self._update_graph_cutoff)
1176
+
1177
+ # Clicking on the graph updates Keep %
1178
+ def _on_graph_keep_changed(k: int, total: int):
1179
+ total = max(1, int(total))
1180
+ k = max(1, min(total, int(k)))
1181
+ pct = 100.0 * float(k) / float(total)
1182
+
1183
+ block = self.spin_keep.blockSignals(True)
1184
+ try:
1185
+ self.spin_keep.setValue(float(pct))
1186
+ finally:
1187
+ self.spin_keep.blockSignals(block)
1188
+
1189
+ # update graph line (using current analysis ordering)
1190
+ self._update_graph_cutoff()
1191
+ self._append_log(f"Keep set from graph: {pct:.1f}% ({k}/{total})")
1192
+
1193
+ self.graph.keepChanged.connect(_on_graph_keep_changed)
1194
+
1195
+ # ---------------- helpers ----------------
1196
+ def _edit_aps(self):
1197
+ if self._analysis is None:
1198
+ return
1199
+
1200
+ try:
1201
+ dlg = APEditorDialog(
1202
+ self,
1203
+ ref_img01=self._analysis.ref_image,
1204
+ ap_size=int(self.spin_ap_size.value()),
1205
+ ap_spacing=int(self.spin_ap_spacing.value()),
1206
+ ap_min_mean=float(self.spin_ap_min.value()),
1207
+ initial_centers=getattr(self._analysis, "ap_centers", None),
1208
+ )
1209
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1210
+ centers = dlg.ap_centers()
1211
+ self._analysis.ap_centers = centers
1212
+
1213
+ # ✅ pull size/spacing changes from the editor back into the main UI
1214
+ try:
1215
+ self.spin_ap_size.setValue(int(dlg.ap_size()))
1216
+ self.spin_ap_spacing.setValue(int(dlg.ap_spacing()))
1217
+ self.spin_ap_min.setValue(float(dlg.ap_min_mean()))
1218
+ except Exception:
1219
+ pass
1220
+
1221
+ self._append_log(
1222
+ f"APs set: {int(centers.shape[0])} points "
1223
+ f"(size={int(self.spin_ap_size.value())}, spacing={int(self.spin_ap_spacing.value())})"
1224
+ )
1225
+
1226
+ # Recompute alignment only (no full analyze)
1227
+ cfg = self._make_cfg()
1228
+
1229
+ self.lbl_prog.setVisible(True)
1230
+ self.prog.setVisible(True)
1231
+ self.prog.setRange(0, 100)
1232
+ self.prog.setValue(0)
1233
+ self.lbl_prog.setText("Re-aligning with APs…")
1234
+ self.btn_stack.setEnabled(False)
1235
+ self.btn_analyze.setEnabled(False)
1236
+ self.btn_edit_aps.setEnabled(False)
1237
+
1238
+ self._worker_realign = _ReAlignWorker(cfg, self._analysis, debayer=bool(self.chk_debayer.isChecked()), to_rgb=False)
1239
+ self._worker_realign.progress.connect(self._on_analyze_progress) # reuse progress UI
1240
+ self._worker_realign.finished_ok.connect(self._on_realign_ok)
1241
+ self._worker_realign.failed.connect(self._on_analyze_fail)
1242
+ self._worker_realign.start()
1243
+ else:
1244
+ self._append_log("AP edit cancelled.")
1245
+ except Exception as e:
1246
+ tb = traceback.format_exc()
1247
+ QMessageBox.critical(self, "AP Editor Error", f"{e}\n\n{tb}")
1248
+ self._append_log(f"AP editor failed: {e}")
1249
+ self._append_log(tb)
1250
+
1251
+ def _on_realign_ok(self, ar: AnalyzeResult):
1252
+ self._analysis = ar
1253
+
1254
+ self.prog.setVisible(False)
1255
+ self.lbl_prog.setVisible(False)
1256
+
1257
+ self.btn_stack.setEnabled(True)
1258
+ self.btn_analyze.setEnabled(True)
1259
+ self.btn_edit_aps.setEnabled(True)
1260
+ self.btn_close.setEnabled(True)
1261
+
1262
+ self._append_log("Re-align done (dx/dy/conf updated from APs).")
1263
+
1264
+
1265
+
1266
+ def _append_log(self, s: str):
1267
+ try:
1268
+ self.log.append(s)
1269
+ except Exception:
1270
+ pass
1271
+
1272
+ def _track_mode_value(self) -> str:
1273
+ t = self.cmb_track.currentText().strip().lower()
1274
+ if t.startswith("planet"):
1275
+ return "planetary"
1276
+ if t.startswith("surface"):
1277
+ return "surface"
1278
+ return "off"
1279
+
1280
+ def _update_anchor_warning(self):
1281
+ mode = self._track_mode_value()
1282
+ if mode != "surface":
1283
+ self.lbl_anchor.setText("(not used)")
1284
+ self.lbl_anchor.setStyleSheet("color:#888;")
1285
+ return
1286
+
1287
+ if self._surface_anchor is None:
1288
+ self.lbl_anchor.setText("REQUIRED (set in SER Viewer with Ctrl+Shift+drag)")
1289
+ self.lbl_anchor.setStyleSheet("color:#c66;")
1290
+ return
1291
+
1292
+ x, y, w, h = [int(v) for v in self._surface_anchor]
1293
+
1294
+ # Always show ROI-space (that’s what the tracker uses)
1295
+ txt = f"✅ ROI-space: x={x}, y={y}, w={w}, h={h}"
1296
+
1297
+ # If an ROI is set, also show full-frame coords for sanity/debug
1298
+ if self._roi is not None:
1299
+ rx, ry, rw, rh = [int(v) for v in self._roi]
1300
+ fx = rx + x
1301
+ fy = ry + y
1302
+ txt += f" | Full-frame: x={fx}, y={fy}, w={w}, h={h}"
1303
+
1304
+ self.lbl_anchor.setText(txt)
1305
+ self.lbl_anchor.setStyleSheet("color:#4a4;")
1306
+
1307
+
1308
+ # ---------------- actions ----------------
1309
+ def _start_analyze(self):
1310
+ mode = self._track_mode_value()
1311
+ if mode == "surface" and self._surface_anchor is None:
1312
+ self._append_log("Surface mode requires an anchor. Set it in the viewer (Ctrl+Shift+drag).")
1313
+ return
1314
+
1315
+ ref_mode = "best_stack" if self.cmb_ref.currentText().lower().startswith("best stack") else "best_frame"
1316
+ refN = int(self.spin_refN.value()) if ref_mode == "best_stack" else 1
1317
+
1318
+ cfg = self._make_cfg()
1319
+
1320
+ self.btn_analyze.setEnabled(False)
1321
+ self.btn_stack.setEnabled(False)
1322
+ self.btn_close.setEnabled(False)
1323
+ self.lbl_prog.setVisible(True)
1324
+ self.lbl_prog.setText("Analyzing…")
1325
+ self.prog.setVisible(True)
1326
+ self.prog.setRange(0, 100)
1327
+ self.prog.setValue(0)
1328
+
1329
+ self._worker_analyze = _AnalyzeWorker(
1330
+ cfg,
1331
+ debayer=bool(self.chk_debayer.isChecked()),
1332
+ to_rgb=False,
1333
+ ref_mode=ref_mode,
1334
+ ref_count=refN,
1335
+ )
1336
+ self._worker_analyze.finished_ok.connect(self._on_analyze_ok)
1337
+ self._worker_analyze.failed.connect(self._on_analyze_fail)
1338
+ self._worker_analyze.progress.connect(self._on_analyze_progress)
1339
+ self._worker_analyze.start()
1340
+
1341
+
1342
+ def _on_analyze_progress(self, done: int, total: int, phase: str):
1343
+ total = max(1, int(total))
1344
+ done = max(0, min(total, int(done)))
1345
+ pct = int(round(100.0 * done / total))
1346
+ self.prog.setRange(0, 100)
1347
+ self.prog.setValue(pct)
1348
+ self.lbl_prog.setText(f"{phase}: {done}/{total} ({pct}%)")
1349
+
1350
+
1351
+ def _on_analyze_ok(self, ar: AnalyzeResult):
1352
+ self._analysis = ar
1353
+
1354
+ self.prog.setVisible(False)
1355
+ self.lbl_prog.setVisible(False)
1356
+
1357
+ self.btn_analyze.setEnabled(True)
1358
+ self.btn_stack.setEnabled(True)
1359
+ self.btn_blink.setEnabled(True)
1360
+ self.btn_close.setEnabled(True)
1361
+
1362
+ self._append_log(f"Analyze done. frames={ar.frames_total} track={ar.track_mode}")
1363
+ self._append_log(f"Ref: {ar.ref_mode} (N={ar.ref_count})")
1364
+
1365
+ # update graph (time-order) + cutoff marker based on keep%
1366
+ k = int(round(ar.frames_total * (float(self.spin_keep.value()) / 100.0)))
1367
+ k = max(1, min(ar.frames_total, k))
1368
+ q_sorted = ar.quality[ar.order]
1369
+ self.graph.set_data(q_sorted, keep_k=k)
1370
+ self.btn_edit_aps.setEnabled(True)
1371
+
1372
+ def _on_analyze_fail(self, msg: str):
1373
+ self.prog.setVisible(False)
1374
+ self.lbl_prog.setVisible(False)
1375
+
1376
+ self.btn_analyze.setEnabled(True)
1377
+ had_analysis = self._analysis is not None and getattr(self._analysis, "ref_image", None) is not None
1378
+ self.btn_stack.setEnabled(bool(had_analysis))
1379
+ self.btn_edit_aps.setEnabled(bool(had_analysis))
1380
+ self.btn_close.setEnabled(True)
1381
+
1382
+ self._append_log("ANALYZE FAILED:")
1383
+ self._append_log(msg)
1384
+
1385
+ def _update_graph_cutoff(self):
1386
+ if self._analysis is None:
1387
+ return
1388
+ n = int(self._analysis.frames_total)
1389
+ k = int(round(n * (float(self.spin_keep.value()) / 100.0)))
1390
+ k = max(1, min(n, k))
1391
+ q_sorted = self._analysis.quality[self._analysis.order]
1392
+ self.graph.set_data(q_sorted, keep_k=k)
1393
+
1394
+ def _blink_keepers(self):
1395
+ if self._analysis is None:
1396
+ return
1397
+
1398
+ N = int(self._analysis.frames_total)
1399
+ keep_k = int(round(N * (float(self.spin_keep.value()) / 100.0)))
1400
+ keep_k = max(1, min(N, keep_k))
1401
+
1402
+ cfg = self._make_cfg()
1403
+ cfg.keep_mask = getattr(cfg, "keep_mask", None)
1404
+
1405
+ try:
1406
+ dlg = BlinkKeepersDialog(
1407
+ self,
1408
+ cfg=cfg,
1409
+ analysis=self._analysis,
1410
+ debayer=bool(self.chk_debayer.isChecked()),
1411
+ to_rgb=False,
1412
+ keep_k=keep_k,
1413
+ )
1414
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1415
+ km = dlg.keep_mask_all_frames()
1416
+ self._keep_mask = km
1417
+
1418
+ # log stats
1419
+ kept = int(np.count_nonzero(km))
1420
+ self._append_log(f"Blink Keepers: kept {kept}/{N} after manual rejects.")
1421
+ else:
1422
+ self._append_log("Blink Keepers cancelled (no changes).")
1423
+ except Exception as e:
1424
+ tb = traceback.format_exc()
1425
+ QMessageBox.critical(self, "Blink Keepers Error", f"{e}\n\n{tb}")
1426
+ self._append_log(f"Blink Keepers failed: {e}")
1427
+ self._append_log(tb)
1428
+
1429
+
1430
+ def _make_cfg(self) -> SERStackConfig:
1431
+ scale_text = self.cmb_drizzle.currentText()
1432
+ if "1.5" in scale_text:
1433
+ drizzle_scale = 1.5
1434
+ elif "2" in scale_text:
1435
+ drizzle_scale = 2.0
1436
+ else:
1437
+ drizzle_scale = 1.0
1438
+
1439
+ drizzle_kernel = self.cmb_kernel.currentText().strip().lower() # gaussian/circle/square
1440
+
1441
+ return SERStackConfig(
1442
+ source=self._source,
1443
+ roi=self._roi,
1444
+ track_mode=self._track_mode_value(),
1445
+ surface_anchor=self._surface_anchor,
1446
+ keep_percent=float(self.spin_keep.value()),
1447
+ bayer_pattern=self._bayer_pattern,
1448
+ keep_mask=getattr(self, "_keep_mask", None),
1449
+
1450
+ ap_size=int(self.spin_ap_size.value()),
1451
+ ap_spacing=int(self.spin_ap_spacing.value()),
1452
+ ap_min_mean=float(self.spin_ap_min.value()),
1453
+ ap_multiscale=(self.cmb_ap_scale.currentIndex() == 1),
1454
+ ssd_refine_bruteforce=bool(
1455
+ getattr(self, "chk_ssd_bruteforce", None) and self.chk_ssd_bruteforce.isChecked()
1456
+ ),
1457
+
1458
+ # ✅ drizzle
1459
+ drizzle_scale=float(drizzle_scale),
1460
+ drizzle_pixfrac=float(self.spin_pixfrac.value()),
1461
+ drizzle_kernel=str(drizzle_kernel),
1462
+ drizzle_sigma=float(self.spin_sigma.value()),
1463
+ )
1464
+
1465
+ def _on_keep_changed(self, _v):
1466
+ self._keep_mask = None
1467
+
1468
+ def _start_stack(self):
1469
+ mode = self._track_mode_value()
1470
+ if mode == "surface" and self._surface_anchor is None:
1471
+ self._append_log("Surface mode requires an anchor. Set it in the viewer (Ctrl+Shift+drag).")
1472
+ return
1473
+
1474
+ cfg = self._make_cfg()
1475
+ cfg.keep_mask = self._keep_mask
1476
+ debayer = bool(self.chk_debayer.isChecked())
1477
+
1478
+ self.btn_stack.setEnabled(False)
1479
+ self.btn_close.setEnabled(False)
1480
+ self.btn_analyze.setEnabled(False)
1481
+ self.btn_edit_aps.setEnabled(False)
1482
+ scale_text = self.cmb_drizzle.currentText()
1483
+ if "1.5" in scale_text:
1484
+ drizzle_scale = 1.5
1485
+ elif "2" in scale_text:
1486
+ drizzle_scale = 2.0
1487
+ else:
1488
+ drizzle_scale = 1.0
1489
+
1490
+ drizzle_kernel = self.cmb_kernel.currentText().strip().lower() # "gaussian"/"circle"/"square"
1491
+ drizzle_pixfrac = float(self.spin_pixfrac.value())
1492
+ drizzle_sigma = float(self.spin_sigma.value())
1493
+ self.lbl_prog.setVisible(True)
1494
+ self.prog.setVisible(True)
1495
+ self.prog.setRange(0, 100)
1496
+ self.prog.setValue(0)
1497
+ self.lbl_prog.setText("Stack: 0%")
1498
+
1499
+ self._append_log("Stacking...")
1500
+
1501
+ self._worker = _StackWorker(cfg, analysis=self._analysis, debayer=debayer, to_rgb=False)
1502
+ self._worker.progress.connect(self._on_analyze_progress) # ✅ reuse your progress handler
1503
+ self._worker.finished_ok.connect(self._on_stack_ok)
1504
+ self._worker.failed.connect(self._on_stack_fail)
1505
+ self._worker.start()
1506
+
1507
+ def _on_stack_ok(self, out, diag):
1508
+ self._last_out = out
1509
+ self._last_diag = diag
1510
+
1511
+ self.prog.setVisible(False)
1512
+ self.lbl_prog.setVisible(False)
1513
+
1514
+ self.btn_stack.setEnabled(True)
1515
+ self.btn_close.setEnabled(True)
1516
+ self.btn_analyze.setEnabled(True)
1517
+ self.btn_edit_aps.setEnabled(True)
1518
+
1519
+ self._append_log(f"Done. Kept {diag.get('frames_kept')} / {diag.get('frames_total')}")
1520
+ self._append_log(f"Track: {diag.get('track_mode')} ROI: {diag.get('roi_used')}")
1521
+
1522
+ # ✅ Create the new stacked document (GUI thread)
1523
+ newdoc = _push_as_new_doc(
1524
+ self._main,
1525
+ self._source_doc,
1526
+ out,
1527
+ title_suffix="_stack",
1528
+ source="Planetary Stacker",
1529
+ source_path=self._source,
1530
+ )
1531
+
1532
+ # Optional: stash diag on the document metadata (handy later)
1533
+ if newdoc is not None:
1534
+ try:
1535
+ md = getattr(newdoc, "metadata", None)
1536
+ if md is None:
1537
+ md = {}
1538
+ setattr(newdoc, "metadata", md)
1539
+ md["ser_stack_diag"] = diag
1540
+ except Exception:
1541
+ pass
1542
+
1543
+ # Keep emitting too (so other callers can hook it)
1544
+ self.stackProduced.emit(out, diag)
1545
+
1546
+
1547
+ def _on_stack_fail(self, msg: str):
1548
+ self.prog.setVisible(False)
1549
+ self.btn_stack.setEnabled(True)
1550
+ self.btn_close.setEnabled(True)
1551
+ self.btn_analyze.setEnabled(True)
1552
+ self._append_log("FAILED:")
1553
+ self._append_log(msg)
1554
+
1555
+ class BlinkKeepersDialog(QDialog):
1556
+ """
1557
+ Blink through the frames currently selected to keep, allow user to reject any.
1558
+ Returns a keep_mask (bool) for ALL frames, True=keep.
1559
+ """
1560
+ def __init__(self, parent=None, *, cfg: SERStackConfig, analysis: AnalyzeResult,
1561
+ debayer: bool, to_rgb: bool, keep_k: int):
1562
+ super().__init__(parent)
1563
+ self.setWindowTitle("Blink Keepers")
1564
+ self.resize(1000, 750)
1565
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
1566
+ self.setFocus() # so keypress works immediately
1567
+ self.cfg = cfg
1568
+ self.analysis = analysis
1569
+ self.debayer = bool(debayer)
1570
+ self.to_rgb = bool(to_rgb)
1571
+
1572
+ self.N = int(analysis.frames_total)
1573
+ keep_k = max(1, min(self.N, int(keep_k)))
1574
+
1575
+ # keeper frame indices in original frame space
1576
+ self.keepers = np.asarray(analysis.order[:keep_k], dtype=np.int32)
1577
+
1578
+ # rejection only over the keeper list
1579
+ self.rejected = np.zeros((self.keepers.size,), dtype=bool)
1580
+
1581
+ # ---- UI ----
1582
+ outer = QVBoxLayout(self)
1583
+ outer.setContentsMargins(10, 10, 10, 10)
1584
+ outer.setSpacing(8)
1585
+
1586
+ self.lbl = QLabel(self)
1587
+ self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
1588
+ self.lbl.setStyleSheet("background:#111;")
1589
+ self.lbl.setMinimumHeight(480)
1590
+ outer.addWidget(self.lbl, 1)
1591
+ # --- Instructions / shortcuts ---
1592
+ self.lbl_help = QLabel(self)
1593
+ self.lbl_help.setWordWrap(True)
1594
+ self.lbl_help.setStyleSheet(
1595
+ "color:#9aa; background:#151515; border:1px solid #2a2a2a;"
1596
+ "border-radius:6px; padding:6px; font-size:11px;"
1597
+ )
1598
+ self.lbl_help.setText(
1599
+ "Shortcuts: "
1600
+ "←/→ (or ↑/↓) = Prev/Next | PgUp/PgDn = Prev/Next\n"
1601
+ "R or Space = Toggle Reject + Next | Backspace = Toggle Reject + Prev\n"
1602
+ "Esc = Cancel | Enter = OK"
1603
+ )
1604
+ outer.addWidget(self.lbl_help, 0)
1605
+ info_row = QHBoxLayout()
1606
+ self.lbl_info = QLabel("", self)
1607
+ self.lbl_info.setStyleSheet("color:#bbb;")
1608
+ self.lbl_info.setWordWrap(True)
1609
+ info_row.addWidget(self.lbl_info, 1)
1610
+
1611
+ self.btn_toggle = QPushButton("Reject", self)
1612
+ self.btn_toggle.setCheckable(True)
1613
+ info_row.addWidget(self.btn_toggle, 0)
1614
+
1615
+ outer.addLayout(info_row)
1616
+
1617
+ nav = QHBoxLayout()
1618
+ self.btn_prev = QPushButton("◀ Prev", self)
1619
+ self.btn_next = QPushButton("Next ▶", self)
1620
+
1621
+ self.sld = QSlider(Qt.Orientation.Horizontal, self)
1622
+ self.sld.setRange(0, max(0, self.keepers.size - 1))
1623
+ self.sld.setValue(0)
1624
+
1625
+ self.lbl_pos = QLabel("", self)
1626
+ self.lbl_pos.setStyleSheet("color:#aaa; min-width:90px;")
1627
+
1628
+ nav.addWidget(self.btn_prev)
1629
+ nav.addWidget(self.sld, 1)
1630
+ nav.addWidget(self.btn_next)
1631
+ nav.addWidget(self.lbl_pos)
1632
+ outer.addLayout(nav)
1633
+
1634
+ btns = QHBoxLayout()
1635
+ self.btn_ok = QPushButton("OK", self)
1636
+ self.btn_cancel = QPushButton("Cancel", self)
1637
+ btns.addStretch(1)
1638
+ btns.addWidget(self.btn_ok)
1639
+ btns.addWidget(self.btn_cancel)
1640
+ outer.addLayout(btns)
1641
+
1642
+ # ---- signals ----
1643
+ self.btn_cancel.clicked.connect(self.reject)
1644
+ self.btn_ok.clicked.connect(self.accept)
1645
+ self.btn_prev.clicked.connect(lambda: self._step(-1))
1646
+ self.btn_next.clicked.connect(lambda: self._step(+1))
1647
+ self.sld.valueChanged.connect(self._show_index)
1648
+ self.btn_toggle.clicked.connect(lambda: self._toggle_reject_and_advance(+1))
1649
+
1650
+ # ---- load source ----
1651
+ from setiastro.saspro.imageops.serloader import open_planetary_source
1652
+ self.src = open_planetary_source(
1653
+ self.cfg.source,
1654
+ cache_items=20,
1655
+ )
1656
+ self._debayer = bool(debayer)
1657
+ self._bayer_pattern = getattr(self.cfg, "bayer_pattern", None)
1658
+ self._force_rgb = True
1659
+ self.lbl.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1660
+ self.sld.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1661
+ self.btn_prev.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1662
+ self.btn_next.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1663
+ self.btn_toggle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1664
+ self._show_index(0)
1665
+
1666
+ def _toggle_reject_at(self, idx: int):
1667
+ if 0 <= idx < self.rejected.size:
1668
+ self.rejected[idx] = ~self.rejected[idx]
1669
+ self._update_labels()
1670
+
1671
+ def _toggle_reject_and_advance(self, step: int = +1):
1672
+ i = int(self.sld.value())
1673
+ if self.keepers.size <= 0:
1674
+ return
1675
+
1676
+ self._toggle_reject_at(i)
1677
+
1678
+ # advance (clamped)
1679
+ j = i + int(step)
1680
+ j = max(0, min(int(self.keepers.size) - 1, j))
1681
+ self.sld.setValue(j)
1682
+
1683
+ def keyPressEvent(self, e):
1684
+ k = e.key()
1685
+ mods = e.modifiers()
1686
+
1687
+ # ignore if user is holding Ctrl/Alt/Meta (don’t fight standard shortcuts)
1688
+ if mods & (Qt.KeyboardModifier.ControlModifier |
1689
+ Qt.KeyboardModifier.AltModifier |
1690
+ Qt.KeyboardModifier.MetaModifier):
1691
+ super().keyPressEvent(e)
1692
+ return
1693
+
1694
+ if k in (Qt.Key.Key_Right, Qt.Key.Key_Down, Qt.Key.Key_PageDown):
1695
+ self._step(+1)
1696
+ e.accept()
1697
+ return
1698
+
1699
+ if k in (Qt.Key.Key_Left, Qt.Key.Key_Up, Qt.Key.Key_PageUp):
1700
+ self._step(-1)
1701
+ e.accept()
1702
+ return
1703
+
1704
+ # R toggles reject and moves to next
1705
+ if k == Qt.Key.Key_R:
1706
+ self._toggle_reject_and_advance(+1)
1707
+ e.accept()
1708
+ return
1709
+
1710
+ # Space does the same (nice for rapid triage)
1711
+ if k == Qt.Key.Key_Space:
1712
+ self._toggle_reject_and_advance(+1)
1713
+ e.accept()
1714
+ return
1715
+
1716
+ # Optional: backspace toggles and moves back
1717
+ if k == Qt.Key.Key_Backspace:
1718
+ self._toggle_reject_and_advance(-1)
1719
+ e.accept()
1720
+ return
1721
+ if k == Qt.Key.Key_Escape:
1722
+ self.reject()
1723
+ e.accept()
1724
+ return
1725
+ if k in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
1726
+ self.accept()
1727
+ e.accept()
1728
+ return
1729
+ super().keyPressEvent(e)
1730
+
1731
+
1732
+ def _step(self, d: int):
1733
+ i = int(self.sld.value()) + int(d)
1734
+ i = max(0, min(int(self.keepers.size) - 1, i))
1735
+ self.sld.setValue(i)
1736
+
1737
+ def _toggle_reject_current(self, checked: bool):
1738
+ i = int(self.sld.value())
1739
+ if 0 <= i < self.rejected.size:
1740
+ self.rejected[i] = bool(checked)
1741
+ self._update_labels()
1742
+
1743
+ def _update_labels(self):
1744
+ i = int(self.sld.value())
1745
+ fi = int(self.keepers[i]) if self.keepers.size else 0
1746
+ q = float(self.analysis.quality[fi]) if self.analysis.quality is not None else 0.0
1747
+
1748
+ is_rej = bool(self.rejected[i]) if self.rejected.size else False
1749
+
1750
+ self.lbl_pos.setText(f"{i+1}/{int(self.keepers.size)}")
1751
+ self.lbl_info.setText(
1752
+ f"Keeper #{i+1} | Frame index: {fi} | Quality: {q:.6g} | "
1753
+ f"{'REJECTED' if is_rej else 'KEEP'}"
1754
+ )
1755
+
1756
+ # ✅ colorize text when rejected
1757
+ if is_rej:
1758
+ self.lbl_info.setStyleSheet("color:#f66; font-weight:600;") # red
1759
+ self.lbl_pos.setStyleSheet("color:#f66; min-width:90px;")
1760
+ # optional: make the button look “danger”
1761
+ self.btn_toggle.setStyleSheet("background:#3a1111; color:#f66;")
1762
+ else:
1763
+ self.lbl_info.setStyleSheet("color:#bbb;")
1764
+ self.lbl_pos.setStyleSheet("color:#aaa; min-width:90px;")
1765
+ self.btn_toggle.setStyleSheet("") # back to default
1766
+
1767
+ block = self.btn_toggle.blockSignals(True)
1768
+ try:
1769
+ self.btn_toggle.setChecked(is_rej)
1770
+ self.btn_toggle.setText("Un-reject" if is_rej else "Reject")
1771
+ finally:
1772
+ self.btn_toggle.blockSignals(block)
1773
+
1774
+
1775
+ @staticmethod
1776
+ def _disp_u8(mono01: np.ndarray) -> np.ndarray:
1777
+ mono = np.asarray(mono01, dtype=np.float32)
1778
+ mono = np.clip(mono, 0.0, 1.0)
1779
+ lo = float(np.percentile(mono, 1.0))
1780
+ hi = float(np.percentile(mono, 99.5))
1781
+ if hi <= lo + 1e-8:
1782
+ hi = lo + 1e-3
1783
+ v = (mono - lo) / (hi - lo)
1784
+ v = np.clip(v, 0.0, 1.0)
1785
+ return (v * 255.0 + 0.5).astype(np.uint8)
1786
+
1787
+ def _show_index(self, i: int):
1788
+ if self.keepers.size == 0:
1789
+ return
1790
+ i = int(max(0, min(int(self.keepers.size) - 1, int(i))))
1791
+ fi = int(self.keepers[i])
1792
+
1793
+ roi = getattr(self.cfg, "roi", None)
1794
+ img = self.src.get_frame(
1795
+ fi,
1796
+ roi=roi,
1797
+ debayer=bool(self._debayer),
1798
+ to_float01=True,
1799
+ force_rgb=bool(self._force_rgb),
1800
+ bayer_pattern=getattr(self, "_bayer_pattern", None),
1801
+ ).astype(np.float32, copy=False)
1802
+
1803
+ # ✅ apply analyze global alignment (same as stack_ser does first)
1804
+ gdx = float(self.analysis.dx[int(fi)]) if (getattr(self.analysis, "dx", None) is not None) else 0.0
1805
+ gdy = float(self.analysis.dy[int(fi)]) if (getattr(self.analysis, "dy", None) is not None) else 0.0
1806
+ img = _shift_image(img, gdx, gdy)
1807
+
1808
+ # display mono channel
1809
+ if img.ndim == 3:
1810
+ img = img[..., 0]
1811
+
1812
+ u8 = self._disp_u8(img)
1813
+ h, w = u8.shape
1814
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
1815
+ pm = QPixmap.fromImage(qimg.copy())
1816
+
1817
+ self.lbl.setPixmap(pm.scaled(
1818
+ self.lbl.size(),
1819
+ Qt.AspectRatioMode.KeepAspectRatio,
1820
+ Qt.TransformationMode.SmoothTransformation
1821
+ ))
1822
+ self._update_labels()
1823
+
1824
+
1825
+ def resizeEvent(self, e):
1826
+ super().resizeEvent(e)
1827
+ self._show_index(self.sld.value())
1828
+
1829
+ def keep_mask_all_frames(self) -> np.ndarray:
1830
+ """
1831
+ Convert keeper+rejected into a full N-length keep mask.
1832
+ """
1833
+ km = np.zeros((self.N,), dtype=bool)
1834
+ km[self.keepers] = True
1835
+ # turn off rejected keepers
1836
+ if self.keepers.size:
1837
+ km[self.keepers[self.rejected]] = False
1838
+ return km