setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/3dplanet.png +0 -0
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__init__.py +9 -8
- setiastro/saspro/__main__.py +326 -285
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +128 -13
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +4 -1
- setiastro/saspro/gui/main_window.py +326 -46
- setiastro/saspro/gui/mixins/file_mixin.py +41 -18
- setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1429 -0
- setiastro/saspro/layers.py +186 -10
- setiastro/saspro/layers_dock.py +198 -5
- setiastro/saspro/legacy/image_manager.py +10 -4
- setiastro/saspro/legacy/numba_utils.py +1 -1
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/planetprojection.py +3854 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +8 -0
- setiastro/saspro/rgbalign.py +456 -12
- setiastro/saspro/save_options.py +45 -13
- setiastro/saspro/ser_stack_config.py +102 -0
- setiastro/saspro/ser_stacker.py +2327 -0
- setiastro/saspro/ser_stacker_dialog.py +1865 -0
- setiastro/saspro/ser_tracking.py +228 -0
- setiastro/saspro/serviewer.py +1773 -0
- setiastro/saspro/sfcc.py +298 -64
- setiastro/saspro/shortcuts.py +14 -7
- setiastro/saspro/stacking_suite.py +21 -6
- setiastro/saspro/stat_stretch.py +179 -31
- setiastro/saspro/subwindow.py +38 -5
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Auto-generated at build time. Do not edit.
|
|
2
|
-
BUILD_TIMESTAMP = "2026-01-
|
|
3
|
-
APP_VERSION = "1.
|
|
2
|
+
BUILD_TIMESTAMP = "2026-01-20T16:24:15Z"
|
|
3
|
+
APP_VERSION = "1.7.3"
|
|
@@ -7,13 +7,37 @@ import numpy as np
|
|
|
7
7
|
import sys
|
|
8
8
|
import platform # add
|
|
9
9
|
import time
|
|
10
|
+
import subprocess
|
|
10
11
|
|
|
11
12
|
IS_APPLE_ARM = (sys.platform == "darwin" and platform.machine() == "arm64")
|
|
12
13
|
|
|
14
|
+
def _has_nvidia_gpu() -> bool:
|
|
15
|
+
"""Check if system has an NVIDIA GPU (Linux/Windows)."""
|
|
16
|
+
try:
|
|
17
|
+
if platform.system() == "Linux":
|
|
18
|
+
r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, timeout=2)
|
|
19
|
+
return "GPU" in (r.stdout.decode("utf-8", errors="ignore") or "")
|
|
20
|
+
elif platform.system() == "Windows":
|
|
21
|
+
try:
|
|
22
|
+
ps = subprocess.run(
|
|
23
|
+
["powershell", "-NoProfile", "-Command",
|
|
24
|
+
"(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join ';'"],
|
|
25
|
+
capture_output=True, timeout=2
|
|
26
|
+
)
|
|
27
|
+
out = (ps.stdout.decode("utf-8", errors="ignore") or "").lower()
|
|
28
|
+
return "nvidia" in out
|
|
29
|
+
except Exception:
|
|
30
|
+
w = subprocess.run(["wmic", "path", "win32_VideoController", "get", "name"],
|
|
31
|
+
capture_output=True, timeout=2)
|
|
32
|
+
return "nvidia" in (w.stdout.decode("utf-8", errors="ignore") or "").lower()
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return False
|
|
36
|
+
|
|
13
37
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QStandardPaths, QSettings
|
|
14
38
|
from PyQt6.QtWidgets import (
|
|
15
39
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog,
|
|
16
|
-
QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox, QLineEdit
|
|
40
|
+
QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox, QLineEdit, QApplication
|
|
17
41
|
)
|
|
18
42
|
from PyQt6.QtGui import QIcon
|
|
19
43
|
from setiastro.saspro.config import Config
|
|
@@ -145,10 +169,13 @@ def _restore_output(arr: np.ndarray, channels_last: bool, was_uint16: bool, H: i
|
|
|
145
169
|
arr = arr[0] # (H,W)
|
|
146
170
|
return arr
|
|
147
171
|
|
|
148
|
-
def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64,
|
|
172
|
+
def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64,
|
|
173
|
+
progress_cb=None, cancel_cb=None) -> np.ndarray:
|
|
149
174
|
"""
|
|
150
175
|
session: onnxruntime.InferenceSession
|
|
151
176
|
img: mono (H,W) or RGB (H,W,3) numpy array
|
|
177
|
+
|
|
178
|
+
cancel_cb: callable -> bool, return True to cancel
|
|
152
179
|
"""
|
|
153
180
|
arr, channels_last, was_uint16 = _prepare_input(img) # (C,H,W)
|
|
154
181
|
arr, H0, W0 = _pad_C_HW(arr, patch_size)
|
|
@@ -168,11 +195,15 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
|
|
|
168
195
|
for c in range(C):
|
|
169
196
|
for i in hs:
|
|
170
197
|
for j in ws:
|
|
171
|
-
|
|
198
|
+
if cancel_cb and cancel_cb():
|
|
199
|
+
raise RuntimeError("Canceled")
|
|
200
|
+
|
|
201
|
+
patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1,P,P)
|
|
172
202
|
inp = np.ascontiguousarray(patch[np.newaxis, ...], dtype=np.float32) # (1,1,P,P)
|
|
173
203
|
|
|
174
204
|
out_patch = session.run(None, {inp_name: inp})[0] # (1,1,P,P)
|
|
175
205
|
out_patch = np.squeeze(out_patch, axis=0) # (1,P,P)
|
|
206
|
+
|
|
176
207
|
out[c:c+1, i:i+patch_size, j:j+patch_size] += out_patch * win
|
|
177
208
|
wgt[c:c+1, i:i+patch_size, j:j+patch_size] += win
|
|
178
209
|
|
|
@@ -184,7 +215,6 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
|
|
|
184
215
|
arr = out / wgt
|
|
185
216
|
return _restore_output(arr, channels_last, was_uint16, H0, W0)
|
|
186
217
|
|
|
187
|
-
|
|
188
218
|
# ---------- providers ----------
|
|
189
219
|
def pick_providers(auto_gpu=True) -> list[str]:
|
|
190
220
|
"""
|
|
@@ -248,9 +278,11 @@ def _preserve_border(dst: np.ndarray, src: np.ndarray, px: int = 10) -> np.ndarr
|
|
|
248
278
|
|
|
249
279
|
# ---------- worker ----------
|
|
250
280
|
class _ONNXWorker(QThread):
|
|
251
|
-
progressed
|
|
252
|
-
failed
|
|
253
|
-
finished_ok= pyqtSignal(np.ndarray)
|
|
281
|
+
progressed = pyqtSignal(int) # 0..100
|
|
282
|
+
failed = pyqtSignal(str)
|
|
283
|
+
finished_ok = pyqtSignal(np.ndarray)
|
|
284
|
+
canceled = pyqtSignal()
|
|
285
|
+
log_message = pyqtSignal(str) # for console logging
|
|
254
286
|
|
|
255
287
|
def __init__(self, model_path: str, image: np.ndarray, patch: int, overlap: int, providers: list[str]):
|
|
256
288
|
super().__init__()
|
|
@@ -260,33 +292,115 @@ class _ONNXWorker(QThread):
|
|
|
260
292
|
self.overlap = overlap
|
|
261
293
|
self.providers = providers
|
|
262
294
|
self.used_provider = None
|
|
295
|
+
self._cancel = False # cooperative flag
|
|
296
|
+
|
|
297
|
+
def cancel(self):
|
|
298
|
+
# Safe to call from UI thread
|
|
299
|
+
self._cancel = True
|
|
300
|
+
self.requestInterruption()
|
|
301
|
+
|
|
302
|
+
def _is_canceled(self) -> bool:
|
|
303
|
+
return self._cancel or self.isInterruptionRequested()
|
|
263
304
|
|
|
264
305
|
def run(self):
|
|
265
306
|
if ort is None:
|
|
266
307
|
self.failed.emit("onnxruntime is not installed.")
|
|
267
308
|
return
|
|
309
|
+
|
|
310
|
+
# If canceled before start, exit cleanly
|
|
311
|
+
if self._is_canceled():
|
|
312
|
+
self.canceled.emit()
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# Log available providers for debugging
|
|
316
|
+
avail_providers = ort.get_available_providers()
|
|
317
|
+
gpu_providers = [p for p in self.providers if p != "CPUExecutionProvider"]
|
|
318
|
+
has_nvidia = _has_nvidia_gpu()
|
|
319
|
+
|
|
320
|
+
self.log_message.emit(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
|
|
321
|
+
self.log_message.emit(f"🔍 Attempting providers: {', '.join(self.providers)}")
|
|
322
|
+
print(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
|
|
323
|
+
print(f"🔍 Attempting providers: {', '.join(self.providers)}")
|
|
324
|
+
|
|
325
|
+
# Check if NVIDIA GPU is present but CUDA provider is missing
|
|
326
|
+
if has_nvidia and "CUDAExecutionProvider" not in avail_providers:
|
|
327
|
+
msg = ("⚠️ GPU NVIDIA détecté mais CUDAExecutionProvider n'est pas disponible.\n"
|
|
328
|
+
" Vous devez installer 'onnxruntime-gpu' au lieu de 'onnxruntime'.\n"
|
|
329
|
+
" Commande: pip uninstall onnxruntime && pip install onnxruntime-gpu")
|
|
330
|
+
self.log_message.emit(msg)
|
|
331
|
+
print(msg)
|
|
332
|
+
|
|
268
333
|
try:
|
|
269
334
|
sess = ort.InferenceSession(self.model_path, providers=self.providers)
|
|
270
335
|
self.used_provider = (sess.get_providers()[0] if sess.get_providers() else None)
|
|
271
|
-
|
|
336
|
+
# Log successful GPU usage
|
|
337
|
+
if self.used_provider != "CPUExecutionProvider" and gpu_providers:
|
|
338
|
+
msg = f"✅ Aberration AI: Using GPU provider {self.used_provider}"
|
|
339
|
+
self.log_message.emit(msg)
|
|
340
|
+
print(msg)
|
|
341
|
+
elif has_nvidia and self.used_provider == "CPUExecutionProvider":
|
|
342
|
+
msg = ("⚠️ GPU NVIDIA détecté mais utilisation du CPU.\n"
|
|
343
|
+
" Installez 'onnxruntime-gpu' pour utiliser le GPU.")
|
|
344
|
+
self.log_message.emit(msg)
|
|
345
|
+
print(msg)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
# Log the actual error for debugging
|
|
348
|
+
error_msg = str(e)
|
|
349
|
+
msg = f"⚠️ Aberration AI: GPU provider failed: {error_msg}"
|
|
350
|
+
self.log_message.emit(msg)
|
|
351
|
+
print(msg)
|
|
352
|
+
self.log_message.emit(f"Available providers: {', '.join(avail_providers)}")
|
|
353
|
+
print(f"Available providers: {', '.join(avail_providers)}")
|
|
354
|
+
self.log_message.emit(f"Attempted providers: {', '.join(self.providers)}")
|
|
355
|
+
print(f"Attempted providers: {', '.join(self.providers)}")
|
|
356
|
+
|
|
357
|
+
# Check if onnxruntime-gpu is installed (CUDA provider should be available if it is)
|
|
358
|
+
if "CUDAExecutionProvider" in self.providers and "CUDAExecutionProvider" not in avail_providers:
|
|
359
|
+
if has_nvidia:
|
|
360
|
+
msg = ("❌ CUDAExecutionProvider non disponible alors qu'un GPU NVIDIA est présent.\n"
|
|
361
|
+
" Installez 'onnxruntime-gpu': pip uninstall onnxruntime && pip install onnxruntime-gpu")
|
|
362
|
+
else:
|
|
363
|
+
msg = "⚠️ CUDAExecutionProvider not available. You may need to install onnxruntime-gpu instead of onnxruntime."
|
|
364
|
+
self.log_message.emit(msg)
|
|
365
|
+
print(msg)
|
|
366
|
+
|
|
272
367
|
# fallback CPU if GPU fails
|
|
273
368
|
try:
|
|
274
369
|
sess = ort.InferenceSession(self.model_path, providers=["CPUExecutionProvider"])
|
|
275
|
-
self.used_provider = "CPUExecutionProvider"
|
|
370
|
+
self.used_provider = "CPUExecutionProvider"
|
|
371
|
+
msg = f"⚠️ Aberration AI: Falling back to CPU (GPU initialization failed: {error_msg})"
|
|
372
|
+
self.log_message.emit(msg)
|
|
373
|
+
print(msg)
|
|
276
374
|
except Exception as e2:
|
|
277
|
-
self.failed.emit(f"Failed to init ONNX session:\
|
|
375
|
+
self.failed.emit(f"Failed to init ONNX session:\nGPU error: {error_msg}\nCPU error: {e2}")
|
|
278
376
|
return
|
|
279
377
|
|
|
280
378
|
def cb(frac):
|
|
281
379
|
self.progressed.emit(int(frac * 100))
|
|
282
380
|
|
|
283
381
|
try:
|
|
284
|
-
out = run_onnx_tiled(
|
|
382
|
+
out = run_onnx_tiled(
|
|
383
|
+
sess,
|
|
384
|
+
self.image,
|
|
385
|
+
self.patch,
|
|
386
|
+
self.overlap,
|
|
387
|
+
progress_cb=cb,
|
|
388
|
+
cancel_cb=self._is_canceled,
|
|
389
|
+
)
|
|
285
390
|
except Exception as e:
|
|
286
|
-
|
|
391
|
+
# Normalize cancel
|
|
392
|
+
msg = str(e) or "Error"
|
|
393
|
+
if "Canceled" in msg:
|
|
394
|
+
self.canceled.emit()
|
|
395
|
+
else:
|
|
396
|
+
self.failed.emit(msg)
|
|
397
|
+
return
|
|
287
398
|
|
|
288
|
-
self.
|
|
399
|
+
if self._is_canceled():
|
|
400
|
+
self.canceled.emit()
|
|
401
|
+
return
|
|
289
402
|
|
|
403
|
+
self.finished_ok.emit(out)
|
|
290
404
|
|
|
291
405
|
# ---------- dialog ----------
|
|
292
406
|
class AberrationAIDialog(QDialog):
|
|
@@ -758,6 +872,7 @@ class AberrationAIDialog(QDialog):
|
|
|
758
872
|
self._worker.failed.connect(self._on_failed)
|
|
759
873
|
self._worker.finished_ok.connect(self._on_ok)
|
|
760
874
|
self._worker.finished.connect(self._on_worker_finished)
|
|
875
|
+
self._worker.log_message.connect(self._log) # Connect log messages to console
|
|
761
876
|
self._worker.start()
|
|
762
877
|
|
|
763
878
|
|
|
@@ -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"):
|
|
@@ -685,9 +685,51 @@ def render_spikes(output: np.ndarray, stars: List[Star], config: SpikeConfig, ct
|
|
|
685
685
|
|
|
686
686
|
# Main spikes
|
|
687
687
|
if config.intensity > 0:
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
688
|
+
rainbow_str = config.rainbow_spike_intensity if (config.enable_rainbow and config.rainbow_spikes) else 0
|
|
689
|
+
for i in range(int(config.quantity)):
|
|
690
|
+
theta = main_angle_rad + (i * (math.pi * 2) / float(config.quantity))
|
|
691
|
+
cos_t = math.cos(theta)
|
|
692
|
+
sin_t = math.sin(theta)
|
|
693
|
+
|
|
694
|
+
start_x = star.x + cos_t * 0.5
|
|
695
|
+
start_y = star.y + sin_t * 0.5
|
|
696
|
+
end_x = star.x + cos_t * base_length
|
|
697
|
+
end_y = star.y + sin_t * base_length
|
|
698
|
+
|
|
699
|
+
# Standard Spike
|
|
700
|
+
# Base star color, fading to zero alpha
|
|
701
|
+
c_end = (star.color.r/255.0, star.color.g/255.0, star.color.b/255.0, 0.0)
|
|
702
|
+
|
|
703
|
+
# If rainbow enabled, standard spike is dimmed (matches preview logic)
|
|
704
|
+
opacity_mult = 0.4 if rainbow_str > 0 else 1.0
|
|
705
|
+
c_start = (color[0], color[1], color[2], color[3] * opacity_mult)
|
|
706
|
+
|
|
707
|
+
draw_line_gradient(output, start_x, start_y, end_x, end_y,
|
|
708
|
+
c_start, c_end, thickness, config.sharpness)
|
|
709
|
+
|
|
710
|
+
# Rainbow Overlay
|
|
711
|
+
if rainbow_str > 0:
|
|
712
|
+
stops = 10
|
|
713
|
+
for s in range(stops):
|
|
714
|
+
p1 = s / stops
|
|
715
|
+
p2 = (s + 1) / stops
|
|
716
|
+
if p1 > config.rainbow_spike_length:
|
|
717
|
+
break
|
|
718
|
+
|
|
719
|
+
hue = (p1 * 360.0 * config.rainbow_spike_frequency) % 360.0
|
|
720
|
+
a_rainbow = min(1.0, config.intensity * rainbow_str * 2.0) * (1.0 - p1)
|
|
721
|
+
r_seg, g_seg, b_seg = hsl_to_rgb(hue / 360.0, 0.8, 0.6)
|
|
722
|
+
c_seg = (r_seg, g_seg, b_seg, a_rainbow)
|
|
723
|
+
|
|
724
|
+
# Calculate segment positions
|
|
725
|
+
sx = start_x + (end_x - start_x) * p1
|
|
726
|
+
sy = start_y + (end_y - start_y) * p1
|
|
727
|
+
ex = start_x + (end_x - start_x) * p2
|
|
728
|
+
ey = start_y + (end_y - start_y) * p2
|
|
729
|
+
|
|
730
|
+
# Draw rainbow segment with constant color
|
|
731
|
+
draw_line_gradient(output, sx, sy, ex, ey,
|
|
732
|
+
c_seg, c_seg, thickness, 1.0)
|
|
691
733
|
|
|
692
734
|
# Secondary spikes
|
|
693
735
|
if config.secondary_intensity > 0:
|
|
@@ -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,16 +193,26 @@ 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
|
|
|
201
|
+
|
|
202
|
+
|
|
153
203
|
def compute_all_metrics(self, loaded_images) -> bool:
|
|
154
|
-
"""Run SEP over the full list in parallel using threads and cache results.
|
|
155
|
-
Returns True if metrics were computed, False if user declined/canceled.
|
|
156
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
|
+
|
|
157
216
|
n = len(loaded_images)
|
|
158
217
|
if n == 0:
|
|
159
218
|
self._orig_images = []
|
|
@@ -162,62 +221,17 @@ class MetricsPanel(QWidget):
|
|
|
162
221
|
self._threshold_initialized = [False] * 4
|
|
163
222
|
return True
|
|
164
223
|
|
|
165
|
-
def _has_metrics(md):
|
|
166
|
-
try:
|
|
167
|
-
return md is not None and len(md) == 4 and md[0] is not None and len(md[0]) > 0
|
|
168
|
-
except Exception:
|
|
169
|
-
return False
|
|
170
|
-
|
|
171
|
-
settings = QSettings()
|
|
172
|
-
show_warning = settings.value("metrics/showWarning", True, type=bool)
|
|
173
|
-
|
|
174
|
-
if (not show_warning) and (not _has_metrics(getattr(self, "metrics_data", None))):
|
|
175
|
-
settings.setValue("metrics/showWarning", True)
|
|
176
|
-
show_warning = True
|
|
177
|
-
|
|
178
|
-
# ----------------------------
|
|
179
|
-
# 1) Optional warning gate
|
|
180
|
-
# ----------------------------
|
|
181
|
-
if show_warning:
|
|
182
|
-
msg = QMessageBox(self)
|
|
183
|
-
msg.setWindowTitle(self.tr("Heads-up"))
|
|
184
|
-
msg.setText(self.tr(
|
|
185
|
-
"This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
|
|
186
|
-
"Continue?"
|
|
187
|
-
))
|
|
188
|
-
msg.setStandardButtons(
|
|
189
|
-
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
190
|
-
)
|
|
191
|
-
cb = QCheckBox(self.tr("Don't show again"), msg)
|
|
192
|
-
msg.setCheckBox(cb)
|
|
193
|
-
|
|
194
|
-
clicked = msg.exec()
|
|
195
|
-
clicked_yes = (clicked == QMessageBox.StandardButton.Yes)
|
|
196
|
-
|
|
197
|
-
if not clicked_yes:
|
|
198
|
-
# If they said NO, never allow "Don't show again" to lock them out.
|
|
199
|
-
# Keep the warning enabled so they can opt-in later.
|
|
200
|
-
if cb.isChecked():
|
|
201
|
-
settings.setValue("metrics/showWarning", True)
|
|
202
|
-
return False
|
|
203
|
-
|
|
204
|
-
# They said YES: now it's safe to honor "Don't show again"
|
|
205
|
-
if cb.isChecked():
|
|
206
|
-
settings.setValue("metrics/showWarning", False)
|
|
207
|
-
|
|
208
|
-
# If show_warning is False, we compute with no prompt.
|
|
209
|
-
|
|
210
224
|
# ----------------------------
|
|
211
|
-
#
|
|
225
|
+
# 1) Allocate result arrays
|
|
212
226
|
# ----------------------------
|
|
213
|
-
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
|
|
227
|
+
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM (full-res px units)
|
|
214
228
|
m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
|
|
215
229
|
m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
|
|
216
230
|
m3 = np.full(n, np.nan, dtype=np.float32) # Star count
|
|
217
|
-
flags = [e.get(
|
|
231
|
+
flags = [e.get("flagged", False) for e in loaded_images]
|
|
218
232
|
|
|
219
233
|
# ----------------------------
|
|
220
|
-
#
|
|
234
|
+
# 2) Progress dialog (Cancel)
|
|
221
235
|
# ----------------------------
|
|
222
236
|
prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
|
|
223
237
|
prog.setWindowModality(Qt.WindowModality.WindowModal)
|
|
@@ -226,7 +240,34 @@ class MetricsPanel(QWidget):
|
|
|
226
240
|
prog.show()
|
|
227
241
|
QApplication.processEvents()
|
|
228
242
|
|
|
229
|
-
|
|
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
|
+
|
|
230
271
|
tasks = [(i, loaded_images[i]) for i in range(n)]
|
|
231
272
|
done = 0
|
|
232
273
|
canceled = False
|
|
@@ -238,6 +279,7 @@ class MetricsPanel(QWidget):
|
|
|
238
279
|
if prog.wasCanceled():
|
|
239
280
|
canceled = True
|
|
240
281
|
break
|
|
282
|
+
|
|
241
283
|
try:
|
|
242
284
|
idx, fwhm, ecc, orig_back, star_cnt = fut.result()
|
|
243
285
|
except Exception:
|
|
@@ -245,7 +287,10 @@ class MetricsPanel(QWidget):
|
|
|
245
287
|
fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
|
|
246
288
|
|
|
247
289
|
if 0 <= idx < n:
|
|
248
|
-
m0[idx]
|
|
290
|
+
m0[idx] = fwhm
|
|
291
|
+
m1[idx] = ecc
|
|
292
|
+
m2[idx] = orig_back
|
|
293
|
+
m3[idx] = float(star_cnt)
|
|
249
294
|
|
|
250
295
|
done += 1
|
|
251
296
|
prog.setValue(done)
|
|
@@ -254,7 +299,7 @@ class MetricsPanel(QWidget):
|
|
|
254
299
|
prog.close()
|
|
255
300
|
|
|
256
301
|
if canceled:
|
|
257
|
-
# IMPORTANT: leave caches alone; caller
|
|
302
|
+
# IMPORTANT: leave caches alone; caller handles clear/return
|
|
258
303
|
return False
|
|
259
304
|
|
|
260
305
|
# ----------------------------
|