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