setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__py3-none-any.whl

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