setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,1865 @@
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
+ # ✅ memmap/FITS-safe: QImage assumes row-major contiguous when we pass bytesPerLine=w
446
+ if not u8.flags["C_CONTIGUOUS"]:
447
+ u8 = np.ascontiguousarray(u8)
448
+
449
+ h, w = u8.shape[:2]
450
+
451
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
452
+ base_pm = QPixmap.fromImage(qimg) # already detached above
453
+
454
+ # scale to display zoom (keeps UI sane)
455
+ zw = max(1, int(round(w * self._zoom)))
456
+ zh = max(1, int(round(h * self._zoom)))
457
+ pm = base_pm.scaled(
458
+ zw, zh,
459
+ Qt.AspectRatioMode.IgnoreAspectRatio,
460
+ Qt.TransformationMode.SmoothTransformation
461
+ )
462
+
463
+ # draw AP boxes in *display coords* so thickness doesn't scale
464
+ p = QPainter(pm)
465
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
466
+
467
+ s_img = int(max(8, self._ap_size))
468
+ s_disp = max(2, int(round(s_img * self._zoom)))
469
+ half_disp = s_disp // 2
470
+
471
+ pen = QPen(QColor(0, 255, 0), 2) # constant on-screen thickness
472
+ p.setPen(pen)
473
+
474
+ if self._centers is not None and self._centers.size > 0:
475
+ for cx, cy in self._centers.tolist():
476
+ x = int(round(cx * self._zoom))
477
+ y = int(round(cy * self._zoom))
478
+ p.drawRect(int(x - half_disp), int(y - half_disp), int(s_disp), int(s_disp))
479
+
480
+ p.end()
481
+
482
+ self._pix.setPixmap(pm)
483
+ self._pix.setFixedSize(pm.size())
484
+
485
+ # ---------- actions ----------
486
+ def _do_autoplace(self):
487
+ from setiastro.saspro.ser_stacker import _autoplace_aps # reuse exact logic
488
+ self._centers = _autoplace_aps(self._ref, self._ap_size, self._ap_spacing, self._ap_min_mean)
489
+ self._render()
490
+
491
+ def _do_clear(self):
492
+ self._centers = np.zeros((0, 2), dtype=np.int32)
493
+ self._render()
494
+
495
+ # ---------- mouse handling ----------
496
+ def _on_mouse_press(self, ev):
497
+ pm = self._pix.pixmap()
498
+ if pm is None:
499
+ return
500
+
501
+ # display coords in the label
502
+ dx = float(ev.position().x())
503
+ dy = float(ev.position().y())
504
+
505
+ # map to image coords
506
+ ix = int(round(dx / max(1e-6, self._zoom)))
507
+ iy = int(round(dy / max(1e-6, self._zoom)))
508
+
509
+ # clamp to image bounds
510
+ ix = max(0, min(self._W - 1, ix))
511
+ iy = max(0, min(self._H - 1, iy))
512
+
513
+ if ev.button() == Qt.MouseButton.LeftButton:
514
+ self._add_point(ix, iy)
515
+ elif ev.button() == Qt.MouseButton.RightButton:
516
+ self._delete_nearest(ix, iy)
517
+
518
+ def _add_point(self, x: int, y: int):
519
+ s = int(max(8, self._ap_size))
520
+ half = s // 2
521
+
522
+ # ensure AP box fits fully
523
+ x = max(half, min(self._W - 1 - half, x))
524
+ y = max(half, min(self._H - 1 - half, y))
525
+
526
+ if self._centers is None or self._centers.size == 0:
527
+ self._centers = np.asarray([[x, y]], dtype=np.int32)
528
+ else:
529
+ self._centers = np.vstack([self._centers, np.asarray([[x, y]], dtype=np.int32)])
530
+ self._render()
531
+
532
+ def _delete_nearest(self, x: int, y: int):
533
+ if self._centers is None or self._centers.size == 0:
534
+ return
535
+
536
+ pts = self._centers.astype(np.float32)
537
+ d2 = (pts[:, 0] - float(x)) ** 2 + (pts[:, 1] - float(y)) ** 2
538
+ j = int(np.argmin(d2))
539
+
540
+ # radius in image pixels (so behavior is stable regardless of zoom)
541
+ radius = max(10.0, float(self._ap_size) * 0.6)
542
+ if float(d2[j]) <= radius * radius:
543
+ self._centers = np.delete(self._centers, j, axis=0)
544
+ self._render()
545
+
546
+ class QualityGraph(QWidget):
547
+ """
548
+ AS-style quality plot (sorted curve expected):
549
+ - Curve: q[0] best ... q[N-1] worst
550
+ - Vertical cutoff line at keep_k
551
+ - Midrange horizontal line (min/max midpoint)
552
+ - True median horizontal line labeled 'Med'
553
+ - Click / drag adjusts keep line and emits keepChanged(k, N)
554
+ """
555
+ keepChanged = pyqtSignal(int, int) # keep_k, total_N
556
+
557
+ def __init__(self, parent=None):
558
+ super().__init__(parent)
559
+ self._q: np.ndarray | None = None
560
+ self._keep_k: int | None = None
561
+ self.setMinimumHeight(160)
562
+ self._dragging = False
563
+
564
+ def set_data(self, q: np.ndarray | None, keep_k: int | None = None):
565
+ self._q = None if q is None else np.asarray(q, dtype=np.float32)
566
+ self._keep_k = keep_k
567
+ self.update()
568
+
569
+ def _plot_rect(self):
570
+ # room for labels
571
+ return self.rect().adjusted(34, 10, -10, -22)
572
+
573
+ def _x_to_keep_k(self, x: float) -> int | None:
574
+ if self._q is None or self._q.size < 2:
575
+ return None
576
+ r = self._plot_rect()
577
+ if r.width() <= 1:
578
+ return None
579
+ N = int(self._q.size)
580
+
581
+ # clamp x to plot rect
582
+ xx = max(float(r.left()), min(float(r.right()), float(x)))
583
+
584
+ # map x back to index i in [0..N-1]
585
+ t = (xx - float(r.left())) / float(max(1, r.width()))
586
+ i = int(round(t * float(N - 1)))
587
+ i = max(0, min(N - 1, i))
588
+
589
+ # keep_k is count of frames kept => i=0 means keep 1, i=N-1 means keep N
590
+ return int(i + 1)
591
+
592
+ def mousePressEvent(self, ev):
593
+ if ev.button() != Qt.MouseButton.LeftButton:
594
+ return super().mousePressEvent(ev)
595
+ self._dragging = True
596
+ k = self._x_to_keep_k(ev.position().x())
597
+ if k is not None:
598
+ self._keep_k = int(k)
599
+ self.update()
600
+ self.keepChanged.emit(int(k), int(self._q.size)) # type: ignore
601
+ ev.accept()
602
+
603
+ def mouseMoveEvent(self, ev):
604
+ if not self._dragging:
605
+ return super().mouseMoveEvent(ev)
606
+ k = self._x_to_keep_k(ev.position().x())
607
+ if k is not None:
608
+ if self._keep_k != int(k):
609
+ self._keep_k = int(k)
610
+ self.update()
611
+ self.keepChanged.emit(int(k), int(self._q.size)) # type: ignore
612
+ ev.accept()
613
+
614
+ def mouseReleaseEvent(self, ev):
615
+ if ev.button() == Qt.MouseButton.LeftButton:
616
+ self._dragging = False
617
+ ev.accept()
618
+ return
619
+ return super().mouseReleaseEvent(ev)
620
+
621
+ def paintEvent(self, e):
622
+ p = QPainter(self)
623
+ p.fillRect(self.rect(), QColor(20, 20, 20))
624
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
625
+
626
+ r = self._plot_rect()
627
+
628
+ # frame
629
+ p.setPen(QPen(QColor(80, 80, 80), 1))
630
+ p.drawRect(r)
631
+
632
+ if self._q is None or self._q.size < 2:
633
+ p.setPen(QPen(QColor(160, 160, 160), 1))
634
+ p.drawText(r, Qt.AlignmentFlag.AlignCenter, "Analyze to see quality graph")
635
+ p.end()
636
+ return
637
+
638
+ q = self._q
639
+ N = int(q.size)
640
+
641
+ qmin = float(np.min(q))
642
+ qmax = float(np.max(q))
643
+ if qmax <= qmin + 1e-12:
644
+ qmax = qmin + 1e-6
645
+
646
+ def y_for(val: float) -> float:
647
+ return r.bottom() - ((val - qmin) / (qmax - qmin)) * r.height()
648
+
649
+ # ---- horizontal reference lines ----
650
+ # 1) midrange (between min/max) - dashed
651
+ qmid = qmin + 0.5 * (qmax - qmin)
652
+ ymid = y_for(qmid)
653
+ pen_mid = QPen(QColor(120, 120, 120), 1)
654
+ pen_mid.setStyle(Qt.PenStyle.DashLine)
655
+ p.setPen(pen_mid)
656
+ p.drawLine(int(r.left()), int(ymid), int(r.right()), int(ymid))
657
+
658
+ # 2) true median of q - dotted (or dash-dot)
659
+ qmed = float(np.median(q))
660
+ ymed = y_for(qmed)
661
+ pen_med = QPen(QColor(160, 160, 160), 1)
662
+ pen_med.setStyle(Qt.PenStyle.DotLine)
663
+ p.setPen(pen_med)
664
+ p.drawLine(int(r.left()), int(ymed), int(r.right()), int(ymed))
665
+
666
+ # small "Med" label on the right of the median line
667
+ p.setPen(QPen(QColor(180, 180, 180), 1))
668
+ p.drawText(int(r.right()) - 34, int(ymed) - 2, "Med")
669
+
670
+ # ---- curve ----
671
+ p.setPen(QPen(QColor(0, 220, 0), 2))
672
+ lastx = lasty = None
673
+ for i in range(N):
674
+ x = r.left() + (i / (N - 1)) * r.width()
675
+ y = y_for(float(q[i]))
676
+ if lastx is not None:
677
+ p.drawLine(int(lastx), int(lasty), int(x), int(y))
678
+ lastx, lasty = x, y
679
+
680
+ # ---- cutoff line ----
681
+ if self._keep_k is not None and N > 1:
682
+ k = int(max(1, min(N, int(self._keep_k))))
683
+ xcut = r.left() + ((k - 1) / (N - 1)) * r.width()
684
+ p.setPen(QPen(QColor(255, 220, 0), 2))
685
+ p.drawLine(int(xcut), int(r.top()), int(xcut), int(r.bottom()))
686
+
687
+ # ---- labels ----
688
+ p.setPen(QPen(QColor(180, 180, 180), 1))
689
+ p.drawText(
690
+ self.rect().adjusted(6, 0, 0, 0),
691
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
692
+ "Best",
693
+ )
694
+ p.drawText(
695
+ self.rect().adjusted(0, 0, -6, 0),
696
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom,
697
+ "Worst",
698
+ )
699
+
700
+ # y labels: max, mid, median, min
701
+ p.drawText(4, int(r.top()) + 10, f"{qmax:.3g}")
702
+ p.drawText(4, int(ymid) + 4, f"{qmid:.3g}") # midrange
703
+ p.drawText(4, int(ymed) + 4, f"{qmed:.3g}") # true median
704
+ p.drawText(4, int(r.bottom()), f"{qmin:.3g}")
705
+
706
+ p.end()
707
+
708
+ class _AnalyzeWorker(QThread):
709
+ progress = pyqtSignal(int, int, str) # done, total, phase
710
+ finished_ok = pyqtSignal(object)
711
+ failed = pyqtSignal(str)
712
+
713
+ def __init__(self, cfg: SERStackConfig, *, debayer: bool, to_rgb: bool, ref_mode: str, ref_count: int):
714
+ super().__init__()
715
+ self.cfg = cfg
716
+ self.debayer = bool(debayer)
717
+ self.to_rgb = bool(to_rgb)
718
+ self.ref_mode = ref_mode
719
+ self.ref_count = int(ref_count)
720
+ self._cancel = False
721
+ self._worker_realign: _ReAlignWorker | None = None
722
+
723
+ def run(self):
724
+ try:
725
+ def cb(done: int, total: int, phase: str):
726
+ self.progress.emit(int(done), int(total), str(phase))
727
+
728
+ ar = analyze_ser(
729
+ self.cfg,
730
+ debayer=self.debayer,
731
+ to_rgb=self.to_rgb,
732
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None),
733
+ ref_mode=self.ref_mode,
734
+ ref_count=self.ref_count,
735
+ progress_cb=cb,
736
+ )
737
+ self.finished_ok.emit(ar)
738
+ except Exception as e:
739
+ msg = f"{e}\n\n{traceback.format_exc()}"
740
+ self.failed.emit(msg)
741
+
742
+ class _StackWorker(QThread):
743
+ progress = pyqtSignal(int, int, str) # ✅ add this
744
+ finished_ok = pyqtSignal(object, object) # out(np.ndarray), diag(dict)
745
+ failed = pyqtSignal(str)
746
+
747
+ def __init__(self, cfg: SERStackConfig, analysis: AnalyzeResult | None, *, debayer: bool, to_rgb: bool):
748
+ super().__init__()
749
+ self.cfg = cfg
750
+ self.analysis = analysis
751
+ self.debayer = bool(debayer)
752
+ self.to_rgb = bool(to_rgb)
753
+
754
+ def run(self):
755
+ try:
756
+ print(f"tracking mode = {getattr(self.cfg, 'track_mode', 'planetary')}")
757
+ def cb(done: int, total: int, phase: str):
758
+ self.progress.emit(int(done), int(total), str(phase))
759
+
760
+ out, diag = stack_ser(
761
+ self.cfg.source,
762
+ roi=self.cfg.roi,
763
+ debayer=self.debayer,
764
+ to_rgb=self.to_rgb,
765
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None), # ✅ add this
766
+ keep_percent=float(getattr(self.cfg, "keep_percent", 20.0)),
767
+ track_mode=str(getattr(self.cfg, "track_mode", "planetary")),
768
+ surface_anchor=getattr(self.cfg, "surface_anchor", None),
769
+ analysis=self.analysis,
770
+ local_warp=True,
771
+ progress_cb=cb,
772
+ drizzle_scale=float(getattr(self.cfg, "drizzle_scale", 1.0)),
773
+ drizzle_pixfrac=float(getattr(self.cfg, "drizzle_pixfrac", 0.80)),
774
+ drizzle_kernel=str(getattr(self.cfg, "drizzle_kernel", "gaussian")),
775
+ drizzle_sigma=float(getattr(self.cfg, "drizzle_sigma", 0.0)),
776
+ keep_mask=getattr(self.cfg, "keep_mask", None),
777
+ )
778
+
779
+
780
+ self.finished_ok.emit(out, diag)
781
+ except Exception as e:
782
+ msg = f"{e}\n\n{traceback.format_exc()}"
783
+ self.failed.emit(msg)
784
+
785
+
786
+ class _ReAlignWorker(QThread):
787
+ progress = pyqtSignal(int, int, str) # done, total, phase
788
+ finished_ok = pyqtSignal(object) # updated AnalyzeResult
789
+ failed = pyqtSignal(str)
790
+
791
+ def __init__(self, cfg: SERStackConfig, analysis: AnalyzeResult, *, debayer: bool, to_rgb: bool):
792
+ super().__init__()
793
+ self.cfg = cfg
794
+ self.analysis = analysis
795
+ self.debayer = bool(debayer)
796
+ self.to_rgb = bool(to_rgb)
797
+
798
+ def run(self):
799
+ try:
800
+ def cb(done: int, total: int, phase: str):
801
+ self.progress.emit(int(done), int(total), str(phase))
802
+
803
+ from setiastro.saspro.ser_stacker import realign_ser # you’ll add this below
804
+ out_analysis = realign_ser(
805
+ self.cfg,
806
+ self.analysis,
807
+ debayer=self.debayer,
808
+ to_rgb=self.to_rgb,
809
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None),
810
+ progress_cb=cb,
811
+ )
812
+ self.finished_ok.emit(out_analysis)
813
+ except Exception as e:
814
+ self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
815
+
816
+
817
+ class SERStackerDialog(QDialog):
818
+ """
819
+ Dedicated stacking UI (AutoStakkert-like direction):
820
+ - Keeps viewer separate from stacking.
821
+ - V1: track mode, keep %, uses ROI + optional surface anchor from viewer.
822
+ - Later: alignment points (manual/auto), quality graph, drizzle, etc.
823
+ """
824
+
825
+ # Main app can connect this to "push to new view"
826
+ stackProduced = pyqtSignal(object, object) # out(np.ndarray), diag(dict)
827
+
828
+ def __init__(
829
+ self,
830
+ parent=None,
831
+ *,
832
+ main,
833
+ source_doc=None,
834
+ ser_path: Optional[str] = None, # ✅ typed + default
835
+ source: Optional[SourceSpec] = None,
836
+ roi=None,
837
+ track_mode: str = "planetary",
838
+ surface_anchor=None,
839
+ debayer: bool = True,
840
+ keep_percent: float = 20.0,
841
+ bayer_pattern: Optional[str] = None,
842
+ planet_min_val=0.02, planet_use_norm=False, planet_norm_hi_pct=99.5,
843
+ planet_thresh_pct=92.0, planet_smooth_sigma=1.5, **kwargs
844
+ ):
845
+ super().__init__(parent)
846
+ self.setWindowTitle("Planetary Stacker - Beta")
847
+ self.setWindowFlag(Qt.WindowType.Window, True)
848
+ self.setWindowModality(Qt.WindowModality.NonModal)
849
+ self.setModal(False)
850
+ self._bayer_pattern = bayer_pattern
851
+ self._keep_mask = None # np.ndarray bool shape (N,) or None
852
+
853
+ self._planet_min_val = float(planet_min_val)
854
+ self._planet_use_norm = bool(planet_use_norm)
855
+ self._planet_norm_hi_pct = float(planet_norm_hi_pct)
856
+ self._planet_thresh_pct = float(planet_thresh_pct)
857
+ self._planet_smooth_sigma = float(planet_smooth_sigma)
858
+
859
+ # ---- Normalize inputs ------------------------------------------------
860
+ # If caller provided only `source`, treat string-source as ser_path too.
861
+ if source is None:
862
+ source = ser_path
863
+
864
+ # If source is a single path string and ser_path is missing, fill it.
865
+ if ser_path is None and isinstance(source, str) and source:
866
+ ser_path = source
867
+
868
+ if source is None:
869
+ raise ValueError("SERStackerDialog requires source (path or list of paths).")
870
+
871
+ self._main = main
872
+ self._source = source
873
+ self._source_doc = source_doc
874
+ self.setMinimumWidth(980) # or 1024 if you want it beefier
875
+ self.resize(1040, 720) # initial size (width, height)
876
+ # IMPORTANT: now _ser_path is never empty for the common SER case
877
+ self._ser_path = ser_path
878
+
879
+ self._track_mode = track_mode
880
+ self._roi = roi
881
+ self._surface_anchor = surface_anchor
882
+ self._debayer = bool(debayer)
883
+ self._keep_percent = float(keep_percent)
884
+
885
+ self._analysis = None
886
+ self._worker_analyze = None
887
+ self._worker = None
888
+ self._last_out = None
889
+ self._last_diag = None
890
+ try:
891
+ if isinstance(self._source, (list, tuple)):
892
+ self._append_log(f"Source: sequence ({len(self._source)} frames) first={self._source[0]}")
893
+ else:
894
+ self._append_log(f"Source: {self._source}")
895
+ except Exception:
896
+ pass
897
+
898
+ self._build_ui()
899
+
900
+ # defaults
901
+ self.cmb_track.setCurrentText(
902
+ "Planetary" if track_mode == "planetary" else ("Surface" if track_mode == "surface" else "Off")
903
+ )
904
+ self.spin_keep.setValue(float(keep_percent))
905
+ self.chk_debayer.setChecked(bool(debayer))
906
+ self._update_anchor_warning()
907
+ try:
908
+ if isinstance(self._source, (list, tuple)):
909
+ self._append_log(f"Source: sequence ({len(self._source)} frames)")
910
+ else:
911
+ self._append_log(f"Source: {self._source}")
912
+ except Exception:
913
+ self._append_log("Source: (unknown)")
914
+ self._append_log(f"ROI: {roi if roi is not None else '(full frame)'}")
915
+ if track_mode == "surface":
916
+ self._append_log(f"Surface anchor (ROI-space): {surface_anchor}")
917
+
918
+ # ---------------- UI ----------------
919
+ def _build_ui(self):
920
+ # ----- Dialog layout -----
921
+ outer = QVBoxLayout(self)
922
+ outer.setContentsMargins(10, 10, 10, 10)
923
+ outer.setSpacing(8)
924
+
925
+ # Split into two columns so we don't exceed monitor height:
926
+ # Left: settings/analyze/actions/progress
927
+ # Right: quality graph + log
928
+ cols = QHBoxLayout()
929
+ cols.setSpacing(10)
930
+ outer.addLayout(cols, 1)
931
+
932
+ left = QVBoxLayout()
933
+ left.setSpacing(8)
934
+ right = QVBoxLayout()
935
+ right.setSpacing(8)
936
+
937
+ cols.addLayout(left, 0)
938
+ cols.addLayout(right, 1)
939
+
940
+ # =========================
941
+ # LEFT COLUMN
942
+ # =========================
943
+
944
+ # --- Stack Settings ---
945
+ gb = QGroupBox("Stack Settings", self)
946
+ form = QFormLayout(gb)
947
+
948
+ self.cmb_track = QComboBox(self)
949
+ self.cmb_track.addItems(["Planetary", "Surface", "Off"])
950
+
951
+ self.spin_keep = QDoubleSpinBox(self)
952
+ self.spin_keep.setRange(0.1, 100.0)
953
+ self.spin_keep.setDecimals(1)
954
+ self.spin_keep.setSingleStep(1.0)
955
+ self.spin_keep.setValue(20.0)
956
+
957
+ self.chk_debayer = QCheckBox("Debayer (Bayer SER)", self)
958
+ self.chk_debayer.setChecked(True)
959
+
960
+ self.lbl_anchor = QLabel("", self)
961
+ self.lbl_anchor.setWordWrap(True)
962
+
963
+ form.addRow("Tracking", self.cmb_track)
964
+ form.addRow("Keep %", self.spin_keep)
965
+ form.addRow("", self.chk_debayer)
966
+ form.addRow("Surface anchor", self.lbl_anchor)
967
+
968
+ left.addWidget(gb, 0)
969
+
970
+ # --- Drizzle ---
971
+ gbD = QGroupBox("Drizzle", self)
972
+ fD = QFormLayout(gbD)
973
+
974
+ self.spin_pixfrac = QDoubleSpinBox(self)
975
+ self.spin_pixfrac.setRange(0.30, 1.00)
976
+ self.spin_pixfrac.setDecimals(2)
977
+ self.spin_pixfrac.setSingleStep(0.05)
978
+ self.spin_pixfrac.setValue(0.80)
979
+
980
+ self.cmb_kernel = QComboBox(self)
981
+ self.cmb_kernel.addItems(["Gaussian", "Circle", "Square"])
982
+
983
+ self.spin_sigma = QDoubleSpinBox(self)
984
+ self.spin_sigma.setRange(0.00, 10.00)
985
+ self.spin_sigma.setDecimals(2)
986
+ self.spin_sigma.setSingleStep(0.05)
987
+ self.spin_sigma.setValue(0.00) # 0 = auto
988
+ self.spin_sigma.setToolTip("Gaussian sigma in output pixels (0 = auto from pixfrac)")
989
+
990
+ # scale row: combo + info button in same row
991
+ scale_row = QHBoxLayout()
992
+ scale_row.setContentsMargins(0, 0, 0, 0)
993
+
994
+ self.cmb_drizzle = QComboBox(self)
995
+ self.cmb_drizzle.addItems(["Off (1x)", "1.5x", "2x"])
996
+
997
+ self.btn_drizzle_info = QToolButton(self)
998
+ self.btn_drizzle_info.setText("?")
999
+ self.btn_drizzle_info.setToolTip("Drizzle info")
1000
+ self.btn_drizzle_info.setFixedSize(22, 22)
1001
+
1002
+ scale_row.addWidget(self.cmb_drizzle, 1)
1003
+ scale_row.addWidget(self.btn_drizzle_info, 0)
1004
+
1005
+ scale_row_w = QWidget(self)
1006
+ scale_row_w.setLayout(scale_row)
1007
+
1008
+ fD.addRow("Scale", scale_row_w)
1009
+ fD.addRow("Pixfrac", self.spin_pixfrac)
1010
+ fD.addRow("Kernel", self.cmb_kernel)
1011
+ fD.addRow("Sigma", self.spin_sigma)
1012
+
1013
+ def _sync_drizzle_ui():
1014
+ t = self.cmb_drizzle.currentText()
1015
+ off = "Off" in t
1016
+ self.spin_pixfrac.setEnabled(not off)
1017
+ self.cmb_kernel.setEnabled(not off)
1018
+
1019
+ k = self.cmb_kernel.currentText().lower()
1020
+ is_gauss = ("gaussian" in k)
1021
+ self.spin_sigma.setEnabled((not off) and is_gauss)
1022
+
1023
+ # sensible defaults when enabling drizzle
1024
+ if off:
1025
+ return
1026
+ if "1.5" in t:
1027
+ if abs(self.spin_pixfrac.value() - 0.80) < 1e-6 or self.spin_pixfrac.value() in (0.70,):
1028
+ self.spin_pixfrac.setValue(0.80)
1029
+ elif "2" in t:
1030
+ if abs(self.spin_pixfrac.value() - 0.70) < 1e-6 or self.spin_pixfrac.value() in (0.80,):
1031
+ self.spin_pixfrac.setValue(0.70)
1032
+
1033
+ self.cmb_drizzle.currentIndexChanged.connect(lambda _=None: _sync_drizzle_ui())
1034
+ self.cmb_kernel.currentIndexChanged.connect(lambda _=None: _sync_drizzle_ui())
1035
+ _sync_drizzle_ui()
1036
+
1037
+ def _show_drizzle_info():
1038
+ QMessageBox.information(
1039
+ self,
1040
+ "Drizzle Info",
1041
+ "Drizzle increases output resolution by resampling and re-depositing pixels.\n\n"
1042
+ "Compute cost:\n"
1043
+ "• 1.5× drizzle ≈ 225% compute (2.25×)\n"
1044
+ "• 2× drizzle ≈ 400% compute (4×)\n\n"
1045
+ "Pixfrac (drop shrink):\n"
1046
+ "• Controls how large each input pixel’s “drop” is in the output grid.\n"
1047
+ "• Lower pixfrac = tighter drops (sharper, but can create gaps/noise).\n"
1048
+ "• Higher pixfrac = smoother coverage (less noise, slightly softer).\n\n"
1049
+ "When drizzle helps:\n"
1050
+ "• Best when you are under-sampled and you have good alignment / many frames.\n"
1051
+ "• Helps most with stable seeing and lots of usable frames.\n\n"
1052
+ "When drizzle may NOT help:\n"
1053
+ "• If you’re already well-sampled (common around f/10–f/20 depending on pixel size),\n"
1054
+ " gains can be minimal.\n"
1055
+ "• If seeing is very poor, drizzle often just magnifies blur/noise.\n\n"
1056
+ "Tip: Start with 1.5× and pixfrac ~0.8. If coverage looks sparse/noisy, increase pixfrac."
1057
+ )
1058
+
1059
+ self.btn_drizzle_info.clicked.connect(_show_drizzle_info)
1060
+
1061
+ left.addWidget(gbD, 0)
1062
+
1063
+ # --- Analyze settings (no graph in left column anymore) ---
1064
+ gbA = QGroupBox("Analyze", self)
1065
+ fA = QFormLayout(gbA)
1066
+
1067
+ self.cmb_ref = QComboBox(self)
1068
+ self.cmb_ref.addItems(["Best frame", "Best stack (N)"])
1069
+
1070
+ self.spin_refN = QSpinBox(self)
1071
+ self.spin_refN.setRange(2, 200)
1072
+ self.spin_refN.setValue(10)
1073
+
1074
+ self.spin_ap_min = QDoubleSpinBox(self)
1075
+ self.spin_ap_min.setRange(0.0, 1.0)
1076
+ self.spin_ap_min.setDecimals(3)
1077
+ self.spin_ap_min.setSingleStep(0.005)
1078
+ self.spin_ap_min.setValue(0.03)
1079
+ fA.addRow("AP min mean (0..1)", self.spin_ap_min)
1080
+
1081
+ self.btn_edit_aps = QPushButton("(2) Edit APs…", self)
1082
+ self.btn_edit_aps.setEnabled(False)
1083
+ fA.addRow("", self.btn_edit_aps)
1084
+
1085
+ self.spin_ap_size = QSpinBox(self)
1086
+ self.spin_ap_size.setRange(16, 256)
1087
+ self.spin_ap_size.setSingleStep(8)
1088
+ self.spin_ap_size.setValue(64)
1089
+
1090
+ self.spin_ap_spacing = QSpinBox(self)
1091
+ self.spin_ap_spacing.setRange(8, 256)
1092
+ self.spin_ap_spacing.setSingleStep(8)
1093
+ self.spin_ap_spacing.setValue(48)
1094
+
1095
+ fA.addRow("Reference", self.cmb_ref)
1096
+ fA.addRow("Ref stack N", self.spin_refN)
1097
+
1098
+ self.cmb_ap_scale = QComboBox(self)
1099
+ self.cmb_ap_scale.addItems(["Single", "Multi-scale (2× / 1× / ½×)"])
1100
+ fA.addRow("AP scale", self.cmb_ap_scale)
1101
+
1102
+ self.chk_ssd_bruteforce = QCheckBox("SSD refine: brute force (slower, can rescue tough data)", self)
1103
+ self.chk_ssd_bruteforce.setChecked(False)
1104
+ fA.addRow("", self.chk_ssd_bruteforce)
1105
+
1106
+ fA.addRow("AP size (px)", self.spin_ap_size)
1107
+ fA.addRow("AP spacing (px)", self.spin_ap_spacing)
1108
+
1109
+ left.addWidget(gbA, 0)
1110
+
1111
+ # --- Action buttons ---
1112
+ row = QHBoxLayout()
1113
+ self.btn_analyze = QPushButton("(1) Analyze", self)
1114
+ self.btn_analyze.setEnabled(True)
1115
+ self.btn_blink = QPushButton("(3) Blink Keepers", self) # ✅ new
1116
+ self.btn_stack = QPushButton("(4) Stack Now", self)
1117
+ self.btn_close = QPushButton("Close", self)
1118
+
1119
+ row.addWidget(self.btn_analyze)
1120
+ row.addStretch(1)
1121
+ row.addWidget(self.btn_blink)
1122
+ row.addStretch(1)
1123
+ row.addWidget(self.btn_stack)
1124
+ row.addWidget(self.btn_close)
1125
+
1126
+ left.addLayout(row, 0)
1127
+
1128
+ # --- Progress ---
1129
+ self.prog = QProgressBar(self)
1130
+ self.prog.setRange(0, 0)
1131
+ self.prog.setVisible(False)
1132
+ left.addWidget(self.prog, 0)
1133
+
1134
+ self.lbl_prog = QLabel("", self)
1135
+ self.lbl_prog.setStyleSheet("color:#aaa;")
1136
+ self.lbl_prog.setVisible(False)
1137
+ left.addWidget(self.lbl_prog, 0)
1138
+
1139
+ left.addStretch(1)
1140
+
1141
+ # =========================
1142
+ # RIGHT COLUMN
1143
+ # =========================
1144
+
1145
+ # --- Quality Graph ---
1146
+ gbQ = QGroupBox("Quality", self)
1147
+ vQ = QVBoxLayout(gbQ)
1148
+ vQ.setContentsMargins(8, 8, 8, 8)
1149
+ vQ.setSpacing(6)
1150
+
1151
+ self.graph = QualityGraph(self)
1152
+ self.graph.setMinimumHeight(180)
1153
+ self.graph.setMinimumWidth(480) # keeps the right column from scrunching
1154
+
1155
+ # small hint under the graph
1156
+ self.lbl_graph_hint = QLabel("Tip: click the graph to set Keep cutoff.", self)
1157
+ self.lbl_graph_hint.setStyleSheet("color:#888; font-size:11px;")
1158
+ self.lbl_graph_hint.setWordWrap(True)
1159
+
1160
+ vQ.addWidget(self.graph, 1)
1161
+ vQ.addWidget(self.lbl_graph_hint, 0)
1162
+
1163
+ right.addWidget(gbQ, 1)
1164
+
1165
+ # --- Log ---
1166
+ gbL = QGroupBox("Log", self)
1167
+ vL = QVBoxLayout(gbL)
1168
+ vL.setContentsMargins(8, 8, 8, 8)
1169
+
1170
+ self.log = QTextEdit(self)
1171
+ self.log.setReadOnly(True)
1172
+ self.log.setMinimumHeight(140)
1173
+ self.log.setPlaceholderText("Log…")
1174
+
1175
+ vL.addWidget(self.log, 1)
1176
+ right.addWidget(gbL, 1)
1177
+
1178
+ # =========================
1179
+ # Signals / wiring
1180
+ # =========================
1181
+
1182
+ self.btn_close.clicked.connect(self.close)
1183
+ self.btn_stack.clicked.connect(self._start_stack)
1184
+ self.btn_blink.clicked.connect(self._blink_keepers)
1185
+ self.cmb_track.currentIndexChanged.connect(self._update_anchor_warning)
1186
+ self.btn_analyze.clicked.connect(self._start_analyze)
1187
+ self.btn_edit_aps.clicked.connect(self._edit_aps)
1188
+ self.spin_keep.valueChanged.connect(self._on_keep_changed)
1189
+
1190
+ # Keep % edits update the cutoff line
1191
+ self.spin_keep.valueChanged.connect(self._update_graph_cutoff)
1192
+
1193
+ # Clicking on the graph updates Keep %
1194
+ def _on_graph_keep_changed(k: int, total: int):
1195
+ total = max(1, int(total))
1196
+ k = max(1, min(total, int(k)))
1197
+ pct = 100.0 * float(k) / float(total)
1198
+
1199
+ block = self.spin_keep.blockSignals(True)
1200
+ try:
1201
+ self.spin_keep.setValue(float(pct))
1202
+ finally:
1203
+ self.spin_keep.blockSignals(block)
1204
+
1205
+ # update graph line (using current analysis ordering)
1206
+ self._update_graph_cutoff()
1207
+ self._append_log(f"Keep set from graph: {pct:.1f}% ({k}/{total})")
1208
+
1209
+ self.graph.keepChanged.connect(_on_graph_keep_changed)
1210
+
1211
+ # ---------------- helpers ----------------
1212
+ def _edit_aps(self):
1213
+ if self._analysis is None:
1214
+ return
1215
+
1216
+ try:
1217
+ dlg = APEditorDialog(
1218
+ self,
1219
+ ref_img01=self._analysis.ref_image,
1220
+ ap_size=int(self.spin_ap_size.value()),
1221
+ ap_spacing=int(self.spin_ap_spacing.value()),
1222
+ ap_min_mean=float(self.spin_ap_min.value()),
1223
+ initial_centers=getattr(self._analysis, "ap_centers", None),
1224
+ )
1225
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1226
+ centers = dlg.ap_centers()
1227
+ self._analysis.ap_centers = centers
1228
+
1229
+ # ✅ pull size/spacing changes from the editor back into the main UI
1230
+ try:
1231
+ self.spin_ap_size.setValue(int(dlg.ap_size()))
1232
+ self.spin_ap_spacing.setValue(int(dlg.ap_spacing()))
1233
+ self.spin_ap_min.setValue(float(dlg.ap_min_mean()))
1234
+ except Exception:
1235
+ pass
1236
+
1237
+ self._append_log(
1238
+ f"APs set: {int(centers.shape[0])} points "
1239
+ f"(size={int(self.spin_ap_size.value())}, spacing={int(self.spin_ap_spacing.value())})"
1240
+ )
1241
+
1242
+ # Recompute alignment only (no full analyze)
1243
+ cfg = self._make_cfg()
1244
+
1245
+ self.lbl_prog.setVisible(True)
1246
+ self.prog.setVisible(True)
1247
+ self.prog.setRange(0, 100)
1248
+ self.prog.setValue(0)
1249
+ self.lbl_prog.setText("Re-aligning with APs…")
1250
+ self.btn_stack.setEnabled(False)
1251
+ self.btn_analyze.setEnabled(False)
1252
+ self.btn_edit_aps.setEnabled(False)
1253
+
1254
+ self._worker_realign = _ReAlignWorker(cfg, self._analysis, debayer=bool(self.chk_debayer.isChecked()), to_rgb=False)
1255
+ self._worker_realign.progress.connect(self._on_analyze_progress) # reuse progress UI
1256
+ self._worker_realign.finished_ok.connect(self._on_realign_ok)
1257
+ self._worker_realign.failed.connect(self._on_analyze_fail)
1258
+ self._worker_realign.start()
1259
+ else:
1260
+ self._append_log("AP edit cancelled.")
1261
+ except Exception as e:
1262
+ tb = traceback.format_exc()
1263
+ QMessageBox.critical(self, "AP Editor Error", f"{e}\n\n{tb}")
1264
+ self._append_log(f"AP editor failed: {e}")
1265
+ self._append_log(tb)
1266
+
1267
+ def _on_realign_ok(self, ar: AnalyzeResult):
1268
+ self._analysis = ar
1269
+
1270
+ self.prog.setVisible(False)
1271
+ self.lbl_prog.setVisible(False)
1272
+
1273
+ self.btn_stack.setEnabled(True)
1274
+ self.btn_analyze.setEnabled(True)
1275
+ self.btn_edit_aps.setEnabled(True)
1276
+ self.btn_close.setEnabled(True)
1277
+
1278
+ self._append_log("Re-align done (dx/dy/conf updated from APs).")
1279
+
1280
+
1281
+
1282
+ def _append_log(self, s: str):
1283
+ try:
1284
+ self.log.append(s)
1285
+ except Exception:
1286
+ pass
1287
+
1288
+ def _track_mode_value(self) -> str:
1289
+ t = self.cmb_track.currentText().strip().lower()
1290
+ if t.startswith("planet"):
1291
+ return "planetary"
1292
+ if t.startswith("surface"):
1293
+ return "surface"
1294
+ return "off"
1295
+
1296
+ def _update_anchor_warning(self):
1297
+ mode = self._track_mode_value()
1298
+ if mode != "surface":
1299
+ self.lbl_anchor.setText("(not used)")
1300
+ self.lbl_anchor.setStyleSheet("color:#888;")
1301
+ return
1302
+
1303
+ if self._surface_anchor is None:
1304
+ self.lbl_anchor.setText("REQUIRED (set in SER Viewer with Ctrl+Shift+drag)")
1305
+ self.lbl_anchor.setStyleSheet("color:#c66;")
1306
+ return
1307
+
1308
+ x, y, w, h = [int(v) for v in self._surface_anchor]
1309
+
1310
+ # Always show ROI-space (that’s what the tracker uses)
1311
+ txt = f"✅ ROI-space: x={x}, y={y}, w={w}, h={h}"
1312
+
1313
+ # If an ROI is set, also show full-frame coords for sanity/debug
1314
+ if self._roi is not None:
1315
+ rx, ry, rw, rh = [int(v) for v in self._roi]
1316
+ fx = rx + x
1317
+ fy = ry + y
1318
+ txt += f" | Full-frame: x={fx}, y={fy}, w={w}, h={h}"
1319
+
1320
+ self.lbl_anchor.setText(txt)
1321
+ self.lbl_anchor.setStyleSheet("color:#4a4;")
1322
+
1323
+
1324
+ # ---------------- actions ----------------
1325
+ def _start_analyze(self):
1326
+ mode = self._track_mode_value()
1327
+ if mode == "surface" and self._surface_anchor is None:
1328
+ self._append_log("Surface mode requires an anchor. Set it in the viewer (Ctrl+Shift+drag).")
1329
+ return
1330
+
1331
+ ref_mode = "best_stack" if self.cmb_ref.currentText().lower().startswith("best stack") else "best_frame"
1332
+ refN = int(self.spin_refN.value()) if ref_mode == "best_stack" else 1
1333
+
1334
+ cfg = self._make_cfg()
1335
+
1336
+ self.btn_analyze.setEnabled(False)
1337
+ self.btn_stack.setEnabled(False)
1338
+ self.btn_close.setEnabled(False)
1339
+ self.lbl_prog.setVisible(True)
1340
+ self.lbl_prog.setText("Analyzing…")
1341
+ self.prog.setVisible(True)
1342
+ self.prog.setRange(0, 100)
1343
+ self.prog.setValue(0)
1344
+
1345
+ self._worker_analyze = _AnalyzeWorker(
1346
+ cfg,
1347
+ debayer=bool(self.chk_debayer.isChecked()),
1348
+ to_rgb=False,
1349
+ ref_mode=ref_mode,
1350
+ ref_count=refN,
1351
+ )
1352
+ self._worker_analyze.finished_ok.connect(self._on_analyze_ok)
1353
+ self._worker_analyze.failed.connect(self._on_analyze_fail)
1354
+ self._worker_analyze.progress.connect(self._on_analyze_progress)
1355
+ self._worker_analyze.start()
1356
+
1357
+
1358
+ def _on_analyze_progress(self, done: int, total: int, phase: str):
1359
+ total = max(1, int(total))
1360
+ done = max(0, min(total, int(done)))
1361
+ pct = int(round(100.0 * done / total))
1362
+ self.prog.setRange(0, 100)
1363
+ self.prog.setValue(pct)
1364
+ self.lbl_prog.setText(f"{phase}: {done}/{total} ({pct}%)")
1365
+
1366
+
1367
+ def _on_analyze_ok(self, ar: AnalyzeResult):
1368
+ self._analysis = ar
1369
+
1370
+ self.prog.setVisible(False)
1371
+ self.lbl_prog.setVisible(False)
1372
+
1373
+ self.btn_analyze.setEnabled(True)
1374
+ self.btn_stack.setEnabled(True)
1375
+ self.btn_blink.setEnabled(True)
1376
+ self.btn_close.setEnabled(True)
1377
+
1378
+ self._append_log(f"Analyze done. frames={ar.frames_total} track={ar.track_mode}")
1379
+ self._append_log(f"Ref: {ar.ref_mode} (N={ar.ref_count})")
1380
+
1381
+ # update graph (time-order) + cutoff marker based on keep%
1382
+ k = int(round(ar.frames_total * (float(self.spin_keep.value()) / 100.0)))
1383
+ k = max(1, min(ar.frames_total, k))
1384
+ q_sorted = ar.quality[ar.order]
1385
+ self.graph.set_data(q_sorted, keep_k=k)
1386
+ self.btn_edit_aps.setEnabled(True)
1387
+
1388
+ def _on_analyze_fail(self, msg: str):
1389
+ self.prog.setVisible(False)
1390
+ self.lbl_prog.setVisible(False)
1391
+
1392
+ self.btn_analyze.setEnabled(True)
1393
+ had_analysis = self._analysis is not None and getattr(self._analysis, "ref_image", None) is not None
1394
+ self.btn_stack.setEnabled(bool(had_analysis))
1395
+ self.btn_edit_aps.setEnabled(bool(had_analysis))
1396
+ self.btn_close.setEnabled(True)
1397
+
1398
+ self._append_log("ANALYZE FAILED:")
1399
+ self._append_log(msg)
1400
+
1401
+ def _update_graph_cutoff(self):
1402
+ if self._analysis is None:
1403
+ return
1404
+ n = int(self._analysis.frames_total)
1405
+ k = int(round(n * (float(self.spin_keep.value()) / 100.0)))
1406
+ k = max(1, min(n, k))
1407
+ q_sorted = self._analysis.quality[self._analysis.order]
1408
+ self.graph.set_data(q_sorted, keep_k=k)
1409
+
1410
+ def _blink_keepers(self):
1411
+ if self._analysis is None:
1412
+ return
1413
+
1414
+ N = int(self._analysis.frames_total)
1415
+ keep_k = int(round(N * (float(self.spin_keep.value()) / 100.0)))
1416
+ keep_k = max(1, min(N, keep_k))
1417
+
1418
+ cfg = self._make_cfg()
1419
+ cfg.keep_mask = getattr(cfg, "keep_mask", None)
1420
+
1421
+ try:
1422
+ dlg = BlinkKeepersDialog(
1423
+ self,
1424
+ cfg=cfg,
1425
+ analysis=self._analysis,
1426
+ debayer=bool(self.chk_debayer.isChecked()),
1427
+ to_rgb=False,
1428
+ keep_k=keep_k,
1429
+ )
1430
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1431
+ km = dlg.keep_mask_all_frames()
1432
+ self._keep_mask = km
1433
+
1434
+ # log stats
1435
+ kept = int(np.count_nonzero(km))
1436
+ self._append_log(f"Blink Keepers: kept {kept}/{N} after manual rejects.")
1437
+ else:
1438
+ self._append_log("Blink Keepers cancelled (no changes).")
1439
+ except Exception as e:
1440
+ tb = traceback.format_exc()
1441
+ QMessageBox.critical(self, "Blink Keepers Error", f"{e}\n\n{tb}")
1442
+ self._append_log(f"Blink Keepers failed: {e}")
1443
+ self._append_log(tb)
1444
+
1445
+
1446
+ def _make_cfg(self) -> SERStackConfig:
1447
+ scale_text = self.cmb_drizzle.currentText()
1448
+ if "1.5" in scale_text:
1449
+ drizzle_scale = 1.5
1450
+ elif "2" in scale_text:
1451
+ drizzle_scale = 2.0
1452
+ else:
1453
+ drizzle_scale = 1.0
1454
+
1455
+ drizzle_kernel = self.cmb_kernel.currentText().strip().lower() # gaussian/circle/square
1456
+
1457
+ return SERStackConfig(
1458
+ source=self._source,
1459
+ roi=self._roi,
1460
+ track_mode=self._track_mode_value(),
1461
+ surface_anchor=self._surface_anchor,
1462
+ keep_percent=float(self.spin_keep.value()),
1463
+ bayer_pattern=self._bayer_pattern,
1464
+ keep_mask=getattr(self, "_keep_mask", None),
1465
+
1466
+ ap_size=int(self.spin_ap_size.value()),
1467
+ ap_spacing=int(self.spin_ap_spacing.value()),
1468
+ ap_min_mean=float(self.spin_ap_min.value()),
1469
+ ap_multiscale=(self.cmb_ap_scale.currentIndex() == 1),
1470
+ ssd_refine_bruteforce=bool(
1471
+ getattr(self, "chk_ssd_bruteforce", None) and self.chk_ssd_bruteforce.isChecked()
1472
+ ),
1473
+
1474
+ # ✅ NEW: planetary centroid knobs (add UI controls or set defaults)
1475
+ planet_smooth_sigma=self._planet_smooth_sigma,
1476
+ planet_thresh_pct=self._planet_thresh_pct,
1477
+ planet_min_val=self._planet_min_val,
1478
+ planet_use_norm=self._planet_use_norm,
1479
+ planet_norm_hi_pct=self._planet_norm_hi_pct,
1480
+
1481
+ # drizzle
1482
+ drizzle_scale=float(drizzle_scale),
1483
+ drizzle_pixfrac=float(self.spin_pixfrac.value()),
1484
+ drizzle_kernel=str(drizzle_kernel),
1485
+ drizzle_sigma=float(self.spin_sigma.value()),
1486
+ )
1487
+
1488
+ def _on_keep_changed(self, _v):
1489
+ self._keep_mask = None
1490
+
1491
+ def _start_stack(self):
1492
+ mode = self._track_mode_value()
1493
+ if mode == "surface" and self._surface_anchor is None:
1494
+ self._append_log("Surface mode requires an anchor. Set it in the viewer (Ctrl+Shift+drag).")
1495
+ return
1496
+
1497
+ cfg = self._make_cfg()
1498
+ cfg.keep_mask = self._keep_mask
1499
+ debayer = bool(self.chk_debayer.isChecked())
1500
+
1501
+ self.btn_stack.setEnabled(False)
1502
+ self.btn_close.setEnabled(False)
1503
+ self.btn_analyze.setEnabled(False)
1504
+ self.btn_edit_aps.setEnabled(False)
1505
+ scale_text = self.cmb_drizzle.currentText()
1506
+ if "1.5" in scale_text:
1507
+ drizzle_scale = 1.5
1508
+ elif "2" in scale_text:
1509
+ drizzle_scale = 2.0
1510
+ else:
1511
+ drizzle_scale = 1.0
1512
+
1513
+ drizzle_kernel = self.cmb_kernel.currentText().strip().lower() # "gaussian"/"circle"/"square"
1514
+ drizzle_pixfrac = float(self.spin_pixfrac.value())
1515
+ drizzle_sigma = float(self.spin_sigma.value())
1516
+ self.lbl_prog.setVisible(True)
1517
+ self.prog.setVisible(True)
1518
+ self.prog.setRange(0, 100)
1519
+ self.prog.setValue(0)
1520
+ self.lbl_prog.setText("Stack: 0%")
1521
+
1522
+ self._append_log("Stacking...")
1523
+
1524
+ self._worker = _StackWorker(cfg, analysis=self._analysis, debayer=debayer, to_rgb=False)
1525
+ self._worker.progress.connect(self._on_analyze_progress) # ✅ reuse your progress handler
1526
+ self._worker.finished_ok.connect(self._on_stack_ok)
1527
+ self._worker.failed.connect(self._on_stack_fail)
1528
+ self._worker.start()
1529
+
1530
+ def _on_stack_ok(self, out, diag):
1531
+ self._last_out = out
1532
+ self._last_diag = diag
1533
+
1534
+ self.prog.setVisible(False)
1535
+ self.lbl_prog.setVisible(False)
1536
+
1537
+ self.btn_stack.setEnabled(True)
1538
+ self.btn_close.setEnabled(True)
1539
+ self.btn_analyze.setEnabled(True)
1540
+ self.btn_edit_aps.setEnabled(True)
1541
+
1542
+ self._append_log(f"Done. Kept {diag.get('frames_kept')} / {diag.get('frames_total')}")
1543
+ self._append_log(f"Track: {diag.get('track_mode')} ROI: {diag.get('roi_used')}")
1544
+
1545
+ # ✅ Create the new stacked document (GUI thread)
1546
+ newdoc = _push_as_new_doc(
1547
+ self._main,
1548
+ self._source_doc,
1549
+ out,
1550
+ title_suffix="_stack",
1551
+ source="Planetary Stacker",
1552
+ source_path=self._source,
1553
+ )
1554
+
1555
+ # Optional: stash diag on the document metadata (handy later)
1556
+ if newdoc is not None:
1557
+ try:
1558
+ md = getattr(newdoc, "metadata", None)
1559
+ if md is None:
1560
+ md = {}
1561
+ setattr(newdoc, "metadata", md)
1562
+ md["ser_stack_diag"] = diag
1563
+ except Exception:
1564
+ pass
1565
+
1566
+ # Keep emitting too (so other callers can hook it)
1567
+ self.stackProduced.emit(out, diag)
1568
+
1569
+
1570
+ def _on_stack_fail(self, msg: str):
1571
+ self.prog.setVisible(False)
1572
+ self.btn_stack.setEnabled(True)
1573
+ self.btn_close.setEnabled(True)
1574
+ self.btn_analyze.setEnabled(True)
1575
+ self._append_log("FAILED:")
1576
+ self._append_log(msg)
1577
+
1578
+ class BlinkKeepersDialog(QDialog):
1579
+ """
1580
+ Blink through the frames currently selected to keep, allow user to reject any.
1581
+ Returns a keep_mask (bool) for ALL frames, True=keep.
1582
+ """
1583
+ def __init__(self, parent=None, *, cfg: SERStackConfig, analysis: AnalyzeResult,
1584
+ debayer: bool, to_rgb: bool, keep_k: int):
1585
+ super().__init__(parent)
1586
+ self.setWindowTitle("Blink Keepers")
1587
+ self.resize(1000, 750)
1588
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
1589
+ self.setFocus() # so keypress works immediately
1590
+ self.cfg = cfg
1591
+ self.analysis = analysis
1592
+ self.debayer = bool(debayer)
1593
+ self.to_rgb = bool(to_rgb)
1594
+
1595
+ self.N = int(analysis.frames_total)
1596
+ keep_k = max(1, min(self.N, int(keep_k)))
1597
+
1598
+ # keeper frame indices in original frame space
1599
+ self.keepers = np.asarray(analysis.order[:keep_k], dtype=np.int32)
1600
+
1601
+ # rejection only over the keeper list
1602
+ self.rejected = np.zeros((self.keepers.size,), dtype=bool)
1603
+
1604
+ # ---- UI ----
1605
+ outer = QVBoxLayout(self)
1606
+ outer.setContentsMargins(10, 10, 10, 10)
1607
+ outer.setSpacing(8)
1608
+
1609
+ self.lbl = QLabel(self)
1610
+ self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
1611
+ self.lbl.setStyleSheet("background:#111;")
1612
+ self.lbl.setMinimumHeight(480)
1613
+ outer.addWidget(self.lbl, 1)
1614
+ # --- Instructions / shortcuts ---
1615
+ self.lbl_help = QLabel(self)
1616
+ self.lbl_help.setWordWrap(True)
1617
+ self.lbl_help.setStyleSheet(
1618
+ "color:#9aa; background:#151515; border:1px solid #2a2a2a;"
1619
+ "border-radius:6px; padding:6px; font-size:11px;"
1620
+ )
1621
+ self.lbl_help.setText(
1622
+ "Shortcuts: "
1623
+ "←/→ (or ↑/↓) = Prev/Next | PgUp/PgDn = Prev/Next\n"
1624
+ "R or Space = Toggle Reject + Next | Backspace = Toggle Reject + Prev\n"
1625
+ "Esc = Cancel | Enter = OK"
1626
+ )
1627
+ outer.addWidget(self.lbl_help, 0)
1628
+ info_row = QHBoxLayout()
1629
+ self.lbl_info = QLabel("", self)
1630
+ self.lbl_info.setStyleSheet("color:#bbb;")
1631
+ self.lbl_info.setWordWrap(True)
1632
+ info_row.addWidget(self.lbl_info, 1)
1633
+
1634
+ self.btn_toggle = QPushButton("Reject", self)
1635
+ self.btn_toggle.setCheckable(True)
1636
+ info_row.addWidget(self.btn_toggle, 0)
1637
+
1638
+ outer.addLayout(info_row)
1639
+
1640
+ nav = QHBoxLayout()
1641
+ self.btn_prev = QPushButton("◀ Prev", self)
1642
+ self.btn_next = QPushButton("Next ▶", self)
1643
+
1644
+ self.sld = QSlider(Qt.Orientation.Horizontal, self)
1645
+ self.sld.setRange(0, max(0, self.keepers.size - 1))
1646
+ self.sld.setValue(0)
1647
+
1648
+ self.lbl_pos = QLabel("", self)
1649
+ self.lbl_pos.setStyleSheet("color:#aaa; min-width:90px;")
1650
+
1651
+ nav.addWidget(self.btn_prev)
1652
+ nav.addWidget(self.sld, 1)
1653
+ nav.addWidget(self.btn_next)
1654
+ nav.addWidget(self.lbl_pos)
1655
+ outer.addLayout(nav)
1656
+
1657
+ btns = QHBoxLayout()
1658
+ self.btn_ok = QPushButton("OK", self)
1659
+ self.btn_cancel = QPushButton("Cancel", self)
1660
+ btns.addStretch(1)
1661
+ btns.addWidget(self.btn_ok)
1662
+ btns.addWidget(self.btn_cancel)
1663
+ outer.addLayout(btns)
1664
+
1665
+ # ---- signals ----
1666
+ self.btn_cancel.clicked.connect(self.reject)
1667
+ self.btn_ok.clicked.connect(self.accept)
1668
+ self.btn_prev.clicked.connect(lambda: self._step(-1))
1669
+ self.btn_next.clicked.connect(lambda: self._step(+1))
1670
+ self.sld.valueChanged.connect(self._show_index)
1671
+ self.btn_toggle.clicked.connect(lambda: self._toggle_reject_and_advance(+1))
1672
+
1673
+ # ---- load source ----
1674
+ from setiastro.saspro.imageops.serloader import open_planetary_source
1675
+ self.src = open_planetary_source(
1676
+ self.cfg.source,
1677
+ cache_items=20,
1678
+ )
1679
+ self._debayer = bool(debayer)
1680
+ self._bayer_pattern = getattr(self.cfg, "bayer_pattern", None)
1681
+ self._force_rgb = True
1682
+ self.lbl.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1683
+ self.sld.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1684
+ self.btn_prev.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1685
+ self.btn_next.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1686
+ self.btn_toggle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1687
+ self._show_index(0)
1688
+
1689
+ def _toggle_reject_at(self, idx: int):
1690
+ if 0 <= idx < self.rejected.size:
1691
+ self.rejected[idx] = ~self.rejected[idx]
1692
+ self._update_labels()
1693
+
1694
+ def _toggle_reject_and_advance(self, step: int = +1):
1695
+ i = int(self.sld.value())
1696
+ if self.keepers.size <= 0:
1697
+ return
1698
+
1699
+ self._toggle_reject_at(i)
1700
+
1701
+ # advance (clamped)
1702
+ j = i + int(step)
1703
+ j = max(0, min(int(self.keepers.size) - 1, j))
1704
+ self.sld.setValue(j)
1705
+
1706
+ def keyPressEvent(self, e):
1707
+ k = e.key()
1708
+ mods = e.modifiers()
1709
+
1710
+ # ignore if user is holding Ctrl/Alt/Meta (don’t fight standard shortcuts)
1711
+ if mods & (Qt.KeyboardModifier.ControlModifier |
1712
+ Qt.KeyboardModifier.AltModifier |
1713
+ Qt.KeyboardModifier.MetaModifier):
1714
+ super().keyPressEvent(e)
1715
+ return
1716
+
1717
+ if k in (Qt.Key.Key_Right, Qt.Key.Key_Down, Qt.Key.Key_PageDown):
1718
+ self._step(+1)
1719
+ e.accept()
1720
+ return
1721
+
1722
+ if k in (Qt.Key.Key_Left, Qt.Key.Key_Up, Qt.Key.Key_PageUp):
1723
+ self._step(-1)
1724
+ e.accept()
1725
+ return
1726
+
1727
+ # R toggles reject and moves to next
1728
+ if k == Qt.Key.Key_R:
1729
+ self._toggle_reject_and_advance(+1)
1730
+ e.accept()
1731
+ return
1732
+
1733
+ # Space does the same (nice for rapid triage)
1734
+ if k == Qt.Key.Key_Space:
1735
+ self._toggle_reject_and_advance(+1)
1736
+ e.accept()
1737
+ return
1738
+
1739
+ # Optional: backspace toggles and moves back
1740
+ if k == Qt.Key.Key_Backspace:
1741
+ self._toggle_reject_and_advance(-1)
1742
+ e.accept()
1743
+ return
1744
+ if k == Qt.Key.Key_Escape:
1745
+ self.reject()
1746
+ e.accept()
1747
+ return
1748
+ if k in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
1749
+ self.accept()
1750
+ e.accept()
1751
+ return
1752
+ super().keyPressEvent(e)
1753
+
1754
+
1755
+ def _step(self, d: int):
1756
+ i = int(self.sld.value()) + int(d)
1757
+ i = max(0, min(int(self.keepers.size) - 1, i))
1758
+ self.sld.setValue(i)
1759
+
1760
+ def _toggle_reject_current(self, checked: bool):
1761
+ i = int(self.sld.value())
1762
+ if 0 <= i < self.rejected.size:
1763
+ self.rejected[i] = bool(checked)
1764
+ self._update_labels()
1765
+
1766
+ def _update_labels(self):
1767
+ i = int(self.sld.value())
1768
+ fi = int(self.keepers[i]) if self.keepers.size else 0
1769
+ q = float(self.analysis.quality[fi]) if self.analysis.quality is not None else 0.0
1770
+
1771
+ is_rej = bool(self.rejected[i]) if self.rejected.size else False
1772
+
1773
+ self.lbl_pos.setText(f"{i+1}/{int(self.keepers.size)}")
1774
+ self.lbl_info.setText(
1775
+ f"Keeper #{i+1} | Frame index: {fi} | Quality: {q:.6g} | "
1776
+ f"{'REJECTED' if is_rej else 'KEEP'}"
1777
+ )
1778
+
1779
+ # ✅ colorize text when rejected
1780
+ if is_rej:
1781
+ self.lbl_info.setStyleSheet("color:#f66; font-weight:600;") # red
1782
+ self.lbl_pos.setStyleSheet("color:#f66; min-width:90px;")
1783
+ # optional: make the button look “danger”
1784
+ self.btn_toggle.setStyleSheet("background:#3a1111; color:#f66;")
1785
+ else:
1786
+ self.lbl_info.setStyleSheet("color:#bbb;")
1787
+ self.lbl_pos.setStyleSheet("color:#aaa; min-width:90px;")
1788
+ self.btn_toggle.setStyleSheet("") # back to default
1789
+
1790
+ block = self.btn_toggle.blockSignals(True)
1791
+ try:
1792
+ self.btn_toggle.setChecked(is_rej)
1793
+ self.btn_toggle.setText("Un-reject" if is_rej else "Reject")
1794
+ finally:
1795
+ self.btn_toggle.blockSignals(block)
1796
+
1797
+
1798
+ @staticmethod
1799
+ def _disp_u8(mono01: np.ndarray) -> np.ndarray:
1800
+ mono = np.asarray(mono01, dtype=np.float32)
1801
+ mono = np.clip(mono, 0.0, 1.0)
1802
+ lo = float(np.percentile(mono, 1.0))
1803
+ hi = float(np.percentile(mono, 99.5))
1804
+ if hi <= lo + 1e-8:
1805
+ hi = lo + 1e-3
1806
+ v = (mono - lo) / (hi - lo)
1807
+ v = np.clip(v, 0.0, 1.0)
1808
+ return (v * 255.0 + 0.5).astype(np.uint8)
1809
+
1810
+ def _show_index(self, i: int):
1811
+ if self.keepers.size == 0:
1812
+ return
1813
+ i = int(max(0, min(int(self.keepers.size) - 1, int(i))))
1814
+ fi = int(self.keepers[i])
1815
+
1816
+ roi = getattr(self.cfg, "roi", None)
1817
+ img = self.src.get_frame(
1818
+ fi,
1819
+ roi=roi,
1820
+ debayer=bool(self._debayer),
1821
+ to_float01=True,
1822
+ force_rgb=bool(self._force_rgb),
1823
+ bayer_pattern=getattr(self, "_bayer_pattern", None),
1824
+ ).astype(np.float32, copy=False)
1825
+
1826
+ # ✅ apply analyze global alignment (same as stack_ser does first)
1827
+ gdx = float(self.analysis.dx[int(fi)]) if (getattr(self.analysis, "dx", None) is not None) else 0.0
1828
+ gdy = float(self.analysis.dy[int(fi)]) if (getattr(self.analysis, "dy", None) is not None) else 0.0
1829
+ img = _shift_image(img, gdx, gdy)
1830
+
1831
+ # display mono channel
1832
+ if img.ndim == 3:
1833
+ img = img[..., 0]
1834
+
1835
+ u8 = self._disp_u8(img)
1836
+
1837
+ # ✅ memmap/FITS-safe: guarantee tight row stride for bytesPerLine=w
1838
+ if not u8.flags["C_CONTIGUOUS"]:
1839
+ u8 = np.ascontiguousarray(u8)
1840
+
1841
+ h, w = u8.shape
1842
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1843
+ pm = QPixmap.fromImage(qimg)
1844
+
1845
+ self.lbl.setPixmap(pm.scaled(
1846
+ self.lbl.size(),
1847
+ Qt.AspectRatioMode.KeepAspectRatio,
1848
+ Qt.TransformationMode.SmoothTransformation
1849
+ ))
1850
+ self._update_labels()
1851
+
1852
+ def resizeEvent(self, e):
1853
+ super().resizeEvent(e)
1854
+ self._show_index(self.sld.value())
1855
+
1856
+ def keep_mask_all_frames(self) -> np.ndarray:
1857
+ """
1858
+ Convert keeper+rejected into a full N-length keep mask.
1859
+ """
1860
+ km = np.zeros((self.N,), dtype=bool)
1861
+ km[self.keepers] = True
1862
+ # turn off rejected keepers
1863
+ if self.keepers.size:
1864
+ km[self.keepers[self.rejected]] = False
1865
+ return km