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.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- 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
|