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.

Files changed (27) hide show
  1. setiastro/saspro/_generated/build_info.py +2 -2
  2. setiastro/saspro/accel_installer.py +21 -8
  3. setiastro/saspro/accel_workers.py +11 -12
  4. setiastro/saspro/comet_stacking.py +113 -85
  5. setiastro/saspro/cosmicclarity.py +604 -826
  6. setiastro/saspro/cosmicclarity_engines/benchmark_engine.py +732 -0
  7. setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +576 -0
  8. setiastro/saspro/cosmicclarity_engines/denoise_engine.py +567 -0
  9. setiastro/saspro/cosmicclarity_engines/satellite_engine.py +620 -0
  10. setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +587 -0
  11. setiastro/saspro/cosmicclarity_engines/superres_engine.py +412 -0
  12. setiastro/saspro/gui/main_window.py +14 -0
  13. setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
  14. setiastro/saspro/model_manager.py +324 -0
  15. setiastro/saspro/model_workers.py +102 -0
  16. setiastro/saspro/ops/benchmark.py +320 -0
  17. setiastro/saspro/ops/settings.py +407 -10
  18. setiastro/saspro/remove_stars.py +424 -442
  19. setiastro/saspro/resources.py +73 -10
  20. setiastro/saspro/runtime_torch.py +107 -22
  21. setiastro/saspro/signature_insert.py +14 -3
  22. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/METADATA +2 -1
  23. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/RECORD +27 -18
  24. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/WHEEL +0 -0
  25. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/entry_points.txt +0 -0
  26. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/LICENSE +0 -0
  27. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/license.txt +0 -0
@@ -1,4 +1,4 @@
1
- # pro/cosmicclarity.py
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
- # --- replace your _atomic_fsync_replace with this ---
33
- def _atomic_fsync_replace(src_bytes_writer, final_path: str):
39
+ def _cc_models_installed() -> tuple[bool, str]:
34
40
  """
35
- Write to a unique temp file next to final_path, fsync it, then atomically
36
- replace final_path. src_bytes_writer(tmp_path) must CREATE tmp_path.
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
- d = os.path.dirname(final_path) or "."
39
- os.makedirs(d, exist_ok=True)
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
- # Use same extension so writers (like your save_image) don't append a new one.
42
- ext = os.path.splitext(final_path)[1] or ".tmp"
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
- try:
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
- # Ensure written bytes are on disk
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
- # Promote atomically
59
- os.replace(tmp_path, final_path)
59
+ if not os.path.exists(sentinel):
60
+ return False, f"Missing sentinel: {os.path.basename(sentinel)}"
60
61
 
61
- # POSIX-only: best-effort directory entry fsync (Windows doesn't support this)
62
- if os.name != "nt":
63
- try:
64
- dirfd = os.open(d, os.O_DIRECTORY)
65
- try: os.fsync(dirfd)
66
- finally: os.close(dirfd)
67
- except Exception:
68
- pass
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
- class WaitForFileWorker(QThread):
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._glob = glob_pat
244
- self._timeout = int(timeout_sec)
245
- self._poll_ms = int(poll_ms)
246
- self._stable_polls = int(stable_polls)
247
- self._stable_timeout = int(stable_timeout_sec)
248
- self._running = True
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
- def _is_stable_and_readable(self, path: str) -> bool:
155
+ # ---- progress adapter ----
156
+ def _mk_progress_cb(self, stage_label: str, stage_weight: float, base_pct: float, total_stages: int):
267
157
  """
268
- Consider stable when size+mtime unchanged for N polls in a row AND file is readable.
269
- Handles slow writers + Windows "file still locked" issues.
158
+ stage_weight: fraction of total progress reserved for this stage (0..1)
159
+ base_pct: starting % for this stage
270
160
  """
271
- stable = 0
272
- last = None
273
-
274
- t0 = time.monotonic()
275
- while self._running and (time.monotonic() - t0) < self._stable_timeout:
276
- try:
277
- st = os.stat(path)
278
- cur = (st.st_size, st.st_mtime)
279
- if st.st_size <= 0:
280
- stable = 0
281
- last = cur
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
- t_start = time.monotonic()
314
- seen_first_candidate_at = None
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
- while self._running and (time.monotonic() - t_start) < self._timeout:
317
- matches = glob.glob(self._glob)
318
- cand = self._best_candidate(matches)
196
+ n = max(1, len(stages))
197
+ stage_weight = 1.0 / float(n)
319
198
 
320
- if cand:
321
- if seen_first_candidate_at is None:
322
- seen_first_candidate_at = time.monotonic()
199
+ out = img
200
+ self.progress.emit(0)
323
201
 
324
- if self._is_stable_and_readable(cand):
325
- self.fileFound.emit(cand)
202
+ for si, st in enumerate(stages):
203
+ if self._cancel:
204
+ self.error.emit("Cancelled.")
326
205
  return
327
206
 
328
- # If we've been seeing candidates for a while but none stabilize,
329
- # keep looping until global timeout. (This is common on slow disks.)
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
- time.sleep(self._poll_ms / 1000.0)
262
+ else:
263
+ raise RuntimeError(f"Unknown stage: {st}")
332
264
 
333
- if not self._running:
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 = _get_cosmic_root_from_settings()
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
- grid.addWidget(QLabel(self.tr("Use GPU:")), 1, 0)
402
- self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems([self.tr("Yes"), self.tr("No")])
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
- self._wait_thread = None
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
- self.cmb_gpu.setVisible(not show_sr)
550
- self.parentWidget()
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
- if not self._validate_root():
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
- # Never let replay bookkeeping kill the effect itself
590
- pass
591
-
592
- _ensure_dirs(self.cosmic_root)
593
- _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=False)
594
-
595
-
596
- # Determine queue of operations
597
- mode_idx = self.cmb_mode.currentIndex()
598
- if mode_idx == 3:
599
- # Super-res path
600
- self._run_superres(); return
601
- elif mode_idx == 0:
602
- ops = [("sharpen", "_sharpened")]
603
- elif mode_idx == 1:
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
- ops = [("sharpen", "_sharpened"), ("denoise", "_denoised")]
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
- # Run queue
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
- def _run_next(self):
643
- if not self._op_queue:
644
- # If we ever get here without more steps, we’re done.
645
- self.accept()
646
- return
647
- mode, suffix = self._op_queue.pop(0)
648
- exe_name = _platform_exe_names(mode)
649
- exe_path = os.path.join(self.cosmic_root, exe_name)
650
- if not os.path.exists(exe_path):
651
- QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
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
- self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
709
- self._wait_thread.fileFound.connect(lambda path, mode=mode: self._on_output_file(path, mode))
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
- def _read_proc_output_main(self):
715
- self._read_proc_output(self._proc, which="main")
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
- def _read_proc_output(self, proc: QProcess, which="main"):
718
- out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
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
- for line in out.splitlines():
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
- # Direct move/copy instead of decode+encode
766
- if os.path.abspath(out_path) != os.path.abspath(next_in):
767
- # Windows cannot atomic replace if target exists via os.rename usually,
768
- # but shutil.move is generally robust.
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
- except Exception as e:
787
- QMessageBox.critical(self, "Cosmic Clarity", f"Failed to stage next step:\n{e}")
788
- self._op_queue.clear()
789
- return
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
- # Trigger next step immediately
792
- QTimer.singleShot(50, self._run_next)
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
- dest = img.astype(np.float32, copy=False)
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
- # Apply to document
807
- step_title = f"Cosmic Clarity – {mode.title()}"
808
- create_new = (self.cmb_target.currentIndex() == 1)
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(dest, step_title)
643
+ ok = self._spawn_new_doc_from_numpy(out_arr, step_title)
812
644
  if not ok:
813
- self._apply_to_active(dest, step_title)
645
+ self._apply_to_active(out_arr, step_title)
814
646
  else:
815
- self._apply_to_active(dest, step_title)
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
- def _read_superres_output_main(self):
1037
- self._read_superres_output(self._proc)
1038
-
1039
- def _read_superres_output(self, proc: QProcess):
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
- try:
1097
- img, hdr, bd, mono = load_image(out_path)
1098
- if img is None:
1099
- raise RuntimeError("Unable to load output image.")
1100
- except Exception as e:
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
- dest = img.astype(np.float32, copy=False)
1105
- step_title = "Cosmic Clarity Super Resolution"
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 getattr(self, "_current_input", None) and os.path.exists(self._current_input):
1118
- os.remove(self._current_input)
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.accept()
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
- self.cosmic_clarity_folder = self.settings.value("paths/cosmic_clarity", "", type=str) or ""
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
- # ---------- Settings / root ----------
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
- # Gather possible open views
1366
- views = self._collect_open_views()
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 = mb.addButton("Open View", QMessageBox.ButtonRole.AcceptRole)
1375
- btn_file = mb.addButton("File on Disk", QMessageBox.ButtonRole.AcceptRole)
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 None or mb.clickedButton() == mb.buttons()[-1]: # Cancel
1068
+ elif mb.clickedButton() is btn_file:
1069
+ use_view = False
1070
+ else:
1381
1071
  return
1382
1072
 
1383
- # --- Branch 1: Process an OPEN VIEW ---
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
- # If multiple views, ask which one
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
- idx = titles.index(sel)
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
- # Stage image from the chosen view
1400
- temp_in = self._create_temp_folder()
1401
- temp_out = self._create_temp_folder()
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
- # 32-bit float TIFF like SASv2
1406
- img = np.clip(np.asarray(chosen_doc.image, dtype=np.float32), 0.0, 1.0)
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 stage view for processing:\n{e}")
1109
+ QMessageBox.critical(self, "Error", f"Failed to read image from view:\n{e}")
1415
1110
  return
1416
1111
 
1417
- # Run satellite
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
- self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
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"Error processing image:\n{e}")
1127
+ QMessageBox.critical(self, "Error", f"Failed to load image:\n{e}")
1422
1128
  return
1423
1129
 
1424
- # Pick up result and apply back to the view
1425
- out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1426
- if not out:
1427
- # Likely --skip-save and no trail, or failure
1428
- QMessageBox.information(self, "Satellite Removal", "No output produced (possibly no satellite trail detected).")
1429
- else:
1430
- out_path = out[0]
1431
- try:
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
- # Clean up temp dirs
1455
- try:
1456
- shutil.rmtree(temp_in, ignore_errors=True)
1457
- shutil.rmtree(temp_out, ignore_errors=True)
1458
- except Exception:
1459
- pass
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
- return # done
1150
+ cancelled = {"flag": False}
1462
1151
 
1463
- # --- Branch 2: Process a FILE on disk ---
1464
- file_path, _ = QFileDialog.getOpenFileName(
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
- temp_in = self._create_temp_folder()
1473
- temp_out = self._create_temp_folder()
1474
- try:
1475
- shutil.copy(file_path, temp_in)
1476
- except Exception as e:
1477
- QMessageBox.critical(self, "Error", f"Failed to stage input:\n{e}")
1478
- return
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
- self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
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
- # Move output back next to original
1487
- out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1488
- if out:
1489
- dst = os.path.join(os.path.dirname(file_path), os.path.basename(out[0]))
1490
- try:
1491
- shutil.move(out[0], dst)
1492
- except Exception as e:
1493
- QMessageBox.critical(self, "Error", f"Failed to save result:\n{e}")
1494
- return
1495
- QMessageBox.information(self, "Success", f"Processed image saved to:\n{dst}")
1496
- else:
1497
- QMessageBox.warning(self, "Warning", "No output file found.")
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
- # Cleanup
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
- shutil.rmtree(temp_in, ignore_errors=True)
1502
- shutil.rmtree(temp_out, ignore_errors=True)
1503
- except Exception:
1504
- pass
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
- cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=True, monitor=False)
1562
- self._run_threaded(cmd, title="Satellite – Batch processing")
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._run_threaded(cmd, title="Satellite – Live monitoring", on_finish=lambda: self.sld_sens.setEnabled(True))
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 = SatelliteProcessingThread(cmd)
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
- def __init__(self, command):
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.command = command
1654
- self.process = None
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
- if self.process:
1658
- try:
1659
- self.process.kill()
1660
- except Exception:
1661
- pass
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
- self.log_signal.emit("Running command: " + " ".join(self.command))
1666
- self.process = subprocess.Popen(
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
- # Read output to prevent deadlock
1674
- for line in iter(self.process.stdout.readline, ""):
1675
- if not line: break
1676
- # Optional: emit log signal for verbose output?
1677
- # The original code didn't log stdout, but blocked.
1678
- # Let's just log it if we want, or consume it.
1679
- # The prompt says "I think starnet stops but the window doesnt close"
1680
- # so maybe verbose logging isn't the priority, but consuming stdout is mandatory.
1681
- # However, the original code used subprocess.run which captures output if specified,
1682
- # but it didn't specify capture_output=True or stdout/stderr args in the snippet I saw?
1683
- # Wait, let's check the snippet I saw earlier for SatelliteProcessingThread.
1684
- pass
1685
-
1686
- # Close stdout to ensure cleanup
1687
- if self.process.stdout:
1688
- self.process.stdout.close()
1689
-
1690
- rc = self.process.wait()
1691
- if rc == 0:
1692
- self.log_signal.emit("Processing complete.")
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(f"Processing failed with code {rc}")
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()