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