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.

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {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-07T16:45:00Z"
3
- APP_VERSION = "1.6.12"
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, progress_cb=None) -> np.ndarray:
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
- patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1, P, P)
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 = pyqtSignal(int) # 0..100
252
- failed = pyqtSignal(str)
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
- except Exception:
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" # NEW
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:\n{e2}")
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(sess, self.image, self.patch, self.overlap, cb)
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
- self.failed.emit(str(e)); return
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.finished_ok.emit(out)
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
- QMessageBox.critical(main, "Aberration AI", msg)
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
- btn.clicked.connect(worker.terminate)
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
- for i in range(qty):
689
- theta = main_angle_rad + (i * (math.pi * 2) / float(qty))
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['image_data']
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
- # --- match old Blink’s SEP pipeline ---
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
- data - back,
158
+ ds - back,
122
159
  thresh=7.0,
123
160
  err=gr,
124
- minarea=16,
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['a'] * cat['b']).astype(np.float32, copy=False)
132
- fwhm = float(np.nanmedian(2.3548 * sig))
133
-
134
- # TRUE eccentricity: e = sqrt(1 - (b/a)^2) (old Blink)
135
- # guard against divide-by-zero and NaNs
136
- a = np.maximum(cat['a'].astype(np.float32, copy=False), 1e-12)
137
- b = np.clip(cat['b'].astype(np.float32, copy=False), 0.0, None)
138
- q = np.clip(b / a, 0.0, 1.0) # b/a
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('orig_background', np.nan)
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
- # 2) Allocate result arrays
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('flagged', False) for e in loaded_images]
231
+ flags = [e.get("flagged", False) for e in loaded_images]
218
232
 
219
233
  # ----------------------------
220
- # 3) Progress dialog
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
- workers = min(os.cpu_count() or 1, 60)
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], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
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 will clear/return
302
+ # IMPORTANT: leave caches alone; caller handles clear/return
258
303
  return False
259
304
 
260
305
  # ----------------------------