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.

Files changed (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {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
- 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"):
@@ -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
- # Dialog stays open so user can apply to other images
582
- # Refresh combo boxes for next operation
583
- self._populate_doc_combos()
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
- dlg.exec()
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 background_neutralize_rgb(img: np.ndarray, rect_xywh: tuple[int, int, int, int]) -> np.ndarray:
23
+ def _remove_channel_pedestal(img_rgb01: np.ndarray) -> np.ndarray:
24
24
  """
25
- Apply Background Neutralization to an RGB float32 image in [0,1],
26
- using an image-space rectangle (x, y, w, h) as the sample region.
27
- Returns a new float32 array in [0,1].
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
- h, w, _ = img.shape
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 = img[y:y+rh, x:x+rw, :]
40
- medians = np.median(sample, axis=(0, 1)).astype(np.float32) # (3,)
41
- avg_med = float(np.mean(medians))
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
- return out.astype(np.float32, copy=False)
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
- if not rn or len(rn) != 4:
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['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,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('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
 
153
201
 
154
- def compute_all_metrics(self, loaded_images):
155
- """Run SEP over the full list in parallel using threads and cache results."""
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
- # pre-allocate result arrays
185
- m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
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('flagged', False) for e in loaded_images]
231
+ flags = [e.get("flagged", False) for e in loaded_images]
190
232
 
191
- # progress dialog
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
- 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
+
200
271
  tasks = [(i, loaded_images[i]) for i in range(n)]
201
- done = 0 # <-- FIX: initialize before incrementing
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
- # On failure, leave NaNs/sentinels and continue
213
- idx, fwhm, ecc, orig_back, star_cnt = futures[fut], np.nan, np.nan, np.nan, 0
214
- m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
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
- # stash results
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 = self.sep_threshold_slider.value()
1124
- minarea = self.sep_minarea_spin.value
1125
- sat = self.sep_sat_slider.value()
1126
- maxstars= self.sep_maxstars_spin.value
1127
- half_w = self.sep_stamp_spin.value
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)); return
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)