setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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 (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.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):
@@ -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(