setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- 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 +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -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 +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
import time
|
|
5
5
|
import numpy as np
|
|
6
6
|
from PyQt6.QtCore import QTimer
|
|
7
|
-
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel
|
|
7
|
+
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel, QApplication
|
|
8
8
|
|
|
9
9
|
from PyQt6.QtCore import QSettings
|
|
10
10
|
# reuse everything from the UI module
|
|
@@ -91,13 +91,29 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
|
|
|
91
91
|
worker = _ONNXWorker(model, img, patch, overlap, providers)
|
|
92
92
|
worker.progressed.connect(bar.setValue)
|
|
93
93
|
|
|
94
|
+
def _cancel_clicked():
|
|
95
|
+
btn.setEnabled(False)
|
|
96
|
+
btn.setText("Canceling…")
|
|
97
|
+
worker.cancel() # <-- SAFE
|
|
98
|
+
QApplication.processEvents()
|
|
99
|
+
|
|
94
100
|
def _fail(msg: str):
|
|
95
101
|
try:
|
|
96
102
|
if hasattr(main, "_log"):
|
|
97
103
|
main._log(f"❌ Aberration AI failed: {msg}")
|
|
98
104
|
except Exception:
|
|
99
105
|
pass
|
|
100
|
-
|
|
106
|
+
# If canceled, don't pop an error box
|
|
107
|
+
if "Canceled" not in (msg or ""):
|
|
108
|
+
QMessageBox.critical(main, "Aberration AI", msg)
|
|
109
|
+
dlg.close()
|
|
110
|
+
|
|
111
|
+
def _canceled():
|
|
112
|
+
try:
|
|
113
|
+
if hasattr(main, "_log"):
|
|
114
|
+
main._log("⛔ Aberration AI canceled.")
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
101
117
|
dlg.close()
|
|
102
118
|
|
|
103
119
|
def _ok(out: np.ndarray):
|
|
@@ -157,13 +173,23 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
|
|
|
157
173
|
dlg.close()
|
|
158
174
|
|
|
159
175
|
worker.failed.connect(_fail)
|
|
176
|
+
worker.canceled.connect(_canceled) # <-- NEW
|
|
160
177
|
worker.finished_ok.connect(_ok)
|
|
161
178
|
worker.finished.connect(lambda: btn.setEnabled(False))
|
|
162
|
-
|
|
179
|
+
|
|
180
|
+
btn.clicked.connect(_cancel_clicked)
|
|
181
|
+
|
|
182
|
+
# If user closes dialog via window X, also cancel
|
|
183
|
+
dlg.rejected.connect(_cancel_clicked)
|
|
163
184
|
|
|
164
185
|
worker.start()
|
|
165
186
|
dlg.exec()
|
|
166
187
|
|
|
188
|
+
# Ensure the worker is not left running after the modal closes
|
|
189
|
+
if worker.isRunning():
|
|
190
|
+
worker.cancel()
|
|
191
|
+
worker.wait(2000) # don't hang forever; just give it a moment
|
|
192
|
+
|
|
167
193
|
# clear the guard after a brief tick so downstream signals don’t re-open UI
|
|
168
194
|
def _clear():
|
|
169
195
|
for k in ("_aberration_ai_headless_running", "_aberration_ai_guard"):
|
setiastro/saspro/add_stars.py
CHANGED
|
@@ -578,10 +578,9 @@ class AddStarsDialog(QDialog):
|
|
|
578
578
|
|
|
579
579
|
# Emit (target_doc, blended_image)
|
|
580
580
|
self.stars_added.emit(target_doc, self.blended_image.astype(np.float32, copy=False))
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
|
|
584
|
-
|
|
581
|
+
# Close UI after apply
|
|
582
|
+
self.accept() # or: self.close()
|
|
583
|
+
return
|
|
585
584
|
|
|
586
585
|
# Ensure initial fit once shown
|
|
587
586
|
def showEvent(self, ev):
|
|
@@ -606,7 +605,32 @@ def add_stars(main):
|
|
|
606
605
|
|
|
607
606
|
dlg = AddStarsDialog(main, parent=main)
|
|
608
607
|
dlg.stars_added.connect(lambda target, arr: _apply_to_doc(main, target, arr))
|
|
609
|
-
|
|
608
|
+
|
|
609
|
+
# IMPORTANT: keep a strong reference (non-modal show)
|
|
610
|
+
if not hasattr(main, "_tool_dialogs"):
|
|
611
|
+
main._tool_dialogs = []
|
|
612
|
+
main._tool_dialogs.append(dlg)
|
|
613
|
+
|
|
614
|
+
# When the dialog closes, drop the reference
|
|
615
|
+
def _cleanup(_=None, d=dlg):
|
|
616
|
+
try:
|
|
617
|
+
if hasattr(main, "_tool_dialogs") and d in main._tool_dialogs:
|
|
618
|
+
main._tool_dialogs.remove(d)
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
dlg.finished.connect(_cleanup) # QDialog signal
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
try:
|
|
627
|
+
dlg.destroyed.connect(_cleanup) # QObject signal (extra safety)
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
dlg.show()
|
|
632
|
+
dlg.raise_()
|
|
633
|
+
dlg.activateWindow()
|
|
610
634
|
|
|
611
635
|
|
|
612
636
|
def _apply_to_doc(main, doc, arr: np.ndarray):
|
|
@@ -20,47 +20,75 @@ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
|
20
20
|
# ----------------------------
|
|
21
21
|
# Core neutralization function
|
|
22
22
|
# ----------------------------
|
|
23
|
-
def
|
|
23
|
+
def _remove_channel_pedestal(img_rgb01: np.ndarray) -> np.ndarray:
|
|
24
24
|
"""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Remove a per-channel pedestal using the whole image:
|
|
26
|
+
out[...,c] = out[...,c] - min(out[...,c])
|
|
27
|
+
Assumes float32-ish data; returns float32 clipped to [0,1].
|
|
28
|
+
"""
|
|
29
|
+
out = img_rgb01.astype(np.float32, copy=True)
|
|
30
|
+
|
|
31
|
+
mins = np.nanmin(out.reshape(-1, 3), axis=0).astype(np.float32) # (3,)
|
|
32
|
+
# If a channel is all-NaN, nanmin returns NaN; guard it:
|
|
33
|
+
mins = np.where(np.isfinite(mins), mins, 0.0).astype(np.float32)
|
|
34
|
+
|
|
35
|
+
out -= mins.reshape(1, 1, 3)
|
|
36
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def background_neutralize_rgb(
|
|
40
|
+
img: np.ndarray,
|
|
41
|
+
rect_xywh: tuple[int, int, int, int],
|
|
42
|
+
mode: str = "pivot1",
|
|
43
|
+
*,
|
|
44
|
+
remove_pedestal: bool = True,
|
|
45
|
+
) -> np.ndarray:
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
Step 0 (optional): whole-image pedestal removal (per-channel)
|
|
28
49
|
"""
|
|
29
50
|
if img.ndim != 3 or img.shape[2] != 3:
|
|
30
51
|
raise ValueError("Background Neutralization requires a 3-channel RGB image.")
|
|
31
52
|
|
|
32
|
-
|
|
53
|
+
# Step 0: pedestal removal on the WHOLE image (optional)
|
|
54
|
+
out = _remove_channel_pedestal(img) if remove_pedestal else img.astype(np.float32, copy=True)
|
|
55
|
+
|
|
56
|
+
# Resolve sample rect (use pedestal-free image for medians)
|
|
57
|
+
h, w, _ = out.shape
|
|
33
58
|
x, y, rw, rh = rect_xywh
|
|
34
59
|
x = max(0, min(int(x), w - 1))
|
|
35
60
|
y = max(0, min(int(y), h - 1))
|
|
36
61
|
rw = max(1, min(int(rw), w - x))
|
|
37
62
|
rh = max(1, min(int(rh), h - y))
|
|
38
63
|
|
|
39
|
-
sample =
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
sample = out[y:y + rh, x:x + rw, :]
|
|
65
|
+
m = np.median(sample, axis=(0, 1)).astype(np.float32) # (3,)
|
|
66
|
+
t = float(np.mean(m))
|
|
42
67
|
|
|
43
|
-
out = img.copy()
|
|
44
68
|
eps = 1e-8
|
|
45
|
-
|
|
46
|
-
# Vectorized neutralization
|
|
47
|
-
# diff shape: (3,) -> (1, 1, 3)
|
|
48
|
-
diffs = (medians - avg_med).reshape(1, 1, 3)
|
|
49
|
-
|
|
50
|
-
# denom shape: (1, 1, 3)
|
|
51
|
-
denoms = 1.0 - diffs
|
|
52
|
-
|
|
53
|
-
# Avoid div-by-zero (vectorized)
|
|
54
|
-
# logic: if abs(denom) < eps, set to eps (sign matched)
|
|
55
|
-
# We can do this efficiently:
|
|
56
|
-
small_mask = np.abs(denoms) < eps
|
|
57
|
-
denoms[small_mask] = np.where(denoms[small_mask] >= 0, eps, -eps)
|
|
58
|
-
|
|
59
|
-
# Apply formula: (pixel - diff) / denom
|
|
60
|
-
out = (out - diffs) / denoms
|
|
61
|
-
out = np.clip(out, 0.0, 1.0)
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
if mode == "offset":
|
|
71
|
+
delta = (t - m).reshape(1, 1, 3)
|
|
72
|
+
|
|
73
|
+
# cap deltas so we cannot clip
|
|
74
|
+
ch_min = out.reshape(-1, 3).min(axis=0)
|
|
75
|
+
ch_max = out.reshape(-1, 3).max(axis=0)
|
|
76
|
+
delta = np.clip(
|
|
77
|
+
delta,
|
|
78
|
+
(-ch_min + 0.0).reshape(1, 1, 3),
|
|
79
|
+
(1.0 - ch_max).reshape(1, 1, 3)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return np.clip(out + delta, 0.0, 1.0).astype(np.float32, copy=False)
|
|
83
|
+
|
|
84
|
+
# pivot around 1.0 scaling (highlight-protect)
|
|
85
|
+
denom = np.maximum(1.0 - m, eps) # (3,)
|
|
86
|
+
g = (1.0 - t) / denom # (3,)
|
|
87
|
+
g = np.clip(g, 0.0, 10.0) # sanity cap
|
|
88
|
+
|
|
89
|
+
out = 1.0 - (1.0 - out) * g.reshape(1, 1, 3)
|
|
90
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
91
|
+
|
|
64
92
|
|
|
65
93
|
|
|
66
94
|
# ------------------------------------
|
|
@@ -208,14 +236,26 @@ def apply_background_neutral_to_doc(doc, preset: dict | None = None):
|
|
|
208
236
|
raise ValueError("Background Neutralization currently supports RGB images.")
|
|
209
237
|
|
|
210
238
|
if mode == "rect":
|
|
211
|
-
rn = preset.get("rect_norm")
|
|
212
|
-
|
|
239
|
+
rn = preset.get("rect_norm", None)
|
|
240
|
+
|
|
241
|
+
# IMPORTANT: don't do `if not rn` because rn may be a numpy array
|
|
242
|
+
if rn is None:
|
|
213
243
|
raise ValueError("rect mode requires rect_norm=[x,y,w,h] in normalized coords.")
|
|
244
|
+
|
|
245
|
+
# Coerce array-like -> list
|
|
246
|
+
try:
|
|
247
|
+
rn = list(rn)
|
|
248
|
+
except Exception:
|
|
249
|
+
raise ValueError("rect_norm must be an iterable of 4 numbers.")
|
|
250
|
+
|
|
251
|
+
if len(rn) != 4:
|
|
252
|
+
raise ValueError("rect mode requires rect_norm=[x,y,w,h] (len==4).")
|
|
253
|
+
|
|
214
254
|
H, W, _ = base.shape
|
|
215
|
-
x = int(np.clip(rn[0], 0, 1) * W)
|
|
216
|
-
y = int(np.clip(rn[1], 0, 1) * H)
|
|
217
|
-
w = int(np.clip(rn[2], 0, 1) * W)
|
|
218
|
-
h = int(np.clip(rn[3], 0, 1) * H)
|
|
255
|
+
x = int(np.clip(float(rn[0]), 0.0, 1.0) * W)
|
|
256
|
+
y = int(np.clip(float(rn[1]), 0.0, 1.0) * H)
|
|
257
|
+
w = int(np.clip(float(rn[2]), 0.0, 1.0) * W)
|
|
258
|
+
h = int(np.clip(float(rn[3]), 0.0, 1.0) * H)
|
|
219
259
|
rect = (x, y, max(w, 1), max(h, 1))
|
|
220
260
|
else:
|
|
221
261
|
rect = auto_rect_50x50(base)
|
|
@@ -93,13 +93,34 @@ class MetricsPanel(QWidget):
|
|
|
93
93
|
|
|
94
94
|
@staticmethod
|
|
95
95
|
def _compute_one(i_entry):
|
|
96
|
+
"""
|
|
97
|
+
Compute (FWHM, eccentricity, star_count) using SEP on a *2x downsampled*
|
|
98
|
+
mono float32 frame.
|
|
99
|
+
|
|
100
|
+
- Downsample is fixed at 2x (linear), using AREA.
|
|
101
|
+
- FWHM is converted back to full-res pixel units by multiplying by 2.
|
|
102
|
+
Optionally multiply by sqrt(2) if you want to compensate for the
|
|
103
|
+
AREA downsample's effective smoothing (see fwhm_factor below).
|
|
104
|
+
- Eccentricity is scale-invariant.
|
|
105
|
+
- Star count should be closer to full-res if we also scale minarea
|
|
106
|
+
from 16 -> 4 (area scales by 1/4).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
import cv2
|
|
110
|
+
import sep
|
|
111
|
+
|
|
96
112
|
idx, entry = i_entry
|
|
97
|
-
img = entry[
|
|
113
|
+
img = entry["image_data"]
|
|
98
114
|
|
|
99
|
-
# normalize to float32 mono [0..1] exactly like live
|
|
100
115
|
data = np.asarray(img)
|
|
116
|
+
h0, w0 = data.shape[:2]
|
|
117
|
+
|
|
118
|
+
# ----------------------------
|
|
119
|
+
# 1) Normalize to float32 mono [0..1]
|
|
120
|
+
# ----------------------------
|
|
101
121
|
if data.ndim == 3:
|
|
102
122
|
data = data.mean(axis=2)
|
|
123
|
+
|
|
103
124
|
if data.dtype == np.uint8:
|
|
104
125
|
data = data.astype(np.float32) / 255.0
|
|
105
126
|
elif data.dtype == np.uint16:
|
|
@@ -107,35 +128,63 @@ class MetricsPanel(QWidget):
|
|
|
107
128
|
else:
|
|
108
129
|
data = data.astype(np.float32, copy=False)
|
|
109
130
|
|
|
131
|
+
# Guard: SEP expects finite values
|
|
132
|
+
if not np.isfinite(data).all():
|
|
133
|
+
data = np.nan_to_num(data, nan=0.0, posinf=1.0, neginf=0.0).astype(np.float32, copy=False)
|
|
134
|
+
|
|
135
|
+
# ----------------------------
|
|
136
|
+
# 2) Fixed 2x downsample (linear /2)
|
|
137
|
+
# ----------------------------
|
|
138
|
+
# Use integer decimation by resize to preserve speed and consistency.
|
|
139
|
+
new_w = max(16, w0 // 2)
|
|
140
|
+
new_h = max(16, h0 // 2)
|
|
141
|
+
ds = cv2.resize(data, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
|
142
|
+
|
|
143
|
+
# ----------------------------
|
|
144
|
+
# 3) SEP pipeline (same as before, but minarea scaled)
|
|
145
|
+
# ----------------------------
|
|
110
146
|
try:
|
|
111
|
-
|
|
112
|
-
bkg = sep.Background(data)
|
|
147
|
+
bkg = sep.Background(ds)
|
|
113
148
|
back = bkg.back()
|
|
114
149
|
try:
|
|
115
150
|
gr = float(bkg.globalrms)
|
|
116
151
|
except Exception:
|
|
117
|
-
# some SEP builds only expose per-cell rms map
|
|
118
152
|
gr = float(np.median(np.asarray(bkg.rms(), dtype=np.float32)))
|
|
119
153
|
|
|
154
|
+
# minarea: 16 at full-res ~= 4 at 2x downsample (area /4)
|
|
155
|
+
minarea = 4
|
|
156
|
+
|
|
120
157
|
cat = sep.extract(
|
|
121
|
-
|
|
158
|
+
ds - back,
|
|
122
159
|
thresh=7.0,
|
|
123
160
|
err=gr,
|
|
124
|
-
minarea=
|
|
161
|
+
minarea=minarea,
|
|
125
162
|
clean=True,
|
|
126
163
|
deblend_nthresh=32,
|
|
127
164
|
)
|
|
128
165
|
|
|
129
166
|
if len(cat) > 0:
|
|
130
167
|
# FWHM via geometric-mean sigma (old Blink)
|
|
131
|
-
sig = np.sqrt(cat[
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
sig = np.sqrt(cat["a"] * cat["b"]).astype(np.float32, copy=False)
|
|
169
|
+
fwhm_ds = float(np.nanmedian(2.3548 * sig))
|
|
170
|
+
|
|
171
|
+
# ----------------------------
|
|
172
|
+
# 4) Convert FWHM back to full-res
|
|
173
|
+
# ----------------------------
|
|
174
|
+
# Pure geometric reconversion: *2
|
|
175
|
+
# If you want the "noise reduction" compensation you mentioned:
|
|
176
|
+
# multiply by sqrt(2) instead of 2, or 2*sqrt(2) depending on intent.
|
|
177
|
+
#
|
|
178
|
+
# Most consistent with "true full-res pixels" is factor = 2.
|
|
179
|
+
# If you insist on smoothing-compensation, set factor = 2*np.sqrt(2)
|
|
180
|
+
# (because you still have to undo scale, and then add smoothing term).
|
|
181
|
+
fwhm_factor = 2.0 # change to (2.0 * np.sqrt(2.0)) if you really want it
|
|
182
|
+
fwhm = fwhm_ds * fwhm_factor
|
|
183
|
+
|
|
184
|
+
# TRUE eccentricity
|
|
185
|
+
a = np.maximum(cat["a"].astype(np.float32, copy=False), 1e-12)
|
|
186
|
+
b = np.clip(cat["b"].astype(np.float32, copy=False), 0.0, None)
|
|
187
|
+
q = np.clip(b / a, 0.0, 1.0)
|
|
139
188
|
e_true = np.sqrt(np.maximum(0.0, 1.0 - q * q))
|
|
140
189
|
ecc = float(np.nanmedian(e_true))
|
|
141
190
|
|
|
@@ -144,51 +193,46 @@ class MetricsPanel(QWidget):
|
|
|
144
193
|
fwhm, ecc, star_cnt = np.nan, np.nan, 0
|
|
145
194
|
|
|
146
195
|
except Exception:
|
|
147
|
-
# same sentinel behavior as before
|
|
148
196
|
fwhm, ecc, star_cnt = 10.0, 1.0, 0
|
|
149
197
|
|
|
150
|
-
orig_back = entry.get(
|
|
198
|
+
orig_back = entry.get("orig_background", np.nan)
|
|
151
199
|
return idx, fwhm, ecc, orig_back, star_cnt
|
|
152
200
|
|
|
153
201
|
|
|
154
|
-
|
|
155
|
-
|
|
202
|
+
|
|
203
|
+
def compute_all_metrics(self, loaded_images) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Run SEP over the full list in parallel using threads and cache results.
|
|
206
|
+
Uses *downsampled* SEP for speed + lower RAM.
|
|
207
|
+
Returns True if metrics were computed, False if user canceled.
|
|
208
|
+
"""
|
|
209
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
210
|
+
import os
|
|
211
|
+
import numpy as np
|
|
212
|
+
import psutil
|
|
213
|
+
from PyQt6.QtCore import Qt
|
|
214
|
+
from PyQt6.QtWidgets import QProgressDialog, QApplication
|
|
215
|
+
|
|
156
216
|
n = len(loaded_images)
|
|
157
217
|
if n == 0:
|
|
158
|
-
# Clear any previous state and bail
|
|
159
218
|
self._orig_images = []
|
|
160
|
-
self.metrics_data = [np.array([])]*4
|
|
219
|
+
self.metrics_data = [np.array([])] * 4
|
|
161
220
|
self.flags = []
|
|
162
|
-
self._threshold_initialized = [False]*4
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
# Heads-up dialog (as you already had)
|
|
166
|
-
settings = QSettings()
|
|
167
|
-
show = settings.value("metrics/showWarning", True, type=bool)
|
|
168
|
-
if show:
|
|
169
|
-
msg = QMessageBox(self)
|
|
170
|
-
msg.setWindowTitle(self.tr("Heads-up"))
|
|
171
|
-
msg.setText(self.tr(
|
|
172
|
-
"This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
|
|
173
|
-
"Continue?"
|
|
174
|
-
))
|
|
175
|
-
msg.setStandardButtons(QMessageBox.StandardButton.Yes |
|
|
176
|
-
QMessageBox.StandardButton.No)
|
|
177
|
-
cb = QCheckBox(self.tr("Don't show again"), msg)
|
|
178
|
-
msg.setCheckBox(cb)
|
|
179
|
-
if msg.exec() != QMessageBox.StandardButton.Yes:
|
|
180
|
-
return
|
|
181
|
-
if cb.isChecked():
|
|
182
|
-
settings.setValue("metrics/showWarning", False)
|
|
221
|
+
self._threshold_initialized = [False] * 4
|
|
222
|
+
return True
|
|
183
223
|
|
|
184
|
-
#
|
|
185
|
-
|
|
224
|
+
# ----------------------------
|
|
225
|
+
# 1) Allocate result arrays
|
|
226
|
+
# ----------------------------
|
|
227
|
+
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM (full-res px units)
|
|
186
228
|
m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
|
|
187
229
|
m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
|
|
188
230
|
m3 = np.full(n, np.nan, dtype=np.float32) # Star count
|
|
189
|
-
flags = [e.get(
|
|
231
|
+
flags = [e.get("flagged", False) for e in loaded_images]
|
|
190
232
|
|
|
191
|
-
#
|
|
233
|
+
# ----------------------------
|
|
234
|
+
# 2) Progress dialog (Cancel)
|
|
235
|
+
# ----------------------------
|
|
192
236
|
prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
|
|
193
237
|
prog.setWindowModality(Qt.WindowModality.WindowModal)
|
|
194
238
|
prog.setMinimumDuration(0)
|
|
@@ -196,34 +240,76 @@ class MetricsPanel(QWidget):
|
|
|
196
240
|
prog.show()
|
|
197
241
|
QApplication.processEvents()
|
|
198
242
|
|
|
199
|
-
|
|
243
|
+
cpu = os.cpu_count() or 1
|
|
244
|
+
|
|
245
|
+
# ----------------------------
|
|
246
|
+
# 3) Worker sizing by RAM (downsample-aware)
|
|
247
|
+
# ----------------------------
|
|
248
|
+
# Estimate using the same max_dim as _compute_one (default 1024).
|
|
249
|
+
# Use first frame to estimate scale.
|
|
250
|
+
max_dim = int(loaded_images[0].get("metrics_max_dim", 1024))
|
|
251
|
+
h0, w0 = loaded_images[0]["image_data"].shape[:2]
|
|
252
|
+
scale = 1.0
|
|
253
|
+
if max(h0, w0) > max_dim:
|
|
254
|
+
scale = max_dim / float(max(h0, w0))
|
|
255
|
+
|
|
256
|
+
hd = max(16, int(round(h0 * scale)))
|
|
257
|
+
wd = max(16, int(round(w0 * scale)))
|
|
258
|
+
|
|
259
|
+
# float32 mono downsample buffer
|
|
260
|
+
bytes_per = hd * wd * 4
|
|
261
|
+
|
|
262
|
+
# SEP allocates extra maps; budget ~3x to be safe.
|
|
263
|
+
budget_per_worker = int(bytes_per * 3.0)
|
|
264
|
+
|
|
265
|
+
avail = psutil.virtual_memory().available
|
|
266
|
+
max_by_mem = max(1, int(avail / max(budget_per_worker, 1)))
|
|
267
|
+
|
|
268
|
+
# Don’t exceed CPU, and don’t go crazy high even if RAM is huge
|
|
269
|
+
workers = max(1, min(cpu, max_by_mem, 24))
|
|
270
|
+
|
|
200
271
|
tasks = [(i, loaded_images[i]) for i in range(n)]
|
|
201
|
-
done = 0
|
|
272
|
+
done = 0
|
|
273
|
+
canceled = False
|
|
202
274
|
|
|
203
275
|
try:
|
|
204
276
|
with ThreadPoolExecutor(max_workers=workers) as exe:
|
|
205
277
|
futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
|
|
206
278
|
for fut in as_completed(futures):
|
|
207
279
|
if prog.wasCanceled():
|
|
280
|
+
canceled = True
|
|
208
281
|
break
|
|
282
|
+
|
|
209
283
|
try:
|
|
210
284
|
idx, fwhm, ecc, orig_back, star_cnt = fut.result()
|
|
211
285
|
except Exception:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
286
|
+
idx = futures.get(fut, 0)
|
|
287
|
+
fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
|
|
288
|
+
|
|
289
|
+
if 0 <= idx < n:
|
|
290
|
+
m0[idx] = fwhm
|
|
291
|
+
m1[idx] = ecc
|
|
292
|
+
m2[idx] = orig_back
|
|
293
|
+
m3[idx] = float(star_cnt)
|
|
294
|
+
|
|
215
295
|
done += 1
|
|
216
296
|
prog.setValue(done)
|
|
217
297
|
QApplication.processEvents()
|
|
218
298
|
finally:
|
|
219
299
|
prog.close()
|
|
220
300
|
|
|
221
|
-
|
|
301
|
+
if canceled:
|
|
302
|
+
# IMPORTANT: leave caches alone; caller handles clear/return
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
# ----------------------------
|
|
306
|
+
# 4) Stash results
|
|
307
|
+
# ----------------------------
|
|
222
308
|
self._orig_images = loaded_images
|
|
223
309
|
self.metrics_data = [m0, m1, m2, m3]
|
|
224
310
|
self.flags = flags
|
|
225
|
-
self._threshold_initialized = [False]*4
|
|
226
|
-
|
|
311
|
+
self._threshold_initialized = [False] * 4
|
|
312
|
+
return True
|
|
227
313
|
|
|
228
314
|
def plot(self, loaded_images, indices=None):
|
|
229
315
|
"""
|
|
@@ -242,7 +328,16 @@ class MetricsPanel(QWidget):
|
|
|
242
328
|
|
|
243
329
|
# compute & cache on first call or new image list
|
|
244
330
|
if self._orig_images is not loaded_images or self.metrics_data is None:
|
|
245
|
-
self.compute_all_metrics(loaded_images)
|
|
331
|
+
ok = self.compute_all_metrics(loaded_images)
|
|
332
|
+
if not ok or self.metrics_data is None:
|
|
333
|
+
# user declined/canceled -> clear plots and exit cleanly
|
|
334
|
+
for pw, scat, line in zip(self.plots, self.scats, self.lines):
|
|
335
|
+
scat.setData(x=[], y=[])
|
|
336
|
+
line.setPos(0)
|
|
337
|
+
pw.getPlotItem().getViewBox().update()
|
|
338
|
+
pw.repaint()
|
|
339
|
+
return
|
|
340
|
+
|
|
246
341
|
|
|
247
342
|
# default to all indices
|
|
248
343
|
if indices is None:
|
setiastro/saspro/convo.py
CHANGED
|
@@ -1118,13 +1118,14 @@ class ConvoDeconvoDialog(QDialog):
|
|
|
1118
1118
|
if img is None:
|
|
1119
1119
|
QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
|
|
1120
1120
|
return
|
|
1121
|
+
|
|
1121
1122
|
img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
|
|
1122
1123
|
|
|
1123
|
-
sigma
|
|
1124
|
-
minarea
|
|
1125
|
-
sat
|
|
1126
|
-
maxstars= self.sep_maxstars_spin.value
|
|
1127
|
-
half_w
|
|
1124
|
+
sigma = float(self.sep_threshold_slider.value())
|
|
1125
|
+
minarea = int(self.sep_minarea_spin.value()) # ✅
|
|
1126
|
+
sat = float(self.sep_sat_slider.value())
|
|
1127
|
+
maxstars = int(self.sep_maxstars_spin.value()) # ✅
|
|
1128
|
+
half_w = int(self.sep_stamp_spin.value()) # ✅
|
|
1128
1129
|
|
|
1129
1130
|
try:
|
|
1130
1131
|
psf_kernel = estimate_psf_from_image(
|
|
@@ -1136,11 +1137,13 @@ class ConvoDeconvoDialog(QDialog):
|
|
|
1136
1137
|
stamp_half_width=half_w
|
|
1137
1138
|
)
|
|
1138
1139
|
except RuntimeError as e:
|
|
1139
|
-
QMessageBox.critical(self, "PSF Error", str(e))
|
|
1140
|
+
QMessageBox.critical(self, "PSF Error", str(e))
|
|
1141
|
+
return
|
|
1140
1142
|
|
|
1141
1143
|
self._last_stellar_psf = psf_kernel
|
|
1142
1144
|
self._show_stellar_psf_preview(psf_kernel)
|
|
1143
1145
|
|
|
1146
|
+
|
|
1144
1147
|
def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
|
|
1145
1148
|
h, w = psf_kernel.shape
|
|
1146
1149
|
img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
|