setiastrosuitepro 1.6.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.
Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,634 @@
1
+ # pro/graxpert.py
2
+ from __future__ import annotations
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import tempfile
7
+ import stat
8
+ import glob
9
+ import subprocess
10
+ import numpy as np
11
+
12
+ from PyQt6.QtCore import QThread, pyqtSignal, Qt
13
+ from PyQt6.QtWidgets import (
14
+ QDialog, QVBoxLayout, QTextEdit, QPushButton, QFileDialog,
15
+ QMessageBox, QInputDialog, QFormLayout, QDialogButtonBox, QDoubleSpinBox,
16
+ QRadioButton, QLabel, QComboBox, QCheckBox, QWidget
17
+ )
18
+ from setiastro.saspro.config import Config
19
+
20
+ # Prefer the exact loader you used in SASv2
21
+ try:
22
+ # adjust this import path if your loader lives elsewhere
23
+ from setiastro.saspro.legacy.image_manager import load_image as _legacy_load_image
24
+ except Exception:
25
+ _legacy_load_image = None
26
+
27
+
28
+ class GraXpertOperationDialog(QDialog):
29
+ """Choose operation + parameter (smoothing or strength) + (optional) denoise model."""
30
+ def __init__(self, parent=None):
31
+ super().__init__(parent)
32
+
33
+ self.setWindowTitle("GraXpert")
34
+ root = QVBoxLayout(self)
35
+
36
+ # radios
37
+ self.rb_bg = QRadioButton("Remove gradient")
38
+ self.rb_dn = QRadioButton("Denoise")
39
+ self.rb_bg.setChecked(True)
40
+
41
+ # param widgets
42
+ self.spin = QDoubleSpinBox()
43
+ self.spin.setRange(0.0, 1.0)
44
+ self.spin.setDecimals(2)
45
+ self.spin.setSingleStep(0.01)
46
+ self.spin.setValue(0.10) # default for smoothing
47
+
48
+ # dynamic label
49
+ self.param_label = QLabel("Smoothing (0–1):")
50
+
51
+ # denoise model (optional)
52
+ self.model_label = QLabel("Denoise model:")
53
+ self.model_combo = QComboBox()
54
+ # Index 0 = auto/latest (empty payload → omit flag)
55
+ self.model_combo.addItem("Latest (auto)", "") # omit -ai_version
56
+ for v in ["3.0.2", "3.0.1", "3.0.0", "2.0.0", "1.1.0", "1.0.0"]:
57
+ self.model_combo.addItem(v, v)
58
+
59
+ # GPU toggle (persists via QSettings if available)
60
+ self.cb_gpu = QCheckBox("Use GPU acceleration")
61
+ use_gpu_default = True
62
+ try:
63
+ settings = getattr(parent, "settings", None)
64
+ if settings is not None:
65
+ use_gpu_default = settings.value("graxpert/use_gpu", True, type=bool)
66
+ except Exception:
67
+ pass
68
+ self.cb_gpu.setChecked(bool(use_gpu_default))
69
+
70
+
71
+ # layout
72
+ form = QFormLayout()
73
+ form.addRow(self.rb_bg)
74
+ form.addRow(self.rb_dn)
75
+ form.addRow(self.param_label, self.spin)
76
+ form.addRow(self.model_label, self.model_combo)
77
+ form.addRow(self.cb_gpu)
78
+ root.addLayout(form)
79
+
80
+ # switch label/defaults and enable/disable model picker
81
+ def _to_bg():
82
+ self.param_label.setText("Smoothing (0–1):")
83
+ # If param was the denoise default, flip back to smoothing default
84
+ self.spin.setValue(0.10 if abs(self.spin.value() - 0.50) < 1e-6 else self.spin.value())
85
+ self.model_label.setEnabled(False)
86
+ self.model_combo.setEnabled(False)
87
+
88
+ def _to_dn():
89
+ self.param_label.setText("Strength (0–1):")
90
+ # If param was the smoothing default, flip to denoise default
91
+ self.spin.setValue(0.50 if abs(self.spin.value() - 0.10) < 1e-6 else self.spin.value())
92
+ self.model_label.setEnabled(True)
93
+ self.model_combo.setEnabled(True)
94
+
95
+ self.rb_bg.toggled.connect(lambda checked: _to_bg() if checked else None)
96
+ self.rb_dn.toggled.connect(lambda checked: _to_dn() if checked else None)
97
+
98
+ # initialize state
99
+ _to_bg()
100
+
101
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
102
+ btns.accepted.connect(self.accept)
103
+ btns.rejected.connect(self.reject)
104
+ root.addWidget(btns)
105
+
106
+ def result(self):
107
+ op = "background" if self.rb_bg.isChecked() else "denoise"
108
+ val = float(self.spin.value())
109
+ ai_version = self.model_combo.currentData() if not self.rb_bg.isChecked() else ""
110
+ use_gpu = self.cb_gpu.isChecked()
111
+ return op, val, (ai_version or None), use_gpu
112
+
113
+ def _build_graxpert_cmd(
114
+ exe: str,
115
+ operation: str,
116
+ input_path: str,
117
+ *,
118
+ smoothing: float | None = None,
119
+ strength: float | None = None,
120
+ ai_version: str | None = None,
121
+ gpu: bool = True,
122
+ batch_size: int | None = None
123
+ ) -> list[str]:
124
+ op = "denoising" if operation == "denoise" else "background-extraction"
125
+ cmd = [exe, "-cmd", op, input_path, "-cli", "-gpu", "true" if gpu else "false"]
126
+ if op == "denoising":
127
+ if strength is not None:
128
+ cmd += ["-strength", f"{strength:.2f}"]
129
+ if batch_size is not None:
130
+ cmd += ["-batch_size", str(int(batch_size))]
131
+ # Only include if user chose a specific model
132
+ if ai_version:
133
+ cmd += ["-ai_version", ai_version]
134
+ else:
135
+ if smoothing is not None:
136
+ cmd += ["-smoothing", f"{smoothing:.2f}"]
137
+ return cmd
138
+
139
+ # ---------- Public entry point (call this from your main window) ----------
140
+ def remove_gradient_with_graxpert(main_window, target_doc=None):
141
+ """
142
+ Exactly mirror SASv2 flow:
143
+ - write input_image.tif
144
+ - run GraXpert
145
+ - read input_image_GraXpert.{fits|tif|tiff|png} using legacy loader
146
+ - apply to target document
147
+ """
148
+ if getattr(main_window, "_graxpert_headless_running", False):
149
+ return
150
+ if getattr(main_window, "_graxpert_guard", False): # cool-down guard
151
+ return
152
+
153
+ # 1) pick the document: explicit > fallback
154
+ doc = target_doc
155
+
156
+ if doc is None:
157
+ # Backwards compatibility: fall back to _active_doc
158
+ doc = getattr(main_window, "_active_doc", None)
159
+ if callable(doc):
160
+ doc = doc()
161
+
162
+ if doc is None and hasattr(main_window, "mdi"):
163
+ # Extra fallback: resolve from active subwindow if possible
164
+ try:
165
+ sw = main_window.mdi.activeSubWindow()
166
+ if sw is not None:
167
+ view = sw.widget()
168
+ doc = getattr(view, "document", None)
169
+ except Exception:
170
+ pass
171
+
172
+ if doc is None or getattr(doc, "image", None) is None:
173
+ QMessageBox.warning(
174
+ main_window,
175
+ "No Image",
176
+ "Please load an image before removing the gradient."
177
+ )
178
+ return
179
+
180
+ # 2) smoothing/denoise prompt
181
+ op_dlg = GraXpertOperationDialog(main_window)
182
+ if op_dlg.exec() != QDialog.DialogCode.Accepted:
183
+ return
184
+ operation, param, ai_version, use_gpu = op_dlg.result()
185
+
186
+ # 3) resolve GraXpert executable
187
+ exe = _resolve_graxpert_exec(main_window)
188
+ if not exe:
189
+ return
190
+
191
+ # Persist the checkbox choice for next time
192
+ try:
193
+ if hasattr(main_window, "settings"):
194
+ main_window.settings.setValue("graxpert/use_gpu", bool(use_gpu))
195
+ except Exception:
196
+ pass
197
+
198
+ # 🔁 NEW: record this as a replayable headless-style command
199
+ try:
200
+ remember = getattr(main_window, "remember_last_headless_command", None)
201
+ if remember is None:
202
+ remember = getattr(main_window, "_remember_last_headless_command", None)
203
+
204
+ if callable(remember):
205
+ preset = {
206
+ "op": operation, # "background" or "denoise"
207
+ "gpu": bool(use_gpu),
208
+ }
209
+ if operation == "background":
210
+ preset["smoothing"] = float(param)
211
+ desc = "GraXpert Gradient Removal"
212
+ else:
213
+ preset["strength"] = float(param)
214
+ if ai_version:
215
+ preset["ai_version"] = ai_version
216
+ desc = "GraXpert Denoise"
217
+
218
+ remember("graxpert", preset, description=desc)
219
+
220
+ # Optional log entry, if you want:
221
+ if hasattr(main_window, "_log"):
222
+ try:
223
+ main_window._log(
224
+ f"[Replay] GraXpert preset stored from dialog: "
225
+ f"op={operation}, keys={list(preset.keys())}"
226
+ )
227
+ except Exception:
228
+ pass
229
+ except Exception:
230
+ # Don't let replay bookkeeping break GraXpert itself
231
+ pass
232
+
233
+ # 4) write input to a temp working dir but KEEP THE SAME BASENAMES as v2
234
+ workdir = tempfile.mkdtemp(prefix="saspro_graxpert_")
235
+ input_basename = "input_image"
236
+ input_path = os.path.join(workdir, f"{input_basename}.tif")
237
+ try:
238
+ _write_tiff_float32(doc.image, input_path)
239
+ except Exception as e:
240
+ QMessageBox.critical(main_window, "GraXpert", f"Failed to write temporary input:\n{e}")
241
+ shutil.rmtree(workdir, ignore_errors=True)
242
+ return
243
+
244
+ # 5) build the exact v2 command (now with optional ai_version for denoise)
245
+ command = _build_graxpert_cmd(
246
+ exe,
247
+ operation,
248
+ input_path,
249
+ smoothing=param if operation == "background" else None,
250
+ strength=param if operation == "denoise" else None,
251
+ ai_version=ai_version if operation == "denoise" else None,
252
+ gpu=bool(use_gpu),
253
+ batch_size=(4 if use_gpu else 1)
254
+ )
255
+
256
+ # Label + metadata for history/undo
257
+ op_label = "GraXpert Denoise" if operation == "denoise" else "GraXpert Gradient Removal"
258
+ meta_extras = {
259
+ "graxpert_operation": operation, # "denoise" | "background"
260
+ "graxpert_param": float(param),
261
+ "graxpert_ai_version": (ai_version or "latest") if operation == "denoise" else None,
262
+ "graxpert_gpu": bool(use_gpu),
263
+ }
264
+
265
+ # 6) run and wait with a small log dialog
266
+ output_basename = f"{input_basename}_GraXpert"
267
+ _run_graxpert_command(
268
+ main_window,
269
+ command,
270
+ output_basename,
271
+ workdir,
272
+ target_doc=doc,
273
+ op_label=op_label,
274
+ meta_extras=meta_extras,
275
+ )
276
+
277
+
278
+ # ---------- helpers ----------
279
+ def _resolve_graxpert_exec(main_window) -> str | None:
280
+ # prefer QSettings if available (all OS)
281
+ path = None
282
+ if hasattr(main_window, "settings"):
283
+ try:
284
+ path = main_window.settings.value("paths/graxpert", type=str)
285
+ except Exception:
286
+ path = None
287
+ if path and os.path.exists(path):
288
+ _ensure_exec_bit(path)
289
+ return path
290
+
291
+ sysname = platform.system()
292
+ default = Config.get_graxpert_default_path()
293
+
294
+ if sysname == "Windows":
295
+ # rely on PATH (like v2) or default
296
+ return default if default else "GraXpert.exe"
297
+
298
+ if sysname == "Darwin":
299
+ if default and os.path.exists(default):
300
+ _ensure_exec_bit(default)
301
+ if hasattr(main_window, "settings"):
302
+ main_window.settings.setValue("paths/graxpert", default)
303
+ return default
304
+ return _pick_graxpert_path_and_store(main_window)
305
+
306
+ if sysname == "Linux":
307
+ # in v2 you asked user and saved; do the same
308
+ return _pick_graxpert_path_and_store(main_window)
309
+
310
+ QMessageBox.critical(main_window, "GraXpert", f"Unsupported operating system: {sysname}")
311
+ return None
312
+
313
+ def _pick_graxpert_path_and_store(main_window) -> str | None:
314
+ path, _ = QFileDialog.getOpenFileName(main_window, "Select GraXpert Executable")
315
+ if not path:
316
+ QMessageBox.warning(main_window, "Cancelled", "GraXpert path selection was cancelled.")
317
+ return None
318
+ try:
319
+ _ensure_exec_bit(path)
320
+ except Exception as e:
321
+ QMessageBox.critical(main_window, "GraXpert", f"Failed to set execute permissions:\n{e}")
322
+ return None
323
+ if hasattr(main_window, "settings"):
324
+ main_window.settings.setValue("paths/graxpert", path)
325
+ return path
326
+
327
+
328
+ def _ensure_exec_bit(path: str) -> None:
329
+ if platform.system() == "Windows":
330
+ return
331
+ try:
332
+ st = os.stat(path)
333
+ os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
334
+ except Exception:
335
+ pass
336
+
337
+
338
+ def _write_tiff_float32(image, path: str, *, clip01: bool = True):
339
+ """
340
+ Always write a 32-bit floating-point TIFF for GraXpert.
341
+ - Mono stays 2D; RGB stays HxWx3.
342
+ - Values are clipped to [0,1] by default to avoid weird HDR ranges.
343
+ """
344
+ import numpy as np
345
+
346
+ arr = np.asarray(image)
347
+ if arr.ndim == 3 and arr.shape[2] == 1:
348
+ arr = arr[..., 0]
349
+
350
+ # Convert to float32 in [0,1]
351
+ if np.issubdtype(arr.dtype, np.floating):
352
+ a32 = arr.astype(np.float32, copy=False)
353
+ if clip01:
354
+ a32 = np.clip(a32, 0.0, 1.0)
355
+ elif np.issubdtype(arr.dtype, np.integer):
356
+ # Scale integers to [0,1] float32
357
+ maxv = np.float32(np.iinfo(arr.dtype).max)
358
+ a32 = (arr.astype(np.float32) / maxv)
359
+ else:
360
+ a32 = arr.astype(np.float32)
361
+
362
+ if clip01:
363
+ a32 = np.clip(a32, 0.0, 1.0)
364
+
365
+ # Prefer tifffile to guarantee float32 TIFFs
366
+ try:
367
+ import tifffile as tiff
368
+ # Write a plain, contiguous, uncompressed float32 TIFF
369
+ # (GraXpert doesn't need ImageJ tags; photometric=minisblack is fine)
370
+ tiff.imwrite(
371
+ path,
372
+ a32,
373
+ dtype=np.float32,
374
+ photometric='minisblack' if a32.ndim == 2 else None,
375
+ planarconfig='contig',
376
+ compression=None,
377
+ imagej=False,
378
+ )
379
+ return
380
+ except Exception as e1:
381
+ pass
382
+
383
+ # Fallback: imageio (uses tifffile under the hood in many installs)
384
+ try:
385
+ import imageio.v3 as iio
386
+ iio.imwrite(path, a32.astype(np.float32))
387
+ return
388
+ except Exception as e2:
389
+ raise RuntimeError(
390
+ "Could not write 32-bit TIFF for GraXpert. "
391
+ "Please install 'tifffile' or 'imageio'.\n"
392
+ f"tifffile error: {e1}\nimageio error: {e2}"
393
+ )
394
+
395
+
396
+
397
+ # ---------- runner + dialog ----------
398
+ class _GraXpertThread(QThread):
399
+ stdout_signal = pyqtSignal(str)
400
+ finished_signal = pyqtSignal(int)
401
+
402
+ def __init__(self, command: list[str], cwd: str | None = None, parent=None):
403
+ super().__init__(parent)
404
+ self.command = command
405
+ self.cwd = cwd
406
+
407
+ def run(self):
408
+ env = os.environ.copy()
409
+ for k in ("PYTHONHOME", "PYTHONPATH", "DYLD_LIBRARY_PATH",
410
+ "DYLD_FALLBACK_LIBRARY_PATH", "PYTHONEXECUTABLE"):
411
+ env.pop(k, None)
412
+ try:
413
+ p = subprocess.Popen(
414
+ self.command,
415
+ cwd=self.cwd,
416
+ stdout=subprocess.PIPE,
417
+ stderr=subprocess.STDOUT, # merge; avoids ResourceWarning + deadlocks
418
+ text=True,
419
+ universal_newlines=True,
420
+ env=env,
421
+ start_new_session=True
422
+ )
423
+ for line in iter(p.stdout.readline, ""):
424
+ if not line:
425
+ break
426
+ self.stdout_signal.emit(line.rstrip())
427
+ try:
428
+ p.stdout.close()
429
+ except Exception:
430
+ pass
431
+ rc = p.wait()
432
+ except Exception as e:
433
+ self.stdout_signal.emit(str(e))
434
+ rc = -1
435
+ self.finished_signal.emit(rc)
436
+
437
+
438
+ def _run_graxpert_command(parent, command: list[str], output_basename: str,
439
+ working_dir: str, target_doc,
440
+ op_label: str | None = None,
441
+ meta_extras: dict | None = None):
442
+ dlg = QDialog(parent)
443
+ dlg.setWindowTitle("GraXpert Progress")
444
+ dlg.setMinimumSize(600, 420)
445
+ lay = QVBoxLayout(dlg)
446
+ log = QTextEdit(readOnly=True)
447
+ lay.addWidget(log)
448
+ btn_cancel = QPushButton("Cancel")
449
+ lay.addWidget(btn_cancel)
450
+
451
+ thr = _GraXpertThread(command, cwd=working_dir)
452
+ thr.stdout_signal.connect(lambda s: log.append(s))
453
+ thr.finished_signal.connect(
454
+ lambda code: _on_graxpert_finished(
455
+ parent,
456
+ code,
457
+ output_basename,
458
+ working_dir,
459
+ target_doc,
460
+ dlg,
461
+ op_label,
462
+ meta_extras,
463
+ )
464
+ )
465
+ btn_cancel.clicked.connect(thr.terminate)
466
+
467
+ thr.start()
468
+ dlg.exec()
469
+
470
+
471
+
472
+ # ---------- finish: import EXACT base like v2, via legacy loader ----------
473
+ def _persist_output_file(src_path: str) -> str | None:
474
+ """Optional: move/copy GraXpert output to an app cache we control."""
475
+ try:
476
+ from PyQt6.QtCore import QStandardPaths
477
+ cache_root = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.CacheLocation)
478
+ except Exception:
479
+ cache_root = None
480
+ try:
481
+ base = os.path.join(cache_root or os.path.expanduser("~/.saspro_cache"), "graxpert")
482
+ os.makedirs(base, exist_ok=True)
483
+ dst = os.path.join(base, os.path.basename(src_path))
484
+ # prefer move (cheaper); fall back to copy if cross-device issues
485
+ try:
486
+ shutil.move(src_path, dst)
487
+ except Exception:
488
+ shutil.copy2(src_path, dst)
489
+ return dst
490
+ except Exception:
491
+ return None
492
+
493
+
494
+ def _on_graxpert_finished(parent,
495
+ return_code: int,
496
+ output_basename: str,
497
+ working_dir: str,
498
+ target_doc,
499
+ dlg,
500
+ op_label: str | None = None,
501
+ meta_extras: dict | None = None):
502
+ try:
503
+ dlg.close()
504
+ except Exception:
505
+ pass
506
+
507
+ if return_code != 0:
508
+ QMessageBox.critical(parent, "GraXpert", "GraXpert process failed.")
509
+ shutil.rmtree(working_dir, ignore_errors=True)
510
+ return
511
+
512
+ # 1) find output file in the temp working dir
513
+ output_file = _pick_exact_output(working_dir, output_basename)
514
+ if not output_file:
515
+ QMessageBox.critical(parent, "GraXpert", "GraXpert output file not found.")
516
+ shutil.rmtree(working_dir, ignore_errors=True)
517
+ return
518
+
519
+ # 2) read pixels (we *do not* want its header to replace ours)
520
+ arr, header = None, None
521
+ if _legacy_load_image is not None:
522
+ try:
523
+ out = _legacy_load_image(output_file, return_metadata=True)
524
+ if out and len(out) == 5:
525
+ a, h, bit_depth, is_mono, out_meta = out
526
+ else:
527
+ a, h, bit_depth, is_mono = out
528
+ out_meta = {}
529
+ arr, header = a, h
530
+ except Exception:
531
+ arr = None
532
+ header = None
533
+ bit_depth = "32-bit floating point"
534
+ is_mono = None
535
+ out_meta = {}
536
+ else:
537
+ out_meta = {}
538
+ bit_depth = "32-bit floating point"
539
+ is_mono = None
540
+ # Decide how it appears in history/undo
541
+ step_label = op_label or "GraXpert Gradient Removal"
542
+
543
+ # 3) base metadata: START FROM EXISTING DOC METADATA
544
+ base_meta = dict(getattr(target_doc, "metadata", {}) or {})
545
+
546
+ # Keep original_header / wcs_header from the doc.
547
+ # If you want to keep GraXpert's header for debugging, store separately:
548
+ from astropy.io import fits as _fits_mod
549
+ if header is not None and isinstance(header, _fits_mod.Header):
550
+ base_meta.setdefault("graxpert_header", header)
551
+
552
+ # Basic fields we do want to update
553
+ base_meta["step_name"] = step_label
554
+ base_meta["description"] = step_label
555
+ base_meta["bit_depth"] = "32-bit floating point"
556
+ if is_mono is not None:
557
+ base_meta["is_mono"] = bool(is_mono)
558
+
559
+ # Copy over any interesting fields from GraXpert's own metadata that are SAFE
560
+ # but do NOT overwrite original_header / wcs_header.
561
+ for k, v in (out_meta or {}).items():
562
+ if k in ("original_header", "fits_header", "wcs_header"):
563
+ continue
564
+ base_meta.setdefault(k, v)
565
+
566
+ # Operation-specific extras
567
+ if meta_extras:
568
+ # these are non-header fields like graxpert_operation, etc.
569
+ base_meta.update(meta_extras)
570
+
571
+ # 4) apply to the target doc
572
+ try:
573
+ target_doc.apply_edit(
574
+ arr.astype(np.float32, copy=False),
575
+ metadata=base_meta,
576
+ step_name=step_label,
577
+ )
578
+ except Exception as e:
579
+ QMessageBox.critical(parent, "GraXpert", f"Failed to apply result:\n{e}")
580
+ finally:
581
+ shutil.rmtree(working_dir, ignore_errors=True)
582
+
583
+
584
+ def _pick_exact_output(folder: str, base: str) -> str | None:
585
+ # exact filenames only, like v2 did
586
+ exts = ("fits", "tif", "tiff", "png")
587
+ for ext in exts:
588
+ p = os.path.join(folder, f"{base}.{ext}")
589
+ if os.path.exists(p):
590
+ return p
591
+ # also try case-variants just in case
592
+ for q in glob.glob(os.path.join(folder, f"{base}.*")):
593
+ if q.lower().endswith("." + ext):
594
+ return q
595
+ return None
596
+
597
+
598
+ def _fallback_read_float01(path: str) -> np.ndarray | None:
599
+ """Basic loader: return float32 in [0,1], mono or RGB, without being too clever."""
600
+ try:
601
+ import imageio.v3 as iio
602
+ arr = iio.imread(path)
603
+ except Exception:
604
+ try:
605
+ import tifffile as tiff
606
+ arr = tiff.imread(path)
607
+ except Exception:
608
+ try:
609
+ from astropy.io import fits
610
+ with fits.open(path, memmap=False) as hdul:
611
+ arr = hdul[0].data
612
+ except Exception:
613
+ try:
614
+ import cv2
615
+ arr = cv2.imread(path, cv2.IMREAD_UNCHANGED)
616
+ if arr is not None and arr.ndim == 3:
617
+ arr = arr[..., ::-1] # BGR->RGB
618
+ except Exception:
619
+ arr = None
620
+ if arr is None:
621
+ return None
622
+
623
+ arr = np.asarray(arr)
624
+ if arr.ndim == 3 and arr.shape[2] == 1:
625
+ arr = arr[..., 0]
626
+ if arr.dtype.kind in "ui":
627
+ scale = 65535.0 if arr.dtype.itemsize >= 2 else 255.0
628
+ arr = arr.astype(np.float32) / scale
629
+ else:
630
+ arr = arr.astype(np.float32, copy=False)
631
+ mx = float(arr.max()) if arr.size else 1.0
632
+ if mx > 5.0:
633
+ arr = arr / mx
634
+ return arr