setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -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)
@@ -251,9 +291,19 @@ class BackgroundNeutralizationDialog(QDialog):
251
291
  self._main = parent
252
292
  self.doc = doc
253
293
 
254
- # Connect to active document change signal
294
+ self._connected_current_doc_changed = False
255
295
  if hasattr(self._main, "currentDocumentChanged"):
256
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
296
+ try:
297
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
298
+ self._connected_current_doc_changed = True
299
+ except Exception:
300
+ self._connected_current_doc_changed = False
301
+
302
+ self.finished.connect(self._cleanup_connections)
303
+ try:
304
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
305
+ except Exception:
306
+ pass # older PyQt6 versions
257
307
 
258
308
  if icon:
259
309
  self.setWindowIcon(icon)
@@ -316,7 +366,7 @@ class BackgroundNeutralizationDialog(QDialog):
316
366
 
317
367
  # Events
318
368
  self.btn_apply.clicked.connect(self._on_apply)
319
- self.btn_cancel.clicked.connect(self.reject)
369
+ self.btn_cancel.clicked.connect(self.close)
320
370
  self.btn_toggle_stretch.clicked.connect(self._toggle_auto_stretch)
321
371
  self.btn_find_bg.clicked.connect(self._on_find_background)
322
372
  self.btn_zoom_out.clicked.connect(self.zoom_out)
@@ -536,15 +586,33 @@ class BackgroundNeutralizationDialog(QDialog):
536
586
  )
537
587
  # Dialog stays open so user can apply to other images
538
588
  # Refresh to use the now-active document for next operation
539
- self.accept() # or: self.close()
589
+ self.close()
540
590
 
541
- def closeEvent(self, e):
591
+ def closeEvent(self, ev):
592
+ self._cleanup_connections()
593
+ super().closeEvent(ev)
594
+
595
+ def _cleanup_connections(self):
596
+ # Disconnect active-doc tracking (Fabio hook)
542
597
  try:
543
- if hasattr(self._main, "currentDocumentChanged"):
598
+ if self._connected_current_doc_changed and hasattr(self._main, "currentDocumentChanged"):
544
599
  self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
545
600
  except Exception:
546
601
  pass
547
- super().closeEvent(e)
602
+ self._connected_current_doc_changed = False
603
+
604
+ # If you ever add threads/workers later, stop them here too (safe no-ops now)
605
+ try:
606
+ if getattr(self, "_worker", None) is not None:
607
+ try:
608
+ self._worker.requestInterruption()
609
+ except Exception:
610
+ pass
611
+ if getattr(self, "_thread", None) is not None:
612
+ self._thread.quit()
613
+ self._thread.wait(500)
614
+ except Exception:
615
+ pass
548
616
 
549
617
 
550
618
  def _refresh_document_from_active(self):
@@ -43,7 +43,10 @@ class _BlemishWorker(QRunnable):
43
43
  self.opacity = float(opacity)
44
44
  self.channels_to_process = channels_to_process
45
45
  self.signals = _BBWorkerSignals()
46
-
46
+ try:
47
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
48
+ except Exception:
49
+ pass # older PyQt6 versions
47
50
  @pyqtSlot()
48
51
  def run(self):
49
52
  out = self._remove_blemish(
@@ -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/clahe.py CHANGED
@@ -122,7 +122,10 @@ class CLAHEDialogPro(QDialog):
122
122
  except Exception as e:
123
123
  import logging
124
124
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
125
-
125
+ try:
126
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
127
+ except Exception:
128
+ pass # older PyQt6 versions
126
129
  self.doc = doc
127
130
  self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
128
131
  disp = self.orig
@@ -105,7 +105,10 @@ class ContinuumSubtractTab(QWidget):
105
105
  self.processing_thread = None
106
106
  self.original_header = None
107
107
  self._clickable_images = {}
108
-
108
+ try:
109
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
110
+ except Exception:
111
+ pass # older PyQt6 versions
109
112
 
110
113
  def initUI(self):
111
114
  self.spinnerLabel = QLabel("") # starts empty
setiastro/saspro/convo.py CHANGED
@@ -151,7 +151,10 @@ class ConvoDeconvoDialog(QDialog):
151
151
  # Only follow global active-doc changes if we *weren't* given a doc
152
152
  if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
153
153
  self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
154
-
154
+ try:
155
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
156
+ except Exception:
157
+ pass # older PyQt6 versions
155
158
  self.setWindowTitle(self.tr("Convolution / Deconvolution"))
156
159
  self.setWindowFlag(Qt.WindowType.Window, True)
157
160
  self.setWindowModality(Qt.WindowModality.NonModal)
@@ -1115,13 +1118,14 @@ class ConvoDeconvoDialog(QDialog):
1115
1118
  if img is None:
1116
1119
  QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
1117
1120
  return
1121
+
1118
1122
  img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
1119
1123
 
1120
- sigma = self.sep_threshold_slider.value()
1121
- minarea = self.sep_minarea_spin.value
1122
- sat = self.sep_sat_slider.value()
1123
- maxstars= self.sep_maxstars_spin.value
1124
- 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()) # ✅
1125
1129
 
1126
1130
  try:
1127
1131
  psf_kernel = estimate_psf_from_image(
@@ -1133,11 +1137,13 @@ class ConvoDeconvoDialog(QDialog):
1133
1137
  stamp_half_width=half_w
1134
1138
  )
1135
1139
  except RuntimeError as e:
1136
- QMessageBox.critical(self, "PSF Error", str(e)); return
1140
+ QMessageBox.critical(self, "PSF Error", str(e))
1141
+ return
1137
1142
 
1138
1143
  self._last_stellar_psf = psf_kernel
1139
1144
  self._show_stellar_psf_preview(psf_kernel)
1140
1145
 
1146
+
1141
1147
  def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
1142
1148
  h, w = psf_kernel.shape
1143
1149
  img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)