setiastrosuitepro 1.7.5.post1__py3-none-any.whl → 1.8.0.post3__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.
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/accel_installer.py +21 -8
- setiastro/saspro/accel_workers.py +11 -12
- setiastro/saspro/comet_stacking.py +113 -85
- setiastro/saspro/cosmicclarity.py +604 -826
- setiastro/saspro/cosmicclarity_engines/benchmark_engine.py +732 -0
- setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +576 -0
- setiastro/saspro/cosmicclarity_engines/denoise_engine.py +567 -0
- setiastro/saspro/cosmicclarity_engines/satellite_engine.py +620 -0
- setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +587 -0
- setiastro/saspro/cosmicclarity_engines/superres_engine.py +412 -0
- setiastro/saspro/gui/main_window.py +14 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
- setiastro/saspro/model_manager.py +324 -0
- setiastro/saspro/model_workers.py +102 -0
- setiastro/saspro/ops/benchmark.py +320 -0
- setiastro/saspro/ops/settings.py +407 -10
- setiastro/saspro/remove_stars.py +424 -442
- setiastro/saspro/resources.py +73 -10
- setiastro/saspro/runtime_torch.py +107 -22
- setiastro/saspro/signature_insert.py +14 -3
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/RECORD +27 -18
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/license.txt +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# setiastro/saspro/cosmicclarity.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
@@ -8,7 +8,7 @@ import tempfile
|
|
|
8
8
|
import uuid
|
|
9
9
|
import numpy as np
|
|
10
10
|
|
|
11
|
-
from PyQt6.QtCore import Qt, QTimer, QSettings, QThread, pyqtSignal, QFileSystemWatcher, QEvent
|
|
11
|
+
from PyQt6.QtCore import Qt, QTimer, QSettings, QThread, pyqtSignal, QFileSystemWatcher, QEvent, QTimer
|
|
12
12
|
from PyQt6.QtGui import QIcon, QAction, QImage, QPixmap
|
|
13
13
|
from PyQt6.QtWidgets import (
|
|
14
14
|
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel, QPushButton,
|
|
@@ -23,57 +23,54 @@ from setiastro.saspro.legacy.image_manager import load_image, save_image
|
|
|
23
23
|
|
|
24
24
|
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
25
25
|
|
|
26
|
+
from setiastro.saspro.cosmicclarity_engines.sharpen_engine import sharpen_rgb01
|
|
27
|
+
from setiastro.saspro.cosmicclarity_engines.denoise_engine import denoise_rgb01
|
|
28
|
+
from setiastro.saspro.cosmicclarity_engines.superres_engine import superres_rgb01
|
|
29
|
+
from setiastro.saspro.cosmicclarity_engines.satellite_engine import (
|
|
30
|
+
get_satellite_models,
|
|
31
|
+
satellite_remove_image,
|
|
32
|
+
)
|
|
26
33
|
# Import centralized preview dialog
|
|
27
34
|
from setiastro.saspro.widgets.preview_dialogs import ImagePreviewDialog
|
|
28
35
|
|
|
29
36
|
import shutil
|
|
30
37
|
import subprocess
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
def _atomic_fsync_replace(src_bytes_writer, final_path: str):
|
|
39
|
+
def _cc_models_installed() -> tuple[bool, str]:
|
|
34
40
|
"""
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
Returns (ok, detail). Since your model download is all-or-nothing (zip),
|
|
42
|
+
we use a single sentinel check to avoid maintaining long required lists.
|
|
37
43
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
# Prefer whatever your app uses as the canonical models directory.
|
|
45
|
+
models_dir = ""
|
|
46
|
+
try:
|
|
47
|
+
from setiastro.saspro.resources import get_models_dir
|
|
48
|
+
models_dir = get_models_dir()
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
tmp_path = os.path.join(d, f".stage_{uuid.uuid4().hex}{ext}")
|
|
52
|
+
if not models_dir or not os.path.isdir(models_dir):
|
|
53
|
+
return False, "Models directory is missing."
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
# Let caller create/write the file at tmp_path
|
|
47
|
-
src_bytes_writer(tmp_path)
|
|
55
|
+
# Sentinel approach: pick ONE file that is guaranteed in the zip.
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
try:
|
|
51
|
-
with open(tmp_path, "rb", buffering=0) as f:
|
|
52
|
-
os.fsync(f.fileno())
|
|
53
|
-
except Exception:
|
|
54
|
-
# If a backend keeps the file open exclusively or doesn't support fsync,
|
|
55
|
-
# we still continue; replace() below is atomic on the same filesystem.
|
|
56
|
-
pass
|
|
57
|
+
sentinel = os.path.join(models_dir, "deep_sharp_stellar_cnn_AI3_5s.pth")
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
os.
|
|
59
|
+
if not os.path.exists(sentinel):
|
|
60
|
+
return False, f"Missing sentinel: {os.path.basename(sentinel)}"
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
return True, ""
|
|
63
|
+
|
|
64
|
+
def _warn_models_missing_and_close(parent: QWidget, detail: str = ""):
|
|
65
|
+
msg = (
|
|
66
|
+
"Cosmic Clarity AI models are not installed.\n\n"
|
|
67
|
+
"Please go to Setting->Preferences to Download and Install.\n"
|
|
68
|
+
)
|
|
69
|
+
if detail:
|
|
70
|
+
msg += f"\nDetails: {detail}\n"
|
|
71
|
+
|
|
72
|
+
QMessageBox.warning(parent, "Cosmic Clarity", msg)
|
|
69
73
|
|
|
70
|
-
finally:
|
|
71
|
-
# Cleanup if anything left behind
|
|
72
|
-
try:
|
|
73
|
-
if os.path.exists(tmp_path):
|
|
74
|
-
os.remove(tmp_path)
|
|
75
|
-
except Exception:
|
|
76
|
-
pass
|
|
77
74
|
|
|
78
75
|
def resolve_cosmic_root(parent=None) -> str:
|
|
79
76
|
s = QSettings()
|
|
@@ -115,27 +112,6 @@ def resolve_cosmic_root(parent=None) -> str:
|
|
|
115
112
|
return folder
|
|
116
113
|
return "" # caller should handle "not set"
|
|
117
114
|
|
|
118
|
-
def _wait_stable_file(path: str, timeout_ms: int = 4000, poll_ms: int = 50) -> bool:
|
|
119
|
-
"""Return True when path exists and its size doesn't change for 2 polls in a row."""
|
|
120
|
-
t0 = time.monotonic()
|
|
121
|
-
last = (-1, -1.0) # (size, mtime)
|
|
122
|
-
stable_count = 0
|
|
123
|
-
while (time.monotonic() - t0) * 1000 < timeout_ms:
|
|
124
|
-
try:
|
|
125
|
-
st = os.stat(path)
|
|
126
|
-
cur = (st.st_size, st.st_mtime)
|
|
127
|
-
if cur == last and st.st_size > 0:
|
|
128
|
-
stable_count += 1
|
|
129
|
-
if stable_count >= 2:
|
|
130
|
-
return True
|
|
131
|
-
else:
|
|
132
|
-
stable_count = 0
|
|
133
|
-
last = cur
|
|
134
|
-
except FileNotFoundError:
|
|
135
|
-
stable_count = 0
|
|
136
|
-
time.sleep(poll_ms / 1000.0)
|
|
137
|
-
return False
|
|
138
|
-
|
|
139
115
|
|
|
140
116
|
# =============================================================================
|
|
141
117
|
# Small helpers
|
|
@@ -145,70 +121,8 @@ def _satellite_exe_name() -> str:
|
|
|
145
121
|
return f"{base}.exe" if os.name == "nt" else base
|
|
146
122
|
|
|
147
123
|
|
|
148
|
-
def _get_cosmic_root_from_settings() -> str:
|
|
149
|
-
return resolve_cosmic_root(parent=None) # or pass self as parent
|
|
150
|
-
|
|
151
|
-
def _ensure_dirs(root: str):
|
|
152
|
-
os.makedirs(os.path.join(root, "input"), exist_ok=True)
|
|
153
|
-
os.makedirs(os.path.join(root, "output"), exist_ok=True)
|
|
154
124
|
|
|
155
|
-
_IMG_EXTS = ('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
|
|
156
|
-
'.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef',
|
|
157
|
-
'.jpg', '.jpeg')
|
|
158
125
|
|
|
159
|
-
def _purge_dir(path: str, *, prefix: str | None = None):
|
|
160
|
-
"""Delete lingering image-like files in a folder. Safe: files only."""
|
|
161
|
-
try:
|
|
162
|
-
if not os.path.isdir(path):
|
|
163
|
-
return
|
|
164
|
-
for fn in os.listdir(path):
|
|
165
|
-
fp = os.path.join(path, fn)
|
|
166
|
-
if not os.path.isfile(fp):
|
|
167
|
-
continue
|
|
168
|
-
if prefix and not fn.startswith(prefix):
|
|
169
|
-
continue
|
|
170
|
-
if os.path.splitext(fn)[1].lower() in _IMG_EXTS:
|
|
171
|
-
try: os.remove(fp)
|
|
172
|
-
except Exception as e:
|
|
173
|
-
import logging
|
|
174
|
-
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
175
|
-
except Exception:
|
|
176
|
-
pass
|
|
177
|
-
|
|
178
|
-
def _purge_cc_io(root: str, *, clear_input: bool, clear_output: bool, prefix: str | None = None):
|
|
179
|
-
"""Convenience to purge CC input/output dirs."""
|
|
180
|
-
try:
|
|
181
|
-
if clear_input:
|
|
182
|
-
_purge_dir(os.path.join(root, "input"), prefix=prefix)
|
|
183
|
-
if clear_output:
|
|
184
|
-
_purge_dir(os.path.join(root, "output"), prefix=prefix)
|
|
185
|
-
except Exception:
|
|
186
|
-
pass
|
|
187
|
-
|
|
188
|
-
def _platform_exe_names(mode: str) -> str:
|
|
189
|
-
"""
|
|
190
|
-
Return executable filename for sharpen/denoise based on OS.
|
|
191
|
-
Matches SASv2 you pasted:
|
|
192
|
-
- Windows: SetiAstroCosmicClarity.exe / SetiAstroCosmicClarity_denoise.exe
|
|
193
|
-
- macOS : SetiAstroCosmicClaritymac / SetiAstroCosmicClarity_denoisemac
|
|
194
|
-
- Linux : SetiAstroCosmicClarity / SetiAstroCosmicClarity_denoise
|
|
195
|
-
"""
|
|
196
|
-
is_win = os.name == "nt"
|
|
197
|
-
is_mac = sys.platform == "darwin"
|
|
198
|
-
if mode == "sharpen":
|
|
199
|
-
return "SetiAstroCosmicClarity.exe" if is_win else ("SetiAstroCosmicClaritymac" if is_mac else "SetiAstroCosmicClarity")
|
|
200
|
-
elif mode == "denoise":
|
|
201
|
-
return "SetiAstroCosmicClarity_denoise.exe" if is_win else ("SetiAstroCosmicClarity_denoisemac" if is_mac else "SetiAstroCosmicClarity_denoise")
|
|
202
|
-
elif mode == "superres":
|
|
203
|
-
# SASv2 used lowercase for superres on Windows
|
|
204
|
-
return "setiastrocosmicclarity_superres.exe" if is_win else "setiastrocosmicclarity_superres"
|
|
205
|
-
else:
|
|
206
|
-
return ""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
# =============================================================================
|
|
210
|
-
# Wait UI
|
|
211
|
-
# =============================================================================
|
|
212
126
|
class WaitDialog(QDialog):
|
|
213
127
|
cancelled = pyqtSignal()
|
|
214
128
|
def __init__(self, title="Processing…", parent=None):
|
|
@@ -223,121 +137,145 @@ class WaitDialog(QDialog):
|
|
|
223
137
|
def append_output(self, line: str): self.txt.append(line)
|
|
224
138
|
def set_progress(self, p: int): self.pb.setValue(int(max(0, min(100, p))))
|
|
225
139
|
|
|
140
|
+
class CosmicClarityEngineWorker(QThread):
|
|
141
|
+
progress = pyqtSignal(int) # 0..100
|
|
142
|
+
log = pyqtSignal(str)
|
|
143
|
+
result = pyqtSignal(object, str) # (np.ndarray float32 RGB01, final_step_title)
|
|
144
|
+
error = pyqtSignal(str)
|
|
226
145
|
|
|
227
|
-
|
|
228
|
-
fileFound = pyqtSignal(str)
|
|
229
|
-
cancelled = pyqtSignal()
|
|
230
|
-
error = pyqtSignal(str)
|
|
231
|
-
|
|
232
|
-
def __init__(
|
|
233
|
-
self,
|
|
234
|
-
glob_pat: str,
|
|
235
|
-
timeout_sec: int = 1800,
|
|
236
|
-
parent=None,
|
|
237
|
-
*,
|
|
238
|
-
poll_ms: int = 200,
|
|
239
|
-
stable_polls: int = 6, # 6 * 200ms = ~1.2s of stability
|
|
240
|
-
stable_timeout_sec: int = 120, # extra time after first detection
|
|
241
|
-
):
|
|
146
|
+
def __init__(self, img_rgb01: np.ndarray, preset: dict, parent=None):
|
|
242
147
|
super().__init__(parent)
|
|
243
|
-
self.
|
|
244
|
-
self.
|
|
245
|
-
self.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
self.
|
|
249
|
-
|
|
250
|
-
def stop(self):
|
|
251
|
-
self._running = False
|
|
252
|
-
|
|
253
|
-
def _best_candidate(self, paths: list[str]) -> str | None:
|
|
254
|
-
if not paths:
|
|
255
|
-
return None
|
|
256
|
-
# prefer biggest file; tie-break by newest mtime
|
|
257
|
-
def key(p):
|
|
258
|
-
try:
|
|
259
|
-
st = os.stat(p)
|
|
260
|
-
return (st.st_size, st.st_mtime)
|
|
261
|
-
except Exception:
|
|
262
|
-
return (-1, -1)
|
|
263
|
-
paths.sort(key=key, reverse=True)
|
|
264
|
-
return paths[0]
|
|
148
|
+
self._img = np.asarray(img_rgb01, dtype=np.float32)
|
|
149
|
+
self._preset = dict(preset)
|
|
150
|
+
self._cancel = False
|
|
151
|
+
|
|
152
|
+
def cancel(self):
|
|
153
|
+
self._cancel = True
|
|
265
154
|
|
|
266
|
-
|
|
155
|
+
# ---- progress adapter ----
|
|
156
|
+
def _mk_progress_cb(self, stage_label: str, stage_weight: float, base_pct: float, total_stages: int):
|
|
267
157
|
"""
|
|
268
|
-
|
|
269
|
-
|
|
158
|
+
stage_weight: fraction of total progress reserved for this stage (0..1)
|
|
159
|
+
base_pct: starting % for this stage
|
|
270
160
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
elif cur == last:
|
|
283
|
-
stable += 1
|
|
284
|
-
else:
|
|
285
|
-
stable = 0
|
|
286
|
-
last = cur
|
|
287
|
-
|
|
288
|
-
if stable >= self._stable_polls:
|
|
289
|
-
# extra “is it readable?” check (important on Windows)
|
|
290
|
-
try:
|
|
291
|
-
with open(path, "rb") as f:
|
|
292
|
-
f.read(64)
|
|
293
|
-
return True
|
|
294
|
-
except PermissionError:
|
|
295
|
-
# still locked by writer, keep waiting
|
|
296
|
-
stable = 0
|
|
297
|
-
except Exception:
|
|
298
|
-
# transient weirdness: keep waiting, don’t declare failure yet
|
|
299
|
-
stable = 0
|
|
300
|
-
|
|
301
|
-
except FileNotFoundError:
|
|
302
|
-
stable = 0
|
|
303
|
-
last = None
|
|
304
|
-
except Exception:
|
|
305
|
-
# don't crash the worker for stat weirdness
|
|
306
|
-
stable = 0
|
|
307
|
-
|
|
308
|
-
time.sleep(self._poll_ms / 1000.0)
|
|
309
|
-
|
|
310
|
-
return False
|
|
161
|
+
def _cb(done: int, total: int):
|
|
162
|
+
if self._cancel or self.isInterruptionRequested():
|
|
163
|
+
return False
|
|
164
|
+
if total <= 0:
|
|
165
|
+
return True
|
|
166
|
+
frac = float(done) / float(total)
|
|
167
|
+
pct = base_pct + stage_weight * 100.0 * frac
|
|
168
|
+
self.progress.emit(int(max(0, min(100, round(pct)))))
|
|
169
|
+
# optional: throttle logs; keep it quiet
|
|
170
|
+
return True
|
|
171
|
+
return _cb
|
|
311
172
|
|
|
312
173
|
def run(self):
|
|
313
|
-
|
|
314
|
-
|
|
174
|
+
try:
|
|
175
|
+
p = self._preset
|
|
176
|
+
mode = str(p.get("mode", "sharpen")).lower()
|
|
177
|
+
|
|
178
|
+
use_gpu = bool(p.get("gpu", True))
|
|
179
|
+
create_new_view = bool(p.get("create_new_view", False)) # not used here; dialog decides
|
|
180
|
+
|
|
181
|
+
img = np.clip(self._img, 0.0, 1.0).astype(np.float32, copy=False)
|
|
182
|
+
|
|
183
|
+
# Decide stage plan
|
|
184
|
+
stages = []
|
|
185
|
+
if mode == "sharpen":
|
|
186
|
+
stages = ["sharpen"]
|
|
187
|
+
elif mode == "denoise":
|
|
188
|
+
stages = ["denoise"]
|
|
189
|
+
elif mode == "both":
|
|
190
|
+
stages = ["sharpen", "denoise"]
|
|
191
|
+
elif mode == "superres":
|
|
192
|
+
stages = ["superres"]
|
|
193
|
+
else:
|
|
194
|
+
stages = ["sharpen"]
|
|
315
195
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
cand = self._best_candidate(matches)
|
|
196
|
+
n = max(1, len(stages))
|
|
197
|
+
stage_weight = 1.0 / float(n)
|
|
319
198
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
seen_first_candidate_at = time.monotonic()
|
|
199
|
+
out = img
|
|
200
|
+
self.progress.emit(0)
|
|
323
201
|
|
|
324
|
-
|
|
325
|
-
|
|
202
|
+
for si, st in enumerate(stages):
|
|
203
|
+
if self._cancel:
|
|
204
|
+
self.error.emit("Cancelled.")
|
|
326
205
|
return
|
|
327
206
|
|
|
328
|
-
|
|
329
|
-
|
|
207
|
+
base_pct = (100.0 * si) / float(n)
|
|
208
|
+
self.log.emit(f"Running {st}…")
|
|
209
|
+
|
|
210
|
+
if st == "sharpen":
|
|
211
|
+
# UI preset fields mirror your existing UI naming
|
|
212
|
+
sharpening_mode = p.get("sharpening_mode", "Both")
|
|
213
|
+
stellar_amount = float(p.get("stellar_amount", 0.5))
|
|
214
|
+
nonstellar_amount = float(p.get("nonstellar_amount", 0.5))
|
|
215
|
+
nonstellar_psf = float(p.get("nonstellar_psf", 3.0))
|
|
216
|
+
auto_psf = bool(p.get("auto_psf", True))
|
|
217
|
+
sharpen_sep = bool(p.get("sharpen_channels_separately", False))
|
|
218
|
+
|
|
219
|
+
prog = self._mk_progress_cb("sharpen", stage_weight, base_pct, n)
|
|
220
|
+
|
|
221
|
+
out = sharpen_rgb01(
|
|
222
|
+
out,
|
|
223
|
+
sharpening_mode=str(sharpening_mode),
|
|
224
|
+
stellar_amount=float(stellar_amount),
|
|
225
|
+
nonstellar_amount=float(nonstellar_amount),
|
|
226
|
+
nonstellar_strength=float(nonstellar_psf),
|
|
227
|
+
auto_detect_psf=bool(auto_psf),
|
|
228
|
+
separate_channels=bool(sharpen_sep),
|
|
229
|
+
use_gpu=bool(use_gpu),
|
|
230
|
+
progress_cb=prog,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
elif st == "denoise":
|
|
234
|
+
den_luma = float(p.get("denoise_luma", 0.5))
|
|
235
|
+
den_col = float(p.get("denoise_color", 0.5))
|
|
236
|
+
den_mode = str(p.get("denoise_mode", "full"))
|
|
237
|
+
sep = bool(p.get("separate_channels", False))
|
|
238
|
+
|
|
239
|
+
prog = self._mk_progress_cb("denoise", stage_weight, base_pct, n)
|
|
240
|
+
|
|
241
|
+
out = denoise_rgb01(
|
|
242
|
+
out,
|
|
243
|
+
denoise_strength=float(den_luma),
|
|
244
|
+
denoise_mode=str(den_mode),
|
|
245
|
+
separate_channels=bool(sep),
|
|
246
|
+
color_denoise_strength=float(den_col),
|
|
247
|
+
use_gpu=bool(use_gpu),
|
|
248
|
+
progress_cb=prog,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
elif st == "superres":
|
|
252
|
+
scale = int(p.get("scale", 2))
|
|
253
|
+
prog = self._mk_progress_cb("superres", stage_weight, base_pct, n)
|
|
254
|
+
|
|
255
|
+
out = superres_rgb01(
|
|
256
|
+
out,
|
|
257
|
+
scale=int(scale),
|
|
258
|
+
use_gpu=True, # keep matching your old UI behavior (GPU hidden for SR)
|
|
259
|
+
progress_cb=prog,
|
|
260
|
+
)
|
|
330
261
|
|
|
331
|
-
|
|
262
|
+
else:
|
|
263
|
+
raise RuntimeError(f"Unknown stage: {st}")
|
|
332
264
|
|
|
333
|
-
|
|
334
|
-
self.cancelled.emit()
|
|
335
|
-
else:
|
|
336
|
-
extra = ""
|
|
337
|
-
if seen_first_candidate_at is not None:
|
|
338
|
-
extra = " (output appeared but never stabilized)"
|
|
339
|
-
self.error.emit("Output file not found within timeout." + extra)
|
|
265
|
+
self.progress.emit(100)
|
|
340
266
|
|
|
267
|
+
# Title for history
|
|
268
|
+
if mode == "both":
|
|
269
|
+
step_title = "Cosmic Clarity – Sharpen + Denoise"
|
|
270
|
+
elif mode == "superres":
|
|
271
|
+
step_title = "Cosmic Clarity – Super Resolution"
|
|
272
|
+
else:
|
|
273
|
+
step_title = f"Cosmic Clarity – {mode.title()}"
|
|
274
|
+
|
|
275
|
+
self.result.emit(np.clip(out, 0.0, 1.0).astype(np.float32, copy=False), step_title)
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self.error.emit(str(e))
|
|
341
279
|
|
|
342
280
|
|
|
343
281
|
# =============================================================================
|
|
@@ -356,6 +294,9 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
356
294
|
"""
|
|
357
295
|
def __init__(self, parent, doc, icon: QIcon | None = None, *, headless: bool=False, bypass_guard: bool=False):
|
|
358
296
|
super().__init__(parent)
|
|
297
|
+
self._engine_thread = None
|
|
298
|
+
self._closing_after_cancel = False
|
|
299
|
+
self._wait = None
|
|
359
300
|
# Hard guard unless explicitly bypassed (used by preset runner)
|
|
360
301
|
if not bypass_guard and self._headless_guard_active():
|
|
361
302
|
# avoid any flash; never show
|
|
@@ -364,7 +305,18 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
364
305
|
import logging
|
|
365
306
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
366
307
|
QTimer.singleShot(0, self.reject)
|
|
367
|
-
return
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
ok, detail = _cc_models_installed()
|
|
311
|
+
if not ok:
|
|
312
|
+
_warn_models_missing_and_close(self, detail)
|
|
313
|
+
try:
|
|
314
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
QTimer.singleShot(0, self.reject)
|
|
318
|
+
return
|
|
319
|
+
|
|
368
320
|
self.setWindowTitle(self.tr("Cosmic Clarity"))
|
|
369
321
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
370
322
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
@@ -381,8 +333,10 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
381
333
|
|
|
382
334
|
self.parent_ref = parent
|
|
383
335
|
self.doc = doc
|
|
336
|
+
self._engine_thread = None
|
|
337
|
+
self._closing_after_cancel = False
|
|
384
338
|
self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
|
|
385
|
-
self.cosmic_root =
|
|
339
|
+
self.cosmic_root = "" # no longer used by in-process engines
|
|
386
340
|
|
|
387
341
|
v = QVBoxLayout(self)
|
|
388
342
|
|
|
@@ -398,8 +352,11 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
398
352
|
grid.addWidget(self.cmb_mode, 0, 1, 1, 2)
|
|
399
353
|
|
|
400
354
|
# GPU
|
|
401
|
-
|
|
402
|
-
|
|
355
|
+
self.lbl_gpu = QLabel(self.tr("Use GPU:"))
|
|
356
|
+
grid.addWidget(self.lbl_gpu, 1, 0)
|
|
357
|
+
|
|
358
|
+
self.cmb_gpu = QComboBox()
|
|
359
|
+
self.cmb_gpu.addItems([self.tr("Yes"), self.tr("No")])
|
|
403
360
|
grid.addWidget(self.cmb_gpu, 1, 1)
|
|
404
361
|
|
|
405
362
|
# Sharpen block
|
|
@@ -474,8 +431,7 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
474
431
|
self._mode_changed() # set initial visibility
|
|
475
432
|
|
|
476
433
|
self._wait = None
|
|
477
|
-
|
|
478
|
-
self._proc = None
|
|
434
|
+
|
|
479
435
|
|
|
480
436
|
self._headless = bool(headless)
|
|
481
437
|
if self._headless:
|
|
@@ -486,6 +442,27 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
486
442
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
487
443
|
self.resize(560, 540)
|
|
488
444
|
|
|
445
|
+
def _ensure_models_installed_or_bail(self) -> bool:
|
|
446
|
+
missing = _missing_cc_models()
|
|
447
|
+
if not missing:
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
# Friendly, actionable message
|
|
451
|
+
msg = (
|
|
452
|
+
"Cosmic Clarity AI models are not installed (or are incomplete).\n\n"
|
|
453
|
+
"To install them:\n"
|
|
454
|
+
" Preferences → AI Models → Download/Update Models\n\n"
|
|
455
|
+
"Missing files:\n - " + "\n - ".join(missing[:12]) +
|
|
456
|
+
("\n ..." if len(missing) > 12 else "")
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
QMessageBox.warning(self, "Cosmic Clarity", msg)
|
|
460
|
+
|
|
461
|
+
# Close immediately
|
|
462
|
+
QTimer.singleShot(0, self.reject)
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
|
|
489
466
|
# ----- UI helpers -----
|
|
490
467
|
def _headless_guard_active(self) -> bool:
|
|
491
468
|
# 1) fast path: flags on the main window
|
|
@@ -546,26 +523,27 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
546
523
|
w.setVisible(show_sr)
|
|
547
524
|
|
|
548
525
|
# GPU hidden for superres (matches your SASv2)
|
|
549
|
-
|
|
550
|
-
self.
|
|
526
|
+
show_gpu = (not show_sr)
|
|
527
|
+
self.lbl_gpu.setVisible(show_gpu)
|
|
528
|
+
self.cmb_gpu.setVisible(show_gpu)
|
|
551
529
|
|
|
552
530
|
# ----- Validation -----
|
|
553
531
|
def _validate_root(self) -> bool:
|
|
554
|
-
if not self.cosmic_root:
|
|
555
|
-
QMessageBox.warning(self, "Cosmic Clarity", "No Cosmic Clarity folder is set. Set it in Preferences (Settings).")
|
|
556
|
-
return False
|
|
557
|
-
# basic presence check (don’t force a specific exe here, we do that later)
|
|
558
|
-
if not os.path.isdir(self.cosmic_root):
|
|
559
|
-
QMessageBox.warning(self, "Cosmic Clarity", "The Cosmic Clarity folder in Settings doesn’t exist anymore.")
|
|
560
|
-
return False
|
|
561
532
|
return True
|
|
562
533
|
|
|
563
534
|
# ----- Execution -----
|
|
564
535
|
def _run_main(self):
|
|
565
|
-
|
|
536
|
+
# --- Basic safety: make sure we have an image ---
|
|
537
|
+
try:
|
|
538
|
+
img = np.asarray(getattr(self.doc, "image", None))
|
|
539
|
+
if img is None or img.size == 0:
|
|
540
|
+
QMessageBox.warning(self, "Cosmic Clarity", "No image loaded in the active view.")
|
|
541
|
+
return
|
|
542
|
+
except Exception:
|
|
543
|
+
QMessageBox.warning(self, "Cosmic Clarity", "No image loaded in the active view.")
|
|
566
544
|
return
|
|
567
545
|
|
|
568
|
-
# --- Register this run as "last action" for replay ---
|
|
546
|
+
# --- Register this run as "last action" for replay (same as you had) ---
|
|
569
547
|
try:
|
|
570
548
|
main = self.parent_ref or self.parent()
|
|
571
549
|
if main is not None:
|
|
@@ -573,282 +551,103 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
573
551
|
payload = {
|
|
574
552
|
"cid": "cosmic_clarity",
|
|
575
553
|
"preset": preset,
|
|
576
|
-
# optional label for your UI if you use it
|
|
577
554
|
"label": f"Cosmic Clarity ({preset.get('mode', 'sharpen')})",
|
|
578
555
|
}
|
|
579
|
-
|
|
580
|
-
# Preferred: use the same helper you used for CLAHE / Morphology / PixelMath
|
|
581
556
|
if hasattr(main, "_set_last_headless_command"):
|
|
582
557
|
main._set_last_headless_command(payload)
|
|
583
558
|
else:
|
|
584
|
-
# Fallback: write directly if you're using a bare _last_headless_command dict
|
|
585
559
|
setattr(main, "_last_headless_command", payload)
|
|
586
560
|
if hasattr(main, "_update_replay_button"):
|
|
587
561
|
main._update_replay_button()
|
|
588
562
|
except Exception:
|
|
589
|
-
#
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
#
|
|
597
|
-
|
|
598
|
-
if
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
ops = [("denoise", "_denoised")]
|
|
563
|
+
pass # never block processing
|
|
564
|
+
|
|
565
|
+
# --- Snapshot UI preset ---
|
|
566
|
+
preset = self.build_preset_from_ui()
|
|
567
|
+
mode = str(preset.get("mode", "sharpen")).lower()
|
|
568
|
+
|
|
569
|
+
# --- Normalize to float32 RGB01 for the engines ---
|
|
570
|
+
# Your engines expect RGB01; keep mono as 3-ch internally.
|
|
571
|
+
arr = np.asarray(self.doc.image, dtype=np.float32)
|
|
572
|
+
if arr.ndim == 2:
|
|
573
|
+
arr = np.repeat(arr[..., None], 3, axis=2)
|
|
574
|
+
elif arr.ndim == 3 and arr.shape[2] == 1:
|
|
575
|
+
arr = np.repeat(arr, 3, axis=2)
|
|
576
|
+
elif arr.ndim == 3 and arr.shape[2] >= 3:
|
|
577
|
+
arr = arr[..., :3]
|
|
605
578
|
else:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
# Save current doc image to input
|
|
609
|
-
base = self._base_name()
|
|
610
|
-
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
611
|
-
try:
|
|
612
|
-
# Use atomic fsync
|
|
613
|
-
base = self._base_name()
|
|
614
|
-
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
615
|
-
arr = self.orig # already float32 [0..1]
|
|
616
|
-
|
|
617
|
-
def _writer(tmp_path):
|
|
618
|
-
# reuse your save_image impl to tmp
|
|
619
|
-
save_image(arr, tmp_path, "tiff", "32-bit floating point",
|
|
620
|
-
getattr(self.doc, "original_header", None),
|
|
621
|
-
getattr(self.doc, "is_mono", False))
|
|
622
|
-
|
|
623
|
-
try:
|
|
624
|
-
_atomic_fsync_replace(_writer, in_path)
|
|
625
|
-
except Exception as e:
|
|
626
|
-
print("Atomic save failed:", repr(e))
|
|
627
|
-
raise
|
|
628
|
-
|
|
629
|
-
# ensure stable on disk before launching
|
|
630
|
-
if not _wait_stable_file(in_path):
|
|
631
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Failed to stage input TIFF (not stable on disk).")
|
|
632
|
-
return
|
|
633
|
-
except Exception as e:
|
|
634
|
-
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
|
|
579
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Unsupported image shape: {arr.shape}")
|
|
635
580
|
return
|
|
636
581
|
|
|
637
|
-
|
|
638
|
-
self._op_queue = ops
|
|
639
|
-
self._current_input = in_path
|
|
640
|
-
self._run_next()
|
|
582
|
+
arr = np.clip(arr, 0.0, 1.0).astype(np.float32, copy=False)
|
|
641
583
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return
|
|
653
|
-
|
|
654
|
-
# ✅ compute base early (we need it for purge + glob)
|
|
655
|
-
base = self._base_name()
|
|
656
|
-
|
|
657
|
-
# ✅ purge any stale outputs for THIS base name (avoids matching old files)
|
|
658
|
-
_purge_cc_io(self.cosmic_root, clear_input=False, clear_output=True, prefix=base)
|
|
659
|
-
|
|
660
|
-
# Build args (SASv2 flags mirrored)
|
|
661
|
-
args = []
|
|
662
|
-
if mode == "sharpen":
|
|
663
|
-
psf = self.sld_psf.value()/10.0
|
|
664
|
-
args += [
|
|
665
|
-
"--sharpening_mode", self.cmb_sh_mode.currentText(),
|
|
666
|
-
"--stellar_amount", f"{self.sld_st_amt.value()/100:.2f}",
|
|
667
|
-
"--nonstellar_strength", f"{psf:.1f}",
|
|
668
|
-
"--nonstellar_amount", f"{self.sld_nst_amt.value()/100:.2f}"
|
|
669
|
-
]
|
|
670
|
-
# NEW: per-channel sharpen toggle
|
|
671
|
-
if self.chk_sh_sep.isChecked():
|
|
672
|
-
args.append("--sharpen_channels_separately")
|
|
673
|
-
|
|
674
|
-
if self.chk_auto_psf.isChecked():
|
|
675
|
-
args.append("--auto_detect_psf")
|
|
676
|
-
elif mode == "denoise":
|
|
677
|
-
args += ["--denoise_strength", f"{self.sld_dn_lum.value()/100:.2f}",
|
|
678
|
-
"--color_denoise_strength", f"{self.sld_dn_col.value()/100:.2f}",
|
|
679
|
-
"--denoise_mode", self.cmb_dn_mode.currentText()]
|
|
680
|
-
if self.chk_dn_sep.isChecked():
|
|
681
|
-
args.append("--separate_channels")
|
|
682
|
-
|
|
683
|
-
if self.cmb_gpu.currentText() == "No" and mode in ("sharpen","denoise"):
|
|
684
|
-
args.append("--disable_gpu")
|
|
685
|
-
|
|
686
|
-
# Run process
|
|
687
|
-
self._proc = QProcess(self)
|
|
688
|
-
self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
|
689
|
-
self._proc.setWorkingDirectory(self.cosmic_root) # <-- add this line
|
|
690
|
-
|
|
691
|
-
self._proc.readyReadStandardOutput.connect(self._read_proc_output_main)
|
|
692
|
-
from functools import partial
|
|
693
|
-
self._proc.finished.connect(partial(self._on_proc_finished, mode, suffix))
|
|
694
|
-
self._proc.setProgram(exe_path)
|
|
695
|
-
self._proc.setArguments(args)
|
|
696
|
-
self._proc.start()
|
|
697
|
-
if not self._proc.waitForStarted(3000):
|
|
698
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Failed to start process.")
|
|
699
|
-
return
|
|
700
|
-
|
|
701
|
-
# Wait for output file
|
|
702
|
-
base = self._base_name()
|
|
703
|
-
out_glob = os.path.join(self.cosmic_root, "output", f"{base}*{suffix}*.*")
|
|
704
|
-
self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
|
|
705
|
-
self._wait.cancelled.connect(self._cancel_all)
|
|
584
|
+
# --- Show wait/progress UI ---
|
|
585
|
+
title = "Cosmic Clarity – " + (
|
|
586
|
+
"Sharpen + Denoise" if mode == "both" else
|
|
587
|
+
"Super Resolution" if mode == "superres" else
|
|
588
|
+
mode.title()
|
|
589
|
+
)
|
|
590
|
+
self._wait = WaitDialog(title, self)
|
|
591
|
+
self._wait.set_progress(0)
|
|
592
|
+
self._wait.append_output("Starting…")
|
|
593
|
+
self._wait.cancelled.connect(self._cancel_all_engine)
|
|
706
594
|
self._wait.show()
|
|
707
595
|
|
|
708
|
-
|
|
709
|
-
self.
|
|
710
|
-
self._wait_thread.error.connect(self._on_wait_error)
|
|
711
|
-
self._wait_thread.cancelled.connect(self._on_wait_cancel)
|
|
712
|
-
self._wait_thread.start()
|
|
596
|
+
# --- Run the in-process engine worker ---
|
|
597
|
+
self._engine_thread = CosmicClarityEngineWorker(arr, preset, parent=self)
|
|
713
598
|
|
|
714
|
-
|
|
715
|
-
self.
|
|
599
|
+
self._engine_thread.progress.connect(lambda p: self._wait.set_progress(int(p)) if self._wait else None)
|
|
600
|
+
self._engine_thread.log.connect(lambda s: self._wait.append_output(str(s)) if self._wait else None)
|
|
601
|
+
self._engine_thread.error.connect(self._on_engine_error)
|
|
602
|
+
self._engine_thread.result.connect(self._on_engine_result)
|
|
716
603
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if not self._wait:
|
|
720
|
-
return
|
|
604
|
+
# IMPORTANT: cleanup when the thread actually finishes
|
|
605
|
+
self._engine_thread.finished.connect(self._engine_cleanup_and_maybe_close)
|
|
721
606
|
|
|
722
|
-
|
|
723
|
-
line = line.strip()
|
|
724
|
-
if not line:
|
|
725
|
-
continue
|
|
726
|
-
|
|
727
|
-
if line.startswith("Progress:"):
|
|
728
|
-
try:
|
|
729
|
-
pct = float(line.split()[1].replace("%", ""))
|
|
730
|
-
self._wait.set_progress(int(pct))
|
|
731
|
-
except Exception:
|
|
732
|
-
pass
|
|
733
|
-
continue # <- skip echo
|
|
734
|
-
|
|
735
|
-
# non-progress lines: keep showing + printing
|
|
736
|
-
self._wait.append_output(line)
|
|
737
|
-
print(f"[CC] {line}")
|
|
738
|
-
|
|
739
|
-
def _on_proc_finished(self, mode, suffix, code, status):
|
|
740
|
-
if code != 0:
|
|
741
|
-
if self._wait: self._wait.append_output(f"Process exited with code {code}.")
|
|
742
|
-
# still let the file-watcher decide success/failure (some exes write before exit)
|
|
743
|
-
|
|
744
|
-
def _on_output_file(self, out_path: str, mode: str):
|
|
745
|
-
# stop waiting UI
|
|
746
|
-
if self._wait: self._wait.close(); self._wait = None
|
|
747
|
-
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
748
|
-
|
|
749
|
-
has_more = bool(self._op_queue)
|
|
750
|
-
|
|
751
|
-
# --- Optimization: Chained Execution Fast Path ---
|
|
752
|
-
# If we have more steps, skip the expensive load/display/save cycle.
|
|
753
|
-
# Just move the output file to be the input for the next step.
|
|
754
|
-
if has_more:
|
|
755
|
-
if not out_path or not os.path.exists(out_path):
|
|
756
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Output file missing during chain execution.")
|
|
757
|
-
self._op_queue.clear()
|
|
758
|
-
return
|
|
607
|
+
self._engine_thread.start()
|
|
759
608
|
|
|
760
|
-
base = self._base_name()
|
|
761
|
-
next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
762
|
-
prev_in = getattr(self, "_current_input", None)
|
|
763
609
|
|
|
610
|
+
def _cancel_all_engine(self):
|
|
611
|
+
thr = getattr(self, "_engine_thread", None)
|
|
612
|
+
if thr is not None:
|
|
764
613
|
try:
|
|
765
|
-
#
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
# We remove target first to be sure.
|
|
770
|
-
if os.path.exists(next_in):
|
|
771
|
-
os.remove(next_in)
|
|
772
|
-
shutil.move(out_path, next_in)
|
|
773
|
-
|
|
774
|
-
# Ensure stability of the *new* input
|
|
775
|
-
if not _wait_stable_file(next_in):
|
|
776
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Staged input for next step is unstable.")
|
|
777
|
-
self._op_queue.clear()
|
|
778
|
-
return
|
|
779
|
-
|
|
780
|
-
self._current_input = next_in
|
|
781
|
-
|
|
782
|
-
# Cleanup previous input if distinct
|
|
783
|
-
if prev_in and prev_in != next_in and os.path.exists(prev_in):
|
|
784
|
-
os.remove(prev_in)
|
|
614
|
+
thr.cancel() # your flag
|
|
615
|
+
thr.requestInterruption() # Qt-native interruption request
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
785
618
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
619
|
+
# Keep the wait dialog open (or show "Canceling…") instead of closing immediately
|
|
620
|
+
if self._wait:
|
|
621
|
+
self._wait.append_output("Cancel requested… waiting for worker to stop.")
|
|
622
|
+
self._wait.set_progress(0)
|
|
623
|
+
# optionally disable cancel button to avoid spam clicks:
|
|
624
|
+
# (you'd need to store the cancel button in WaitDialog to disable it)
|
|
790
625
|
|
|
791
|
-
#
|
|
792
|
-
|
|
793
|
-
return
|
|
626
|
+
# If you really want to hide it, do hide() not close() (close may cascade deletes)
|
|
627
|
+
# self._wait.hide()
|
|
794
628
|
|
|
795
|
-
# --- Final Step (or Single Step): Load and Display ---
|
|
796
|
-
try:
|
|
797
|
-
img, hdr, bd, mono = load_image(out_path)
|
|
798
|
-
if img is None:
|
|
799
|
-
raise RuntimeError("Unable to load output image.")
|
|
800
|
-
except Exception as e:
|
|
801
|
-
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load output:\n{e}")
|
|
802
|
-
return
|
|
803
629
|
|
|
804
|
-
|
|
630
|
+
def _on_engine_error(self, msg: str):
|
|
631
|
+
if self._wait:
|
|
632
|
+
self._wait.close()
|
|
633
|
+
self._wait = None
|
|
634
|
+
QMessageBox.critical(self, "Cosmic Clarity", msg)
|
|
805
635
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
636
|
+
def _on_engine_result(self, out_arr: np.ndarray, step_title: str):
|
|
637
|
+
if self._wait:
|
|
638
|
+
self._wait.close()
|
|
639
|
+
self._wait = None
|
|
809
640
|
|
|
641
|
+
create_new = (self.cmb_target.currentIndex() == 1)
|
|
810
642
|
if create_new:
|
|
811
|
-
ok = self._spawn_new_doc_from_numpy(
|
|
643
|
+
ok = self._spawn_new_doc_from_numpy(out_arr, step_title)
|
|
812
644
|
if not ok:
|
|
813
|
-
self._apply_to_active(
|
|
645
|
+
self._apply_to_active(out_arr, step_title)
|
|
814
646
|
else:
|
|
815
|
-
self._apply_to_active(
|
|
816
|
-
|
|
817
|
-
# Cleanup final output
|
|
818
|
-
if out_path and os.path.exists(out_path):
|
|
819
|
-
try: os.remove(out_path)
|
|
820
|
-
except OSError: pass
|
|
821
|
-
|
|
822
|
-
# Cleanup final input
|
|
823
|
-
prev_in = getattr(self, "_current_input", None)
|
|
824
|
-
if prev_in and os.path.exists(prev_in):
|
|
825
|
-
try: os.remove(prev_in)
|
|
826
|
-
except OSError: pass
|
|
827
|
-
|
|
828
|
-
# Final purge
|
|
829
|
-
try:
|
|
830
|
-
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
831
|
-
except Exception:
|
|
832
|
-
pass
|
|
833
|
-
self.accept()
|
|
647
|
+
self._apply_to_active(out_arr, step_title)
|
|
834
648
|
|
|
649
|
+
self.accept()
|
|
835
650
|
|
|
836
|
-
def _on_wait_error(self, msg: str):
|
|
837
|
-
if self._wait: self._wait.close(); self._wait = None
|
|
838
|
-
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
839
|
-
QMessageBox.critical(self, "Cosmic Clarity", msg)
|
|
840
|
-
|
|
841
|
-
def _on_wait_cancel(self):
|
|
842
|
-
if self._wait: self._wait.close(); self._wait = None
|
|
843
|
-
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
844
|
-
|
|
845
|
-
def _cancel_all(self):
|
|
846
|
-
try:
|
|
847
|
-
if self._proc: self._proc.kill()
|
|
848
|
-
except Exception as e:
|
|
849
|
-
import logging
|
|
850
|
-
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
851
|
-
self._on_wait_cancel()
|
|
852
651
|
|
|
853
652
|
def _base_name(self) -> str:
|
|
854
653
|
fp = getattr(self.doc, "file_path", None)
|
|
@@ -904,64 +703,6 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
904
703
|
|
|
905
704
|
|
|
906
705
|
# ----- Super-resolution -----
|
|
907
|
-
def _run_superres(self):
|
|
908
|
-
exe_name = _platform_exe_names("superres")
|
|
909
|
-
exe_path = os.path.join(self.cosmic_root, exe_name)
|
|
910
|
-
if not os.path.exists(exe_path):
|
|
911
|
-
QMessageBox.critical(self, "Cosmic Clarity", f"Super Resolution executable not found:\n{exe_path}")
|
|
912
|
-
return
|
|
913
|
-
|
|
914
|
-
_ensure_dirs(self.cosmic_root)
|
|
915
|
-
# 🔸 purge output too so any file that appears is from THIS run
|
|
916
|
-
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
917
|
-
|
|
918
|
-
base = self._base_name()
|
|
919
|
-
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
920
|
-
try:
|
|
921
|
-
save_image(self.orig, in_path, "tiff", "32-bit floating point",
|
|
922
|
-
getattr(self.doc, "original_header", None),
|
|
923
|
-
getattr(self.doc, "is_mono", False))
|
|
924
|
-
except Exception as e:
|
|
925
|
-
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
|
|
926
|
-
return
|
|
927
|
-
self._current_input = in_path
|
|
928
|
-
|
|
929
|
-
scale = int(self.cmb_scale.currentText().replace("x", ""))
|
|
930
|
-
# keep args as-is if your superres build expects explicit paths
|
|
931
|
-
args = [
|
|
932
|
-
"--input", in_path,
|
|
933
|
-
"--output_dir", os.path.join(self.cosmic_root, "output"),
|
|
934
|
-
"--scale", str(scale),
|
|
935
|
-
"--model_dir", self.cosmic_root
|
|
936
|
-
]
|
|
937
|
-
|
|
938
|
-
self._proc = QProcess(self)
|
|
939
|
-
self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
|
940
|
-
self._proc.readyReadStandardOutput.connect(self._read_superres_output_main)
|
|
941
|
-
# finished handler not required; the file watcher drives success
|
|
942
|
-
self._proc.setProgram(exe_path)
|
|
943
|
-
self._proc.setArguments(args)
|
|
944
|
-
self._proc.start()
|
|
945
|
-
if not self._proc.waitForStarted(3000):
|
|
946
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Failed to start Super Resolution process.")
|
|
947
|
-
return
|
|
948
|
-
|
|
949
|
-
self._wait = WaitDialog("Cosmic Clarity – Super Resolution", self)
|
|
950
|
-
self._wait.cancelled.connect(self._cancel_all)
|
|
951
|
-
self._wait.show()
|
|
952
|
-
|
|
953
|
-
# 🔸 Watch broadly; we purged output so the first file is from this run.
|
|
954
|
-
# We'll still re-pick the exact file in the slot for safety.
|
|
955
|
-
self._sr_base = base
|
|
956
|
-
self._sr_scale = scale
|
|
957
|
-
out_glob = os.path.join(self.cosmic_root, "output", "*.*")
|
|
958
|
-
|
|
959
|
-
self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
|
|
960
|
-
self._wait_thread.fileFound.connect(self._on_superres_file) # path arg is ignored; we reselect
|
|
961
|
-
self._wait_thread.error.connect(self._on_wait_error)
|
|
962
|
-
self._wait_thread.cancelled.connect(self._on_wait_cancel)
|
|
963
|
-
self._wait_thread.start()
|
|
964
|
-
|
|
965
706
|
|
|
966
707
|
def apply_preset(self, p: dict):
|
|
967
708
|
# Mode
|
|
@@ -1031,100 +772,35 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
1031
772
|
|
|
1032
773
|
return preset
|
|
1033
774
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
|
1041
|
-
if not self._wait: return
|
|
1042
|
-
for line in out.splitlines():
|
|
1043
|
-
if line.startswith("PROGRESS:") or line.startswith("Progress:"):
|
|
1044
|
-
try:
|
|
1045
|
-
tail = line.split(":",1)[1] if ":" in line else line.split()[1]
|
|
1046
|
-
pct = int(float(tail.strip().replace("%","")))
|
|
1047
|
-
self._wait.set_progress(pct)
|
|
1048
|
-
except Exception:
|
|
1049
|
-
pass
|
|
1050
|
-
else:
|
|
1051
|
-
self._wait.append_output(line)
|
|
1052
|
-
|
|
1053
|
-
def _pick_superres_output(self, base: str, scale: int) -> str | None:
|
|
1054
|
-
"""
|
|
1055
|
-
Find the most plausible super-res output file. We try several common
|
|
1056
|
-
name patterns, then fall back to the newest/largest file in the output dir.
|
|
1057
|
-
"""
|
|
1058
|
-
out_dir = os.path.join(self.cosmic_root, "output")
|
|
1059
|
-
|
|
1060
|
-
def _best(paths: list[str]) -> str | None:
|
|
1061
|
-
if not paths:
|
|
1062
|
-
return None
|
|
1063
|
-
# prefer bigger file; tie-break by newest mtime
|
|
1064
|
-
paths.sort(key=lambda p: (os.path.getsize(p), os.path.getmtime(p)), reverse=True)
|
|
1065
|
-
return paths[0]
|
|
1066
|
-
|
|
1067
|
-
# common patterns used by different builds
|
|
1068
|
-
patterns = [
|
|
1069
|
-
f"{base}_upscaled{scale}.*",
|
|
1070
|
-
f"{base}_upscaled*.*",
|
|
1071
|
-
f"{base}*upscal*.*",
|
|
1072
|
-
f"{base}*superres*.*",
|
|
1073
|
-
]
|
|
1074
|
-
for pat in patterns:
|
|
1075
|
-
hit = _best(glob.glob(os.path.join(out_dir, pat)))
|
|
1076
|
-
if hit:
|
|
1077
|
-
return hit
|
|
1078
|
-
|
|
1079
|
-
# fallback: anything in output (we purge it first, so whatever appears is ours)
|
|
1080
|
-
return _best(glob.glob(os.path.join(out_dir, "*.*")))
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
def _on_superres_file(self, _first_path_from_watcher: str):
|
|
1084
|
-
# stop waiting UI
|
|
1085
|
-
if self._wait: self._wait.close(); self._wait = None
|
|
1086
|
-
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
1087
|
-
|
|
1088
|
-
# pick the actual output (robust to naming)
|
|
1089
|
-
base = getattr(self, "_sr_base", self._base_name())
|
|
1090
|
-
scale = int(getattr(self, "_sr_scale", int(self.cmb_scale.currentText().replace("x",""))))
|
|
1091
|
-
out_path = self._pick_superres_output(base, scale)
|
|
1092
|
-
if not out_path or not os.path.exists(out_path):
|
|
1093
|
-
QMessageBox.critical(self, "Cosmic Clarity", "Super Resolution output file not found.")
|
|
775
|
+
def closeEvent(self, e):
|
|
776
|
+
thr = getattr(self, "_engine_thread", None)
|
|
777
|
+
if thr is not None and thr.isRunning():
|
|
778
|
+
self._closing_after_cancel = True
|
|
779
|
+
self._cancel_all_engine()
|
|
780
|
+
e.ignore()
|
|
1094
781
|
return
|
|
782
|
+
super().closeEvent(e)
|
|
1095
783
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load Super Resolution output:\n{e}")
|
|
784
|
+
def reject(self):
|
|
785
|
+
thr = getattr(self, "_engine_thread", None)
|
|
786
|
+
if thr is not None and thr.isRunning():
|
|
787
|
+
self._closing_after_cancel = True
|
|
788
|
+
self._cancel_all_engine()
|
|
1102
789
|
return
|
|
790
|
+
super().reject()
|
|
1103
791
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
create_new = (self.cmb_target.currentIndex() == 1)
|
|
1107
|
-
|
|
1108
|
-
if create_new:
|
|
1109
|
-
ok = self._spawn_new_doc_from_numpy(dest, step_title)
|
|
1110
|
-
if not ok:
|
|
1111
|
-
self._apply_to_active(dest, step_title)
|
|
1112
|
-
else:
|
|
1113
|
-
self._apply_to_active(dest, step_title)
|
|
1114
|
-
|
|
1115
|
-
# cleanup mirrors sharpen/denoise
|
|
792
|
+
def _engine_cleanup_and_maybe_close(self):
|
|
793
|
+
# Called when thread has truly stopped
|
|
1116
794
|
try:
|
|
1117
|
-
if
|
|
1118
|
-
|
|
1119
|
-
if os.path.exists(out_path):
|
|
1120
|
-
os.remove(out_path)
|
|
1121
|
-
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
795
|
+
if self._engine_thread is not None:
|
|
796
|
+
self._engine_thread.deleteLater()
|
|
1122
797
|
except Exception:
|
|
1123
798
|
pass
|
|
799
|
+
self._engine_thread = None
|
|
1124
800
|
|
|
1125
|
-
self.
|
|
1126
|
-
|
|
1127
|
-
|
|
801
|
+
if self._closing_after_cancel:
|
|
802
|
+
self._closing_after_cancel = False
|
|
803
|
+
super().reject()
|
|
1128
804
|
|
|
1129
805
|
# =============================================================================
|
|
1130
806
|
# Satellite removal
|
|
@@ -1141,6 +817,19 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1141
817
|
"""
|
|
1142
818
|
def __init__(self, parent, doc=None, icon: QIcon | None = None):
|
|
1143
819
|
super().__init__(parent)
|
|
820
|
+
self._engine_thread = None
|
|
821
|
+
self._closing_after_cancel = False
|
|
822
|
+
self._wait = None
|
|
823
|
+
ok, detail = _cc_models_installed()
|
|
824
|
+
if not ok:
|
|
825
|
+
_warn_models_missing_and_close(self, detail)
|
|
826
|
+
try:
|
|
827
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
QTimer.singleShot(0, self.reject)
|
|
831
|
+
return
|
|
832
|
+
|
|
1144
833
|
self.setWindowTitle("Cosmic Clarity – Satellite Removal")
|
|
1145
834
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
1146
835
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
@@ -1152,7 +841,7 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1152
841
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1153
842
|
|
|
1154
843
|
self.settings = QSettings()
|
|
1155
|
-
|
|
844
|
+
|
|
1156
845
|
self.input_folder = ""
|
|
1157
846
|
self.output_folder = ""
|
|
1158
847
|
self.sensitivity = 0.10 # 0.01–0.50
|
|
@@ -1224,11 +913,6 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1224
913
|
self.btn_monitor = QPushButton("Live Monitor Input Folder"); self.btn_monitor.clicked.connect(self._live_monitor)
|
|
1225
914
|
left.addWidget(self.btn_monitor)
|
|
1226
915
|
|
|
1227
|
-
# Folder display + chooser for Cosmic Clarity root
|
|
1228
|
-
self.lbl_root = QLabel(f"Folder: {self.cosmic_clarity_folder or 'Not set'}")
|
|
1229
|
-
left.addWidget(self.lbl_root)
|
|
1230
|
-
self.btn_pick_root = QPushButton("Choose Cosmic Clarity Folder…"); self.btn_pick_root.clicked.connect(self._choose_root)
|
|
1231
|
-
left.addWidget(self.btn_pick_root)
|
|
1232
916
|
|
|
1233
917
|
left.addStretch(1)
|
|
1234
918
|
|
|
@@ -1253,13 +937,7 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1253
937
|
|
|
1254
938
|
self.resize(900, 600)
|
|
1255
939
|
|
|
1256
|
-
|
|
1257
|
-
def _choose_root(self):
|
|
1258
|
-
folder = QFileDialog.getExistingDirectory(self, "Select Cosmic Clarity Folder", self.cosmic_clarity_folder or "")
|
|
1259
|
-
if not folder: return
|
|
1260
|
-
self.cosmic_clarity_folder = folder
|
|
1261
|
-
self.settings.setValue("paths/cosmic_clarity", folder)
|
|
1262
|
-
self.lbl_root.setText(f"Folder: {folder}")
|
|
940
|
+
|
|
1263
941
|
|
|
1264
942
|
# ---------- IO folders ----------
|
|
1265
943
|
def _choose_input(self):
|
|
@@ -1362,146 +1040,199 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1362
1040
|
|
|
1363
1041
|
# ---------- Single image processing ----------
|
|
1364
1042
|
def _process_single_image(self):
|
|
1365
|
-
|
|
1366
|
-
|
|
1043
|
+
from PyQt6.QtCore import QCoreApplication, Qt
|
|
1044
|
+
from PyQt6.QtWidgets import QMessageBox, QFileDialog, QInputDialog, QProgressDialog
|
|
1045
|
+
|
|
1046
|
+
from setiastro.saspro.cosmicclarity_engines.satellite_engine import (
|
|
1047
|
+
get_satellite_models,
|
|
1048
|
+
satellite_remove_image,
|
|
1049
|
+
)
|
|
1367
1050
|
|
|
1051
|
+
# ----------------------------
|
|
1368
1052
|
# Decide source: view or file
|
|
1053
|
+
# ----------------------------
|
|
1054
|
+
views = self._collect_open_views()
|
|
1369
1055
|
use_view = False
|
|
1056
|
+
|
|
1370
1057
|
if views:
|
|
1371
1058
|
mb = QMessageBox(self)
|
|
1372
1059
|
mb.setWindowTitle("Process Single Image")
|
|
1373
1060
|
mb.setText("Choose the source to process:")
|
|
1374
|
-
btn_view
|
|
1375
|
-
btn_file
|
|
1061
|
+
btn_view = mb.addButton("Open View", QMessageBox.ButtonRole.AcceptRole)
|
|
1062
|
+
btn_file = mb.addButton("File on Disk", QMessageBox.ButtonRole.AcceptRole)
|
|
1376
1063
|
mb.addButton(QMessageBox.StandardButton.Cancel)
|
|
1377
1064
|
mb.exec()
|
|
1065
|
+
|
|
1378
1066
|
if mb.clickedButton() is btn_view:
|
|
1379
1067
|
use_view = True
|
|
1380
|
-
elif mb.clickedButton() is
|
|
1068
|
+
elif mb.clickedButton() is btn_file:
|
|
1069
|
+
use_view = False
|
|
1070
|
+
else:
|
|
1381
1071
|
return
|
|
1382
1072
|
|
|
1383
|
-
#
|
|
1073
|
+
# ----------------------------
|
|
1074
|
+
# Gather engine params from UI
|
|
1075
|
+
# ----------------------------
|
|
1076
|
+
use_gpu = (self.cmb_gpu.currentText() == "Yes")
|
|
1077
|
+
mode = self.cmb_mode.currentText().lower()
|
|
1078
|
+
clip_trail = bool(self.chk_clip.isChecked())
|
|
1079
|
+
sensitivity = float(self.sensitivity)
|
|
1080
|
+
skip_if_none = bool(self.chk_skip.isChecked())
|
|
1081
|
+
|
|
1082
|
+
# ----------------------------------------------------
|
|
1083
|
+
# Acquire input image FIRST (no progress dialog yet)
|
|
1084
|
+
# ----------------------------------------------------
|
|
1085
|
+
hdr = None
|
|
1086
|
+
mono = False
|
|
1087
|
+
chosen_doc = None
|
|
1088
|
+
file_path = None
|
|
1089
|
+
|
|
1384
1090
|
if use_view:
|
|
1385
|
-
#
|
|
1386
|
-
chosen_doc = None
|
|
1091
|
+
# Choose which doc
|
|
1387
1092
|
if len(views) == 1:
|
|
1388
1093
|
chosen_doc = views[0][1]
|
|
1389
|
-
base_name = self._base_name_for_doc(chosen_doc)
|
|
1390
1094
|
else:
|
|
1391
1095
|
titles = [t for (t, _) in views]
|
|
1392
1096
|
sel, ok = QInputDialog.getItem(self, "Select View", "Choose an open view:", titles, 0, False)
|
|
1393
1097
|
if not ok:
|
|
1394
1098
|
return
|
|
1395
|
-
|
|
1396
|
-
chosen_doc = views[idx][1]
|
|
1397
|
-
base_name = self._base_name_for_doc(chosen_doc)
|
|
1099
|
+
chosen_doc = views[titles.index(sel)][1]
|
|
1398
1100
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
staged_in = os.path.join(temp_in, f"{base_name}.tif")
|
|
1101
|
+
if chosen_doc is None or getattr(chosen_doc, "image", None) is None:
|
|
1102
|
+
QMessageBox.warning(self, "Warning", "Selected view has no image.")
|
|
1103
|
+
return
|
|
1403
1104
|
|
|
1404
1105
|
try:
|
|
1405
|
-
|
|
1406
|
-
img = np.clip(
|
|
1407
|
-
save_image(
|
|
1408
|
-
img, staged_in,
|
|
1409
|
-
"tiff", "32-bit floating point",
|
|
1410
|
-
getattr(chosen_doc, "original_header", None),
|
|
1411
|
-
getattr(chosen_doc, "is_mono", False)
|
|
1412
|
-
)
|
|
1106
|
+
img = np.asarray(chosen_doc.image, dtype=np.float32)
|
|
1107
|
+
img = np.clip(img, 0.0, 1.0)
|
|
1413
1108
|
except Exception as e:
|
|
1414
|
-
QMessageBox.critical(self, "Error", f"Failed to
|
|
1109
|
+
QMessageBox.critical(self, "Error", f"Failed to read image from view:\n{e}")
|
|
1415
1110
|
return
|
|
1416
1111
|
|
|
1417
|
-
|
|
1112
|
+
else:
|
|
1113
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1114
|
+
self, "Select Image", "",
|
|
1115
|
+
"Image Files (*.png *.tif *.tiff *.fit *.fits *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef *.jpg *.jpeg)"
|
|
1116
|
+
)
|
|
1117
|
+
if not file_path:
|
|
1118
|
+
return # user cancelled file dialog
|
|
1119
|
+
|
|
1418
1120
|
try:
|
|
1419
|
-
|
|
1121
|
+
img, hdr, bd, mono = load_image(file_path)
|
|
1122
|
+
if img is None:
|
|
1123
|
+
raise RuntimeError("load_image returned None.")
|
|
1124
|
+
img = np.asarray(img, dtype=np.float32)
|
|
1125
|
+
img = np.clip(img, 0.0, 1.0)
|
|
1420
1126
|
except Exception as e:
|
|
1421
|
-
QMessageBox.critical(self, "Error", f"
|
|
1127
|
+
QMessageBox.critical(self, "Error", f"Failed to load image:\n{e}")
|
|
1422
1128
|
return
|
|
1423
1129
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
result, hdr, bd, mono = load_image(out_path)
|
|
1433
|
-
if result is None:
|
|
1434
|
-
raise RuntimeError("Unable to load output image.")
|
|
1435
|
-
result = result.astype(np.float32, copy=False)
|
|
1436
|
-
|
|
1437
|
-
# Apply back to the chosen doc
|
|
1438
|
-
if hasattr(chosen_doc, "set_image"):
|
|
1439
|
-
chosen_doc.set_image(result, step_name="Cosmic Clarity – Satellite Removal")
|
|
1440
|
-
elif hasattr(chosen_doc, "apply_numpy"):
|
|
1441
|
-
chosen_doc.apply_numpy(result, step_name="Cosmic Clarity – Satellite Removal")
|
|
1442
|
-
else:
|
|
1443
|
-
chosen_doc.image = result
|
|
1444
|
-
except Exception as e:
|
|
1445
|
-
QMessageBox.critical(self, "Error", f"Failed to apply result to view:\n{e}")
|
|
1446
|
-
# fall through to cleanup
|
|
1447
|
-
finally:
|
|
1448
|
-
# Clean up temp files
|
|
1449
|
-
try:
|
|
1450
|
-
if os.path.exists(out_path): os.remove(out_path)
|
|
1451
|
-
except Exception:
|
|
1452
|
-
pass
|
|
1130
|
+
# ----------------------------
|
|
1131
|
+
# Load/cached models ONCE
|
|
1132
|
+
# ----------------------------
|
|
1133
|
+
try:
|
|
1134
|
+
models = get_satellite_models(use_gpu=use_gpu, status_cb=lambda s: None)
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
QMessageBox.critical(self, "Error", f"Failed to load Satellite models:\n{e}")
|
|
1137
|
+
return
|
|
1453
1138
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1139
|
+
# ----------------------------
|
|
1140
|
+
# Progress dialog helper
|
|
1141
|
+
# IMPORTANT: create/show AFTER input is chosen
|
|
1142
|
+
# ----------------------------
|
|
1143
|
+
pd = QProgressDialog("Satellite removal…", "Cancel", 0, 100, self)
|
|
1144
|
+
pd.setWindowModality(Qt.WindowModality.WindowModal)
|
|
1145
|
+
pd.setMinimumDuration(250) # helps prevent weird cancel behavior
|
|
1146
|
+
pd.setAutoClose(True)
|
|
1147
|
+
pd.setAutoReset(True)
|
|
1148
|
+
pd.setValue(0)
|
|
1460
1149
|
|
|
1461
|
-
|
|
1150
|
+
cancelled = {"flag": False}
|
|
1462
1151
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
self, "Select Image", "",
|
|
1466
|
-
"Image Files (*.png *.tif *.tiff *.fit *.fits *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef *.jpg *.jpeg)"
|
|
1467
|
-
)
|
|
1468
|
-
if not file_path:
|
|
1469
|
-
QMessageBox.warning(self, "Warning", "No file selected.")
|
|
1470
|
-
return
|
|
1152
|
+
def _on_cancel():
|
|
1153
|
+
cancelled["flag"] = True
|
|
1471
1154
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1155
|
+
pd.canceled.connect(_on_cancel)
|
|
1156
|
+
|
|
1157
|
+
def _progress(done: int, total: int):
|
|
1158
|
+
if total > 0:
|
|
1159
|
+
pd.setValue(int((done * 100) / total))
|
|
1160
|
+
else:
|
|
1161
|
+
pd.setValue(0)
|
|
1162
|
+
|
|
1163
|
+
QCoreApplication.processEvents()
|
|
1479
1164
|
|
|
1165
|
+
# IMPORTANT: return True to continue, False to cancel
|
|
1166
|
+
return (not cancelled["flag"]) and (not pd.wasCanceled())
|
|
1167
|
+
|
|
1168
|
+
# ----------------------------
|
|
1169
|
+
# Run engine
|
|
1170
|
+
# ----------------------------
|
|
1480
1171
|
try:
|
|
1481
|
-
|
|
1172
|
+
cancelled["flag"] = False # reset right before run
|
|
1173
|
+
pd.show()
|
|
1174
|
+
|
|
1175
|
+
out, detected = satellite_remove_image(
|
|
1176
|
+
img,
|
|
1177
|
+
models=models,
|
|
1178
|
+
mode=mode,
|
|
1179
|
+
clip_trail=clip_trail,
|
|
1180
|
+
sensitivity=sensitivity,
|
|
1181
|
+
progress_cb=_progress,
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1482
1184
|
except Exception as e:
|
|
1483
1185
|
QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
|
|
1484
1186
|
return
|
|
1485
1187
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
QMessageBox.
|
|
1188
|
+
finally:
|
|
1189
|
+
pass
|
|
1190
|
+
|
|
1191
|
+
# ----------------------------
|
|
1192
|
+
# Handle cancel / skip-save
|
|
1193
|
+
# ----------------------------
|
|
1194
|
+
if cancelled["flag"]:
|
|
1195
|
+
QMessageBox.information(self, "Cancelled", "Operation cancelled.")
|
|
1196
|
+
return
|
|
1197
|
+
|
|
1198
|
+
if skip_if_none and (not detected):
|
|
1199
|
+
QMessageBox.information(self, "Satellite Removal", "No satellite trail detected (skip-save enabled).")
|
|
1200
|
+
return
|
|
1498
1201
|
|
|
1499
|
-
|
|
1202
|
+
out = np.asarray(out, dtype=np.float32, order="C")
|
|
1203
|
+
|
|
1204
|
+
# ----------------------------
|
|
1205
|
+
# Apply or save
|
|
1206
|
+
# ----------------------------
|
|
1207
|
+
if use_view:
|
|
1208
|
+
if hasattr(chosen_doc, "set_image"):
|
|
1209
|
+
chosen_doc.set_image(out, step_name="Cosmic Clarity – Satellite Removal")
|
|
1210
|
+
elif hasattr(chosen_doc, "apply_numpy"):
|
|
1211
|
+
chosen_doc.apply_numpy(out, step_name="Cosmic Clarity – Satellite Removal")
|
|
1212
|
+
else:
|
|
1213
|
+
chosen_doc.image = out
|
|
1214
|
+
return
|
|
1215
|
+
|
|
1216
|
+
# file path save
|
|
1500
1217
|
try:
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1218
|
+
base, ext = os.path.splitext(file_path)
|
|
1219
|
+
dst = base + "_satellited" + ext
|
|
1220
|
+
|
|
1221
|
+
el = ext.lower()
|
|
1222
|
+
if el in (".tif", ".tiff"):
|
|
1223
|
+
fmt, bitdepth = "tiff", "32-bit floating point"
|
|
1224
|
+
elif el in (".fit", ".fits"):
|
|
1225
|
+
fmt, bitdepth = "fits", "32-bit floating point"
|
|
1226
|
+
elif el == ".xisf":
|
|
1227
|
+
fmt, bitdepth = "xisf", "32-bit floating point"
|
|
1228
|
+
else:
|
|
1229
|
+
fmt, bitdepth = "auto", None
|
|
1230
|
+
|
|
1231
|
+
save_image(out, dst, fmt, bitdepth, hdr, mono)
|
|
1232
|
+
QMessageBox.information(self, "Success", f"Processed image saved to:\n{dst}")
|
|
1233
|
+
|
|
1234
|
+
except Exception as e:
|
|
1235
|
+
QMessageBox.critical(self, "Error", f"Failed to save result:\n{e}")
|
|
1505
1236
|
|
|
1506
1237
|
def _collect_open_views(self):
|
|
1507
1238
|
"""
|
|
@@ -1548,67 +1279,51 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1548
1279
|
return "image"
|
|
1549
1280
|
|
|
1550
1281
|
|
|
1551
|
-
# ---------- Batch ----------
|
|
1552
1282
|
def _batch_process(self):
|
|
1553
1283
|
if not self.input_folder or not self.output_folder:
|
|
1554
1284
|
QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
|
|
1555
1285
|
return
|
|
1556
|
-
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1557
|
-
if not os.path.exists(exe):
|
|
1558
|
-
QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
|
|
1559
|
-
return
|
|
1560
1286
|
|
|
1561
|
-
|
|
1562
|
-
|
|
1287
|
+
self._run_engine_thread(monitor=False, title="Satellite – Batch processing")
|
|
1288
|
+
|
|
1563
1289
|
|
|
1564
|
-
# ---------- Live monitor ----------
|
|
1565
1290
|
def _live_monitor(self):
|
|
1566
1291
|
if not self.input_folder or not self.output_folder:
|
|
1567
1292
|
QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
|
|
1568
1293
|
return
|
|
1569
|
-
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1570
|
-
if not os.path.exists(exe):
|
|
1571
|
-
QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
|
|
1572
|
-
return
|
|
1573
1294
|
|
|
1574
|
-
cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=False, monitor=True)
|
|
1575
1295
|
self.sld_sens.setEnabled(False)
|
|
1576
|
-
self.
|
|
1296
|
+
self._run_engine_thread(monitor=True, title="Satellite – Live monitoring",
|
|
1297
|
+
on_finish=lambda: self.sld_sens.setEnabled(True))
|
|
1298
|
+
|
|
1299
|
+
def _run_engine_thread(self, *, monitor: bool, title: str, on_finish=None):
|
|
1300
|
+
use_gpu = (self.cmb_gpu.currentText() == "Yes")
|
|
1301
|
+
mode = self.cmb_mode.currentText().lower() # "full" / "luminance"
|
|
1302
|
+
clip_trail = bool(self.chk_clip.isChecked())
|
|
1303
|
+
sensitivity = float(self.sensitivity)
|
|
1304
|
+
skip_save = bool(self.chk_skip.isChecked())
|
|
1577
1305
|
|
|
1578
|
-
# ---------- Command / run ----------
|
|
1579
|
-
def _build_cmd(self, exe_path: str, in_dir: str, out_dir: str, *, batch: bool, monitor: bool):
|
|
1580
|
-
cmd = [
|
|
1581
|
-
exe_path,
|
|
1582
|
-
"--input", in_dir,
|
|
1583
|
-
"--output", out_dir,
|
|
1584
|
-
"--mode", self.cmb_mode.currentText().lower(),
|
|
1585
|
-
]
|
|
1586
|
-
if self.cmb_gpu.currentText() == "Yes":
|
|
1587
|
-
cmd.append("--use-gpu")
|
|
1588
|
-
if self.chk_clip.isChecked():
|
|
1589
|
-
cmd.append("--clip-trail")
|
|
1590
|
-
else:
|
|
1591
|
-
cmd.append("--no-clip-trail")
|
|
1592
|
-
if self.chk_skip.isChecked():
|
|
1593
|
-
cmd.append("--skip-save")
|
|
1594
|
-
if batch:
|
|
1595
|
-
cmd.append("--batch")
|
|
1596
|
-
if monitor:
|
|
1597
|
-
cmd.append("--monitor")
|
|
1598
|
-
cmd += ["--sensitivity", f"{self.sensitivity}"]
|
|
1599
|
-
return cmd
|
|
1600
|
-
|
|
1601
|
-
def _run_threaded(self, cmd, title="Processing…", on_finish=None):
|
|
1602
|
-
# Wait dialog + threaded subprocess (mirrors SASv2 SatelliteProcessingThread)
|
|
1603
1306
|
self._wait = WaitDialog(title, self)
|
|
1604
1307
|
self._wait.show()
|
|
1605
1308
|
|
|
1606
|
-
self._sat_thread =
|
|
1309
|
+
self._sat_thread = SatelliteEngineThread(
|
|
1310
|
+
input_dir=self.input_folder,
|
|
1311
|
+
output_dir=self.output_folder,
|
|
1312
|
+
use_gpu=use_gpu,
|
|
1313
|
+
mode=mode,
|
|
1314
|
+
clip_trail=clip_trail,
|
|
1315
|
+
sensitivity=sensitivity,
|
|
1316
|
+
skip_save=skip_save,
|
|
1317
|
+
monitor=monitor,
|
|
1318
|
+
)
|
|
1607
1319
|
self._sat_thread.log_signal.connect(self._wait.append_output)
|
|
1608
1320
|
self._sat_thread.finished_signal.connect(lambda: self._on_thread_finished(on_finish))
|
|
1609
1321
|
self._wait.cancelled.connect(self._cancel_sat_thread)
|
|
1610
1322
|
self._sat_thread.start()
|
|
1611
1323
|
|
|
1324
|
+
|
|
1325
|
+
# ---------- Command / run ----------
|
|
1326
|
+
|
|
1612
1327
|
def _cancel_sat_thread(self):
|
|
1613
1328
|
if self._sat_thread:
|
|
1614
1329
|
self._sat_thread.cancel()
|
|
@@ -1625,16 +1340,6 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1625
1340
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1626
1341
|
QMessageBox.information(self, "Done", "Processing finished.")
|
|
1627
1342
|
|
|
1628
|
-
def _run_satellite(self, *, input_dir: str, output_dir: str, live: bool):
|
|
1629
|
-
if not self.cosmic_clarity_folder:
|
|
1630
|
-
raise RuntimeError("Cosmic Clarity folder not set. Choose it in Preferences or with the button below.")
|
|
1631
|
-
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1632
|
-
if not os.path.exists(exe):
|
|
1633
|
-
raise FileNotFoundError(f"Executable not found: {exe}")
|
|
1634
|
-
|
|
1635
|
-
cmd = self._build_cmd(exe, input_dir, output_dir, batch=not live, monitor=live)
|
|
1636
|
-
print("Running command:", " ".join(cmd))
|
|
1637
|
-
subprocess.run(cmd, check=True)
|
|
1638
1343
|
|
|
1639
1344
|
# ---------- Utils ----------
|
|
1640
1345
|
@staticmethod
|
|
@@ -1644,57 +1349,130 @@ class CosmicClaritySatelliteDialogPro(QDialog):
|
|
|
1644
1349
|
os.makedirs(temp_folder, exist_ok=True)
|
|
1645
1350
|
return temp_folder
|
|
1646
1351
|
|
|
1647
|
-
|
|
1648
|
-
class SatelliteProcessingThread(QThread):
|
|
1352
|
+
class SatelliteEngineThread(QThread):
|
|
1649
1353
|
log_signal = pyqtSignal(str)
|
|
1650
1354
|
finished_signal = pyqtSignal()
|
|
1651
|
-
|
|
1355
|
+
progress_signal = pyqtSignal(int, int) # done, total
|
|
1356
|
+
|
|
1357
|
+
def __init__(self, *, input_dir: str, output_dir: str,
|
|
1358
|
+
use_gpu: bool, mode: str, clip_trail: bool,
|
|
1359
|
+
sensitivity: float, skip_save: bool, monitor: bool,
|
|
1360
|
+
poll_seconds: float = 1.0):
|
|
1652
1361
|
super().__init__()
|
|
1653
|
-
self.
|
|
1654
|
-
self.
|
|
1362
|
+
self.input_dir = input_dir
|
|
1363
|
+
self.output_dir = output_dir
|
|
1364
|
+
self.use_gpu = use_gpu
|
|
1365
|
+
self.mode = mode
|
|
1366
|
+
self.clip_trail = clip_trail
|
|
1367
|
+
self.sensitivity = sensitivity
|
|
1368
|
+
self.skip_save = skip_save
|
|
1369
|
+
self.monitor = monitor
|
|
1370
|
+
self.poll_seconds = poll_seconds
|
|
1371
|
+
|
|
1372
|
+
self._cancel = False
|
|
1373
|
+
self._seen = set()
|
|
1655
1374
|
|
|
1656
1375
|
def cancel(self):
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1376
|
+
self._cancel = True
|
|
1377
|
+
|
|
1378
|
+
def _iter_files(self):
|
|
1379
|
+
exts = ('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
|
|
1380
|
+
'.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef', '.jpg', '.jpeg')
|
|
1381
|
+
try:
|
|
1382
|
+
for fn in sorted(os.listdir(self.input_dir)):
|
|
1383
|
+
if fn.lower().endswith(exts):
|
|
1384
|
+
yield fn
|
|
1385
|
+
except Exception:
|
|
1386
|
+
return
|
|
1662
1387
|
|
|
1663
1388
|
def run(self):
|
|
1664
1389
|
try:
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
self.command,
|
|
1668
|
-
stdout=subprocess.PIPE,
|
|
1669
|
-
stderr=subprocess.STDOUT,
|
|
1670
|
-
universal_newlines=True,
|
|
1671
|
-
text=True
|
|
1390
|
+
from setiastro.saspro.cosmicclarity_engines.satellite_engine import (
|
|
1391
|
+
get_satellite_models, satellite_remove_image
|
|
1672
1392
|
)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
#
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1393
|
+
|
|
1394
|
+
self.log_signal.emit("Loading Satellite models...")
|
|
1395
|
+
models = get_satellite_models(use_gpu=self.use_gpu, status_cb=lambda s: None)
|
|
1396
|
+
self.log_signal.emit("Models loaded.")
|
|
1397
|
+
|
|
1398
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
1399
|
+
|
|
1400
|
+
def process_one(fp_in: str, fp_out: str):
|
|
1401
|
+
# NOTE: assumes load_image/save_image exist in your module scope
|
|
1402
|
+
img, hdr, bd, mono = load_image(fp_in)
|
|
1403
|
+
if img is None:
|
|
1404
|
+
self.log_signal.emit(f"Failed to load: {os.path.basename(fp_in)}")
|
|
1405
|
+
return
|
|
1406
|
+
|
|
1407
|
+
img = np.asarray(img, dtype=np.float32)
|
|
1408
|
+
img = np.clip(img, 0.0, 1.0)
|
|
1409
|
+
|
|
1410
|
+
out, detected = satellite_remove_image(
|
|
1411
|
+
img,
|
|
1412
|
+
models=models,
|
|
1413
|
+
mode=self.mode,
|
|
1414
|
+
clip_trail=self.clip_trail,
|
|
1415
|
+
sensitivity=float(self.sensitivity),
|
|
1416
|
+
progress_cb=None, # keep this simple; we emit per-file progress instead
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
if self.skip_save and (not detected):
|
|
1420
|
+
self.log_signal.emit(f"Skip (no trail): {os.path.basename(fp_in)}")
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
out = np.asarray(out, dtype=np.float32, order="C")
|
|
1424
|
+
out = np.clip(out, 0.0, 1.0)
|
|
1425
|
+
|
|
1426
|
+
# Choose save behavior: keep original extension/name
|
|
1427
|
+
# (You can change naming here if you want.)
|
|
1428
|
+
save_image(out, fp_out, "auto", None, hdr, mono)
|
|
1429
|
+
self.log_signal.emit(f"Saved: {os.path.basename(fp_out)}")
|
|
1430
|
+
|
|
1431
|
+
# -------- batch (single pass) OR monitor (loop) --------
|
|
1432
|
+
while not self._cancel:
|
|
1433
|
+
files = list(self._iter_files())
|
|
1434
|
+
|
|
1435
|
+
# monitor: only process new files
|
|
1436
|
+
todo = [fn for fn in files if fn not in self._seen]
|
|
1437
|
+
total = len(todo)
|
|
1438
|
+
done = 0
|
|
1439
|
+
|
|
1440
|
+
for fn in todo:
|
|
1441
|
+
if self._cancel:
|
|
1442
|
+
break
|
|
1443
|
+
|
|
1444
|
+
self._seen.add(fn)
|
|
1445
|
+
fp_in = os.path.join(self.input_dir, fn)
|
|
1446
|
+
fp_out = os.path.join(self.output_dir, fn)
|
|
1447
|
+
|
|
1448
|
+
# if output already exists, treat as seen
|
|
1449
|
+
if os.path.exists(fp_out):
|
|
1450
|
+
self.log_signal.emit(f"Exists, skipping: {fn}")
|
|
1451
|
+
done += 1
|
|
1452
|
+
self.progress_signal.emit(done, total)
|
|
1453
|
+
continue
|
|
1454
|
+
|
|
1455
|
+
self.log_signal.emit(f"Processing: {fn}")
|
|
1456
|
+
try:
|
|
1457
|
+
process_one(fp_in, fp_out)
|
|
1458
|
+
except Exception as e:
|
|
1459
|
+
self.log_signal.emit(f"Error {fn}: {e}")
|
|
1460
|
+
|
|
1461
|
+
done += 1
|
|
1462
|
+
self.progress_signal.emit(done, total)
|
|
1463
|
+
|
|
1464
|
+
if not self.monitor:
|
|
1465
|
+
break
|
|
1466
|
+
|
|
1467
|
+
# monitor mode: sleep/poll
|
|
1468
|
+
self.msleep(int(max(50, self.poll_seconds * 1000)))
|
|
1469
|
+
|
|
1470
|
+
if self._cancel:
|
|
1471
|
+
self.log_signal.emit("Cancelled.")
|
|
1693
1472
|
else:
|
|
1694
|
-
self.log_signal.emit(
|
|
1473
|
+
self.log_signal.emit("Done.")
|
|
1695
1474
|
|
|
1696
1475
|
except Exception as e:
|
|
1697
1476
|
self.log_signal.emit(f"Unexpected error: {e}")
|
|
1698
1477
|
finally:
|
|
1699
|
-
self.process = None
|
|
1700
1478
|
self.finished_signal.emit()
|