setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.10__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.
Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,379 @@
1
+ # src/setiastro/saspro/acv_exporter.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from PyQt6.QtCore import Qt, QCoreApplication
9
+ from PyQt6.QtWidgets import (
10
+ QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox,
11
+ QLabel, QLineEdit, QPushButton, QFileDialog, QComboBox,
12
+ QMessageBox, QWidget
13
+ )
14
+
15
+ # If you have a canonical place for "global save_as", wire it here.
16
+ # Example placeholders (change to your actual import):
17
+ # from setiastro.saspro.actions.save_as import save_as
18
+ # from setiastro.saspro.fileio.save_as import save_as
19
+
20
+
21
+ def _tr(s: str) -> str:
22
+ return QCoreApplication.translate("AstroCatalogueViewerExporter", s)
23
+
24
+
25
+ class AstroCatalogueViewerExporterDialog(QDialog):
26
+ """
27
+ Astro Catalogue Viewer Exporter
28
+ - Persist folders in QSettings
29
+ - Export current active document/view via your global save_as
30
+ - Route by name prefix: M -> Messier, NGC -> NGC, IC -> IC, C -> Caldwell, else Master
31
+ """
32
+
33
+ # QSettings keys
34
+ K_MASTER = "acv_export/master_folder"
35
+ K_M = "acv_export/messier_folder"
36
+ K_NGC = "acv_export/ngc_folder"
37
+ K_IC = "acv_export/ic_folder"
38
+ K_C = "acv_export/caldwell_folder"
39
+ K_FMT = "acv_export/format"
40
+
41
+ def __init__(self, parent, dm, doc):
42
+ super().__init__(parent)
43
+ self.dm = dm
44
+ self.doc = doc
45
+ self.setObjectName("acv_exporter_dialog")
46
+ self.setWindowTitle(_tr("Astro Catalogue Viewer Exporter"))
47
+
48
+ # Prefer not using WA_DeleteOnClose if that’s a Linux landmine for you.
49
+ # (You can still set it from caller if you want.)
50
+ self._main = parent
51
+ self._settings = getattr(parent, "settings", None) # typical SASpro pattern
52
+
53
+ root = QVBoxLayout(self)
54
+ root.setContentsMargins(12, 12, 12, 12)
55
+ root.setSpacing(10)
56
+
57
+ # -------------------------
58
+ # Folder section
59
+ # -------------------------
60
+ gb = QGroupBox(_tr("Image folders"), self)
61
+ g = QGridLayout(gb)
62
+ g.setContentsMargins(10, 10, 10, 10)
63
+ g.setHorizontalSpacing(10)
64
+ g.setVerticalSpacing(8)
65
+
66
+ self.ed_master = QLineEdit(self)
67
+ self.btn_master = QPushButton(_tr("Browse…"), self)
68
+ self.btn_master.clicked.connect(lambda: self._browse_folder(self.ed_master))
69
+
70
+ g.addWidget(QLabel(_tr("Master Image Folder")), 0, 0)
71
+ g.addWidget(self.ed_master, 0, 1)
72
+ g.addWidget(self.btn_master, 0, 2)
73
+
74
+ gb2 = QGroupBox(_tr("Image folder per catalog"), self)
75
+ gg = QGridLayout(gb2)
76
+ gg.setContentsMargins(10, 10, 10, 10)
77
+ gg.setHorizontalSpacing(10)
78
+ gg.setVerticalSpacing(8)
79
+
80
+ self.ed_m = QLineEdit(self)
81
+ self.ed_ngc = QLineEdit(self)
82
+ self.ed_ic = QLineEdit(self)
83
+ self.ed_c = QLineEdit(self)
84
+
85
+ bm = QPushButton(_tr("Browse…"), self); bm.clicked.connect(lambda: self._browse_folder(self.ed_m))
86
+ bn = QPushButton(_tr("Browse…"), self); bn.clicked.connect(lambda: self._browse_folder(self.ed_ngc))
87
+ bi = QPushButton(_tr("Browse…"), self); bi.clicked.connect(lambda: self._browse_folder(self.ed_ic))
88
+ bc = QPushButton(_tr("Browse…"), self); bc.clicked.connect(lambda: self._browse_folder(self.ed_c))
89
+
90
+ gg.addWidget(QLabel(_tr("Messier")), 0, 0); gg.addWidget(self.ed_m, 0, 1); gg.addWidget(bm, 0, 2)
91
+ gg.addWidget(QLabel(_tr("NGC")), 1, 0); gg.addWidget(self.ed_ngc, 1, 1); gg.addWidget(bn, 1, 2)
92
+ gg.addWidget(QLabel(_tr("IC")), 2, 0); gg.addWidget(self.ed_ic, 2, 1); gg.addWidget(bi, 2, 2)
93
+ gg.addWidget(QLabel(_tr("Caldwell")),3, 0); gg.addWidget(self.ed_c, 3, 1); gg.addWidget(bc, 3, 2)
94
+
95
+ root.addWidget(gb)
96
+ root.addWidget(gb2)
97
+
98
+ # -------------------------
99
+ # Export controls
100
+ # -------------------------
101
+ row = QHBoxLayout()
102
+ row.setSpacing(10)
103
+
104
+ self.ed_name = QLineEdit(self)
105
+ self.ed_name.setPlaceholderText(_tr("e.g. M31, NGC5060, M31_HaOnly…"))
106
+
107
+ self.cmb_fmt = QComboBox(self)
108
+ # keep these lower-case as “extensions”
109
+ self.cmb_fmt.addItems(["jpg", "png", "tif"])
110
+
111
+ self.btn_export = QPushButton(_tr("Export"), self)
112
+ self.btn_export.clicked.connect(self._on_export)
113
+
114
+ row.addWidget(QLabel(_tr("Image Name")), 0)
115
+ row.addWidget(self.ed_name, 2)
116
+ row.addWidget(QLabel(_tr("Type")), 0)
117
+ row.addWidget(self.cmb_fmt, 0)
118
+ row.addWidget(self.btn_export, 0)
119
+
120
+ root.addLayout(row)
121
+
122
+ # Load persisted settings
123
+ self._load_settings()
124
+
125
+ # Save on edits (lightweight)
126
+ self.ed_master.textChanged.connect(self._save_settings)
127
+ self.ed_m.textChanged.connect(self._save_settings)
128
+ self.ed_ngc.textChanged.connect(self._save_settings)
129
+ self.ed_ic.textChanged.connect(self._save_settings)
130
+ self.ed_c.textChanged.connect(self._save_settings)
131
+ self.cmb_fmt.currentTextChanged.connect(self._save_settings)
132
+
133
+ # -------------------------
134
+ # Settings
135
+ # -------------------------
136
+ def _load_settings(self):
137
+ s = self._settings
138
+ if s is None:
139
+ return
140
+ self.ed_master.setText(s.value(self.K_MASTER, "", type=str) or "")
141
+ self.ed_m.setText(s.value(self.K_M, "", type=str) or "")
142
+ self.ed_ngc.setText(s.value(self.K_NGC, "", type=str) or "")
143
+ self.ed_ic.setText(s.value(self.K_IC, "", type=str) or "")
144
+ self.ed_c.setText(s.value(self.K_C, "", type=str) or "")
145
+ fmt = (s.value(self.K_FMT, "png", type=str) or "png").lower()
146
+ idx = self.cmb_fmt.findText(fmt)
147
+ if idx >= 0:
148
+ self.cmb_fmt.setCurrentIndex(idx)
149
+
150
+ def _save_settings(self):
151
+ s = self._settings
152
+ if s is None:
153
+ return
154
+ s.setValue(self.K_MASTER, self.ed_master.text().strip())
155
+ s.setValue(self.K_M, self.ed_m.text().strip())
156
+ s.setValue(self.K_NGC, self.ed_ngc.text().strip())
157
+ s.setValue(self.K_IC, self.ed_ic.text().strip())
158
+ s.setValue(self.K_C, self.ed_c.text().strip())
159
+ s.setValue(self.K_FMT, self.cmb_fmt.currentText().strip().lower())
160
+ try:
161
+ s.sync()
162
+ except Exception:
163
+ pass
164
+
165
+ # -------------------------
166
+ # Folder helpers
167
+ # -------------------------
168
+ def _browse_folder(self, target_edit: QLineEdit):
169
+ start = target_edit.text().strip() or os.path.expanduser("~")
170
+ path = QFileDialog.getExistingDirectory(self, _tr("Select folder"), start)
171
+ if path:
172
+ target_edit.setText(path)
173
+
174
+ # -------------------------
175
+ # Active doc resolution (ROI aware)
176
+ # -------------------------
177
+ def _get_active_doc(self):
178
+ mw = self._main
179
+ mdi = getattr(mw, "mdi", None)
180
+ dm = getattr(mw, "doc_manager", None) or getattr(mw, "docman", None)
181
+
182
+ if mdi is None or not hasattr(mdi, "activeSubWindow"):
183
+ return None
184
+
185
+ sw = mdi.activeSubWindow()
186
+ if not sw:
187
+ return None
188
+
189
+ view = sw.widget()
190
+
191
+ # Prefer ROI-aware doc lookup via DocManager
192
+ if dm is not None and hasattr(dm, "get_document_for_view"):
193
+ try:
194
+ doc = dm.get_document_for_view(view)
195
+ if doc is not None:
196
+ return doc
197
+ except Exception:
198
+ pass
199
+
200
+ # Fallback: view.document
201
+ return getattr(view, "document", None)
202
+
203
+ # -------------------------
204
+ # Routing logic
205
+ # -------------------------
206
+ @staticmethod
207
+ def _pick_target_folder(name: str, master: str, m: str, ngc: str, ic: str, c: str) -> str:
208
+ raw = (name or "").strip().upper()
209
+
210
+ # routing normalization: remove spaces (optionally also "_" and "-" if you want)
211
+ u = re.sub(r"\s+", "", raw)
212
+
213
+ if re.match(r"^M\d+", u):
214
+ return m or master
215
+ if re.match(r"^NGC\d+", u): # optional tighten
216
+ return ngc or master
217
+ if re.match(r"^IC\d+", u): # optional tighten
218
+ return ic or master
219
+ if re.match(r"^C\d+", u):
220
+ return c or master
221
+ return master
222
+
223
+ def _normalize_for_routing(self, name: str) -> str:
224
+ # Uppercase + remove spaces only (routing only)
225
+ u = (name or "").strip().upper()
226
+ u = re.sub(r"\s+", "", u)
227
+ return u
228
+
229
+ def _sanitize_filename(self, s: str) -> str:
230
+ # Keep it simple + safe across OSes
231
+ s = (s or "").strip()
232
+ s = s.replace("\\", "_").replace("/", "_").replace(":", "_")
233
+ s = re.sub(r"\s+", " ", s) # keep single spaces
234
+ s = re.sub(r"[^\w\-\.\(\) ]+", "", s) # drop odd chars
235
+ return s.strip() or "export"
236
+
237
+ # -------------------------
238
+ # Export action
239
+ # -------------------------
240
+ def _on_export(self):
241
+ fmt = self.cmb_fmt.currentText().strip().lower()
242
+
243
+ # -------------------------
244
+ # Name (raw + routing-normalized)
245
+ # -------------------------
246
+ raw_name = self.ed_name.text().strip()
247
+ if not raw_name:
248
+ QMessageBox.information(
249
+ self, _tr("Export"),
250
+ _tr("Please enter an Image Name (e.g. M31, NGC5060…).")
251
+ )
252
+ return
253
+
254
+ route_name = self._normalize_for_routing(raw_name)
255
+
256
+ # -------------------------
257
+ # Active doc (passed in)
258
+ # -------------------------
259
+ doc = self.doc
260
+ if doc is None or getattr(doc, "image", None) is None:
261
+ QMessageBox.information(self, _tr("Export"), _tr("No active image. Open an image first."))
262
+ return
263
+
264
+ # -------------------------
265
+ # Folder config
266
+ # -------------------------
267
+ master_dir = self.ed_master.text().strip()
268
+ if not master_dir:
269
+ QMessageBox.information(self, _tr("Export"), _tr("Please set a Master Image Folder."))
270
+ return
271
+
272
+ messier_dir = self.ed_m.text().strip() or master_dir
273
+ ngc_dir = self.ed_ngc.text().strip() or master_dir
274
+ ic_dir = self.ed_ic.text().strip() or master_dir
275
+ caldwell_dir = self.ed_c.text().strip() or master_dir
276
+
277
+ # -------------------------
278
+ # Route by prefix (digit-guard)
279
+ # -------------------------
280
+ if route_name.startswith("M") and route_name[1:2].isdigit():
281
+ base_dir = messier_dir
282
+ elif route_name.startswith("NGC") and route_name[3:4].isdigit():
283
+ base_dir = ngc_dir
284
+ elif route_name.startswith("IC") and route_name[2:3].isdigit():
285
+ base_dir = ic_dir
286
+ elif route_name.startswith("C") and route_name[1:2].isdigit():
287
+ base_dir = caldwell_dir
288
+ else:
289
+ base_dir = master_dir
290
+
291
+ # Ensure folder exists
292
+ try:
293
+ Path(base_dir).mkdir(parents=True, exist_ok=True)
294
+ except Exception as e:
295
+ QMessageBox.critical(
296
+ self, _tr("Export"),
297
+ _tr("Could not create folder:\n{0}\n\n{1}").format(base_dir, str(e))
298
+ )
299
+ return
300
+
301
+ # Filename uses user's raw name (sanitized for filesystem)
302
+ file_stem = self._sanitize_filename(raw_name)
303
+ out_path = str(Path(base_dir) / f"{file_stem}.{fmt}")
304
+
305
+ # -------------------------
306
+ # Save
307
+ # -------------------------
308
+ ok, err = self._save_current_doc_to_path(doc, out_path, fmt)
309
+ if ok:
310
+ QMessageBox.information(self, _tr("Export"), _tr("Exported:\n{0}").format(out_path))
311
+ else:
312
+ QMessageBox.critical(self, _tr("Export"), _tr("Export failed:\n{0}").format(err or "Unknown error"))
313
+
314
+
315
+ def _save_current_doc_to_path(self, doc, out_path: str, fmt: str) -> tuple[bool, str]:
316
+ try:
317
+ import numpy as np
318
+ from astropy.io import fits
319
+ from astropy.wcs import WCS
320
+
321
+ from setiastro.saspro.legacy.image_manager import save_image as legacy_save_image
322
+
323
+ img = getattr(doc, "image", None)
324
+ if img is None:
325
+ return False, "Active document has no image data."
326
+
327
+ # Ensure numpy array
328
+ img_array = np.asarray(img)
329
+
330
+ # Mono detection (your saver uses this for RAW/XISF paths; safe to provide anyway)
331
+ is_mono = (img_array.ndim == 2) or (img_array.ndim == 3 and img_array.shape[2] == 1)
332
+
333
+ # Preserve bit depth for tif/fit if the doc has it; PNG/JPG ignore bit depth anyway.
334
+ bit_depth = (
335
+ getattr(doc, "bit_depth", None)
336
+ or getattr(doc, "bitdepth", None)
337
+ or (getattr(doc, "metadata", None) or {}).get("bit_depth")
338
+ or (getattr(doc, "metadata", None) or {}).get("bitDepth")
339
+ )
340
+
341
+ # Pull headers/WCS from metadata (matches your SASv2->SASpro WCS preference)
342
+ md = getattr(doc, "metadata", None) or {}
343
+
344
+ original_header = md.get("original_header", None)
345
+ wcs_header = None
346
+
347
+ # If wcs is stored as astropy.wcs.WCS, convert to Header
348
+ wcs_obj = md.get("wcs", None)
349
+ if wcs_obj is not None:
350
+ try:
351
+ if isinstance(wcs_obj, WCS):
352
+ wcs_header = wcs_obj.to_header(relax=True)
353
+ elif isinstance(wcs_obj, fits.Header):
354
+ wcs_header = wcs_obj
355
+ except Exception:
356
+ wcs_header = None
357
+
358
+ # Optional passthroughs (safe if missing)
359
+ image_meta = md.get("image_meta", None) or md.get("image_metadata", None)
360
+ file_meta = md.get("file_meta", None) or md.get("xisf_metadata", None)
361
+
362
+ # IMPORTANT:
363
+ # legacy_save_image expects original_format to be the desired output format,
364
+ # and will normalize extension itself.
365
+ legacy_save_image(
366
+ img_array=img_array,
367
+ filename=out_path,
368
+ original_format=fmt, # "jpg","png","tif","fit"
369
+ bit_depth=bit_depth, # keep same depth for tif/fit when possible
370
+ original_header=original_header,
371
+ is_mono=is_mono,
372
+ image_meta=image_meta,
373
+ file_meta=file_meta,
374
+ wcs_header=wcs_header, # merged into FITS header (your saver supports this)
375
+ )
376
+ return True, ""
377
+
378
+ except Exception as e:
379
+ return False, f"{type(e).__name__}: {e}"
@@ -231,7 +231,10 @@ class AddStarsDialog(QDialog):
231
231
  self.setWindowModality(Qt.WindowModality.NonModal)
232
232
  self.setModal(False)
233
233
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
234
-
234
+ try:
235
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
236
+ except Exception:
237
+ pass # older PyQt6 versions
235
238
  self.main = main
236
239
  self.starless = None
237
240
  self.stars_only = None
@@ -575,10 +578,9 @@ class AddStarsDialog(QDialog):
575
578
 
576
579
  # Emit (target_doc, blended_image)
577
580
  self.stars_added.emit(target_doc, self.blended_image.astype(np.float32, copy=False))
578
- # Dialog stays open so user can apply to other images
579
- # Refresh combo boxes for next operation
580
- self._populate_doc_combos()
581
-
581
+ # Close UI after apply
582
+ self.accept() # or: self.close()
583
+ return
582
584
 
583
585
  # Ensure initial fit once shown
584
586
  def showEvent(self, ev):
@@ -603,7 +605,32 @@ def add_stars(main):
603
605
 
604
606
  dlg = AddStarsDialog(main, parent=main)
605
607
  dlg.stars_added.connect(lambda target, arr: _apply_to_doc(main, target, arr))
606
- 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()
607
634
 
608
635
 
609
636
  def _apply_to_doc(main, doc, arr: np.ndarray):
@@ -251,9 +251,19 @@ class BackgroundNeutralizationDialog(QDialog):
251
251
  self._main = parent
252
252
  self.doc = doc
253
253
 
254
- # Connect to active document change signal
254
+ self._connected_current_doc_changed = False
255
255
  if hasattr(self._main, "currentDocumentChanged"):
256
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
256
+ try:
257
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
258
+ self._connected_current_doc_changed = True
259
+ except Exception:
260
+ self._connected_current_doc_changed = False
261
+
262
+ self.finished.connect(self._cleanup_connections)
263
+ try:
264
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
265
+ except Exception:
266
+ pass # older PyQt6 versions
257
267
 
258
268
  if icon:
259
269
  self.setWindowIcon(icon)
@@ -316,7 +326,7 @@ class BackgroundNeutralizationDialog(QDialog):
316
326
 
317
327
  # Events
318
328
  self.btn_apply.clicked.connect(self._on_apply)
319
- self.btn_cancel.clicked.connect(self.reject)
329
+ self.btn_cancel.clicked.connect(self.close)
320
330
  self.btn_toggle_stretch.clicked.connect(self._toggle_auto_stretch)
321
331
  self.btn_find_bg.clicked.connect(self._on_find_background)
322
332
  self.btn_zoom_out.clicked.connect(self.zoom_out)
@@ -536,15 +546,33 @@ class BackgroundNeutralizationDialog(QDialog):
536
546
  )
537
547
  # Dialog stays open so user can apply to other images
538
548
  # Refresh to use the now-active document for next operation
539
- self.accept() # or: self.close()
549
+ self.close()
540
550
 
541
- def closeEvent(self, e):
551
+ def closeEvent(self, ev):
552
+ self._cleanup_connections()
553
+ super().closeEvent(ev)
554
+
555
+ def _cleanup_connections(self):
556
+ # Disconnect active-doc tracking (Fabio hook)
542
557
  try:
543
- if hasattr(self._main, "currentDocumentChanged"):
558
+ if self._connected_current_doc_changed and hasattr(self._main, "currentDocumentChanged"):
544
559
  self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
545
560
  except Exception:
546
561
  pass
547
- super().closeEvent(e)
562
+ self._connected_current_doc_changed = False
563
+
564
+ # If you ever add threads/workers later, stop them here too (safe no-ops now)
565
+ try:
566
+ if getattr(self, "_worker", None) is not None:
567
+ try:
568
+ self._worker.requestInterruption()
569
+ except Exception:
570
+ pass
571
+ if getattr(self, "_thread", None) is not None:
572
+ self._thread.quit()
573
+ self._thread.wait(500)
574
+ except Exception:
575
+ pass
548
576
 
549
577
 
550
578
  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(
@@ -150,45 +150,75 @@ class MetricsPanel(QWidget):
150
150
  orig_back = entry.get('orig_background', np.nan)
151
151
  return idx, fwhm, ecc, orig_back, star_cnt
152
152
 
153
-
154
- def compute_all_metrics(self, loaded_images):
155
- """Run SEP over the full list in parallel using threads and cache results."""
153
+ 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
+ """
156
157
  n = len(loaded_images)
157
158
  if n == 0:
158
- # Clear any previous state and bail
159
159
  self._orig_images = []
160
- self.metrics_data = [np.array([])]*4
160
+ self.metrics_data = [np.array([])] * 4
161
161
  self.flags = []
162
- self._threshold_initialized = [False]*4
163
- return
162
+ self._threshold_initialized = [False] * 4
163
+ return True
164
+
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
164
170
 
165
- # Heads-up dialog (as you already had)
166
171
  settings = QSettings()
167
- show = settings.value("metrics/showWarning", True, type=bool)
168
- if show:
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:
169
182
  msg = QMessageBox(self)
170
183
  msg.setWindowTitle(self.tr("Heads-up"))
171
184
  msg.setText(self.tr(
172
185
  "This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
173
186
  "Continue?"
174
187
  ))
175
- msg.setStandardButtons(QMessageBox.StandardButton.Yes |
176
- QMessageBox.StandardButton.No)
188
+ msg.setStandardButtons(
189
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
190
+ )
177
191
  cb = QCheckBox(self.tr("Don't show again"), msg)
178
192
  msg.setCheckBox(cb)
179
- if msg.exec() != QMessageBox.StandardButton.Yes:
180
- return
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"
181
205
  if cb.isChecked():
182
206
  settings.setValue("metrics/showWarning", False)
183
207
 
184
- # pre-allocate result arrays
208
+ # If show_warning is False, we compute with no prompt.
209
+
210
+ # ----------------------------
211
+ # 2) Allocate result arrays
212
+ # ----------------------------
185
213
  m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
186
214
  m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
187
215
  m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
188
216
  m3 = np.full(n, np.nan, dtype=np.float32) # Star count
189
217
  flags = [e.get('flagged', False) for e in loaded_images]
190
218
 
191
- # progress dialog
219
+ # ----------------------------
220
+ # 3) Progress dialog
221
+ # ----------------------------
192
222
  prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
193
223
  prog.setWindowModality(Qt.WindowModality.WindowModal)
194
224
  prog.setMinimumDuration(0)
@@ -198,32 +228,43 @@ class MetricsPanel(QWidget):
198
228
 
199
229
  workers = min(os.cpu_count() or 1, 60)
200
230
  tasks = [(i, loaded_images[i]) for i in range(n)]
201
- done = 0 # <-- FIX: initialize before incrementing
231
+ done = 0
232
+ canceled = False
202
233
 
203
234
  try:
204
235
  with ThreadPoolExecutor(max_workers=workers) as exe:
205
236
  futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
206
237
  for fut in as_completed(futures):
207
238
  if prog.wasCanceled():
239
+ canceled = True
208
240
  break
209
241
  try:
210
242
  idx, fwhm, ecc, orig_back, star_cnt = fut.result()
211
243
  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)
244
+ idx = futures.get(fut, 0)
245
+ fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
246
+
247
+ if 0 <= idx < n:
248
+ m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
249
+
215
250
  done += 1
216
251
  prog.setValue(done)
217
252
  QApplication.processEvents()
218
253
  finally:
219
254
  prog.close()
220
255
 
221
- # stash results
256
+ if canceled:
257
+ # IMPORTANT: leave caches alone; caller will clear/return
258
+ return False
259
+
260
+ # ----------------------------
261
+ # 4) Stash results
262
+ # ----------------------------
222
263
  self._orig_images = loaded_images
223
264
  self.metrics_data = [m0, m1, m2, m3]
224
265
  self.flags = flags
225
- self._threshold_initialized = [False]*4
226
-
266
+ self._threshold_initialized = [False] * 4
267
+ return True
227
268
 
228
269
  def plot(self, loaded_images, indices=None):
229
270
  """
@@ -242,7 +283,16 @@ class MetricsPanel(QWidget):
242
283
 
243
284
  # compute & cache on first call or new image list
244
285
  if self._orig_images is not loaded_images or self.metrics_data is None:
245
- self.compute_all_metrics(loaded_images)
286
+ ok = self.compute_all_metrics(loaded_images)
287
+ if not ok or self.metrics_data is None:
288
+ # user declined/canceled -> clear plots and exit cleanly
289
+ for pw, scat, line in zip(self.plots, self.scats, self.lines):
290
+ scat.setData(x=[], y=[])
291
+ line.setPos(0)
292
+ pw.getPlotItem().getViewBox().update()
293
+ pw.repaint()
294
+ return
295
+
246
296
 
247
297
  # default to all indices
248
298
  if indices is None: