setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -7,13 +7,37 @@ import numpy as np
7
7
  import sys
8
8
  import platform # add
9
9
  import time
10
+ import subprocess
10
11
 
11
12
  IS_APPLE_ARM = (sys.platform == "darwin" and platform.machine() == "arm64")
12
13
 
14
+ def _has_nvidia_gpu() -> bool:
15
+ """Check if system has an NVIDIA GPU (Linux/Windows)."""
16
+ try:
17
+ if platform.system() == "Linux":
18
+ r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, timeout=2)
19
+ return "GPU" in (r.stdout.decode("utf-8", errors="ignore") or "")
20
+ elif platform.system() == "Windows":
21
+ try:
22
+ ps = subprocess.run(
23
+ ["powershell", "-NoProfile", "-Command",
24
+ "(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join ';'"],
25
+ capture_output=True, timeout=2
26
+ )
27
+ out = (ps.stdout.decode("utf-8", errors="ignore") or "").lower()
28
+ return "nvidia" in out
29
+ except Exception:
30
+ w = subprocess.run(["wmic", "path", "win32_VideoController", "get", "name"],
31
+ capture_output=True, timeout=2)
32
+ return "nvidia" in (w.stdout.decode("utf-8", errors="ignore") or "").lower()
33
+ except Exception:
34
+ pass
35
+ return False
36
+
13
37
  from PyQt6.QtCore import Qt, QThread, pyqtSignal, QStandardPaths, QSettings
14
38
  from PyQt6.QtWidgets import (
15
39
  QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog,
16
- QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox
40
+ QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox, QLineEdit, QApplication
17
41
  )
18
42
  from PyQt6.QtGui import QIcon
19
43
  from setiastro.saspro.config import Config
@@ -145,10 +169,13 @@ def _restore_output(arr: np.ndarray, channels_last: bool, was_uint16: bool, H: i
145
169
  arr = arr[0] # (H,W)
146
170
  return arr
147
171
 
148
- def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progress_cb=None) -> np.ndarray:
172
+ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64,
173
+ progress_cb=None, cancel_cb=None) -> np.ndarray:
149
174
  """
150
175
  session: onnxruntime.InferenceSession
151
176
  img: mono (H,W) or RGB (H,W,3) numpy array
177
+
178
+ cancel_cb: callable -> bool, return True to cancel
152
179
  """
153
180
  arr, channels_last, was_uint16 = _prepare_input(img) # (C,H,W)
154
181
  arr, H0, W0 = _pad_C_HW(arr, patch_size)
@@ -168,11 +195,15 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
168
195
  for c in range(C):
169
196
  for i in hs:
170
197
  for j in ws:
171
- patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1, P, P)
198
+ if cancel_cb and cancel_cb():
199
+ raise RuntimeError("Canceled")
200
+
201
+ patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1,P,P)
172
202
  inp = np.ascontiguousarray(patch[np.newaxis, ...], dtype=np.float32) # (1,1,P,P)
173
203
 
174
204
  out_patch = session.run(None, {inp_name: inp})[0] # (1,1,P,P)
175
205
  out_patch = np.squeeze(out_patch, axis=0) # (1,P,P)
206
+
176
207
  out[c:c+1, i:i+patch_size, j:j+patch_size] += out_patch * win
177
208
  wgt[c:c+1, i:i+patch_size, j:j+patch_size] += win
178
209
 
@@ -184,7 +215,6 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
184
215
  arr = out / wgt
185
216
  return _restore_output(arr, channels_last, was_uint16, H0, W0)
186
217
 
187
-
188
218
  # ---------- providers ----------
189
219
  def pick_providers(auto_gpu=True) -> list[str]:
190
220
  """
@@ -248,9 +278,11 @@ def _preserve_border(dst: np.ndarray, src: np.ndarray, px: int = 10) -> np.ndarr
248
278
 
249
279
  # ---------- worker ----------
250
280
  class _ONNXWorker(QThread):
251
- progressed = pyqtSignal(int) # 0..100
252
- failed = pyqtSignal(str)
253
- finished_ok= pyqtSignal(np.ndarray)
281
+ progressed = pyqtSignal(int) # 0..100
282
+ failed = pyqtSignal(str)
283
+ finished_ok = pyqtSignal(np.ndarray)
284
+ canceled = pyqtSignal()
285
+ log_message = pyqtSignal(str) # for console logging
254
286
 
255
287
  def __init__(self, model_path: str, image: np.ndarray, patch: int, overlap: int, providers: list[str]):
256
288
  super().__init__()
@@ -260,33 +292,115 @@ class _ONNXWorker(QThread):
260
292
  self.overlap = overlap
261
293
  self.providers = providers
262
294
  self.used_provider = None
295
+ self._cancel = False # cooperative flag
296
+
297
+ def cancel(self):
298
+ # Safe to call from UI thread
299
+ self._cancel = True
300
+ self.requestInterruption()
301
+
302
+ def _is_canceled(self) -> bool:
303
+ return self._cancel or self.isInterruptionRequested()
263
304
 
264
305
  def run(self):
265
306
  if ort is None:
266
307
  self.failed.emit("onnxruntime is not installed.")
267
308
  return
309
+
310
+ # If canceled before start, exit cleanly
311
+ if self._is_canceled():
312
+ self.canceled.emit()
313
+ return
314
+
315
+ # Log available providers for debugging
316
+ avail_providers = ort.get_available_providers()
317
+ gpu_providers = [p for p in self.providers if p != "CPUExecutionProvider"]
318
+ has_nvidia = _has_nvidia_gpu()
319
+
320
+ self.log_message.emit(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
321
+ self.log_message.emit(f"🔍 Attempting providers: {', '.join(self.providers)}")
322
+ print(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
323
+ print(f"🔍 Attempting providers: {', '.join(self.providers)}")
324
+
325
+ # Check if NVIDIA GPU is present but CUDA provider is missing
326
+ if has_nvidia and "CUDAExecutionProvider" not in avail_providers:
327
+ msg = ("⚠️ GPU NVIDIA détecté mais CUDAExecutionProvider n'est pas disponible.\n"
328
+ " Vous devez installer 'onnxruntime-gpu' au lieu de 'onnxruntime'.\n"
329
+ " Commande: pip uninstall onnxruntime && pip install onnxruntime-gpu")
330
+ self.log_message.emit(msg)
331
+ print(msg)
332
+
268
333
  try:
269
334
  sess = ort.InferenceSession(self.model_path, providers=self.providers)
270
335
  self.used_provider = (sess.get_providers()[0] if sess.get_providers() else None)
271
- except Exception:
336
+ # Log successful GPU usage
337
+ if self.used_provider != "CPUExecutionProvider" and gpu_providers:
338
+ msg = f"✅ Aberration AI: Using GPU provider {self.used_provider}"
339
+ self.log_message.emit(msg)
340
+ print(msg)
341
+ elif has_nvidia and self.used_provider == "CPUExecutionProvider":
342
+ msg = ("⚠️ GPU NVIDIA détecté mais utilisation du CPU.\n"
343
+ " Installez 'onnxruntime-gpu' pour utiliser le GPU.")
344
+ self.log_message.emit(msg)
345
+ print(msg)
346
+ except Exception as e:
347
+ # Log the actual error for debugging
348
+ error_msg = str(e)
349
+ msg = f"⚠️ Aberration AI: GPU provider failed: {error_msg}"
350
+ self.log_message.emit(msg)
351
+ print(msg)
352
+ self.log_message.emit(f"Available providers: {', '.join(avail_providers)}")
353
+ print(f"Available providers: {', '.join(avail_providers)}")
354
+ self.log_message.emit(f"Attempted providers: {', '.join(self.providers)}")
355
+ print(f"Attempted providers: {', '.join(self.providers)}")
356
+
357
+ # Check if onnxruntime-gpu is installed (CUDA provider should be available if it is)
358
+ if "CUDAExecutionProvider" in self.providers and "CUDAExecutionProvider" not in avail_providers:
359
+ if has_nvidia:
360
+ msg = ("❌ CUDAExecutionProvider non disponible alors qu'un GPU NVIDIA est présent.\n"
361
+ " Installez 'onnxruntime-gpu': pip uninstall onnxruntime && pip install onnxruntime-gpu")
362
+ else:
363
+ msg = "⚠️ CUDAExecutionProvider not available. You may need to install onnxruntime-gpu instead of onnxruntime."
364
+ self.log_message.emit(msg)
365
+ print(msg)
366
+
272
367
  # fallback CPU if GPU fails
273
368
  try:
274
369
  sess = ort.InferenceSession(self.model_path, providers=["CPUExecutionProvider"])
275
- self.used_provider = "CPUExecutionProvider" # NEW
370
+ self.used_provider = "CPUExecutionProvider"
371
+ msg = f"⚠️ Aberration AI: Falling back to CPU (GPU initialization failed: {error_msg})"
372
+ self.log_message.emit(msg)
373
+ print(msg)
276
374
  except Exception as e2:
277
- self.failed.emit(f"Failed to init ONNX session:\n{e2}")
375
+ self.failed.emit(f"Failed to init ONNX session:\nGPU error: {error_msg}\nCPU error: {e2}")
278
376
  return
279
377
 
280
378
  def cb(frac):
281
379
  self.progressed.emit(int(frac * 100))
282
380
 
283
381
  try:
284
- out = run_onnx_tiled(sess, self.image, self.patch, self.overlap, cb)
382
+ out = run_onnx_tiled(
383
+ sess,
384
+ self.image,
385
+ self.patch,
386
+ self.overlap,
387
+ progress_cb=cb,
388
+ cancel_cb=self._is_canceled,
389
+ )
285
390
  except Exception as e:
286
- self.failed.emit(str(e)); return
391
+ # Normalize cancel
392
+ msg = str(e) or "Error"
393
+ if "Canceled" in msg:
394
+ self.canceled.emit()
395
+ else:
396
+ self.failed.emit(msg)
397
+ return
287
398
 
288
- self.finished_ok.emit(out)
399
+ if self._is_canceled():
400
+ self.canceled.emit()
401
+ return
289
402
 
403
+ self.finished_ok.emit(out)
290
404
 
291
405
  # ---------- dialog ----------
292
406
  class AberrationAIDialog(QDialog):
@@ -315,11 +429,29 @@ class AberrationAIDialog(QDialog):
315
429
  row.addWidget(QLabel(self.tr("Model:")))
316
430
  self.model_label = QLabel("—")
317
431
  self.model_label.setToolTip("")
318
- btn_browse = QPushButton(self.tr("Browse…")); btn_browse.clicked.connect(self._browse_model)
432
+ btn_browse = QPushButton(self.tr("Browse…")); btn_browse.clicked.connect(self._browse_active_model)
319
433
  row.addWidget(self.model_label, 1)
320
434
  row.addWidget(btn_browse)
321
435
  v.addLayout(row)
436
+ # Custom model row (NEW)
437
+ row_custom = QHBoxLayout()
438
+ self.chk_use_custom = QCheckBox(self.tr("Use custom model file"))
439
+ self.chk_use_custom.setChecked(False)
440
+ self.chk_use_custom.toggled.connect(self._on_use_custom_toggled)
441
+
442
+ self.le_custom_model = QLineEdit()
443
+ self.le_custom_model.setReadOnly(True)
444
+ self.le_custom_model.setPlaceholderText(self.tr("No custom model selected"))
445
+ self.le_custom_model.setToolTip("")
446
+
447
+ btn_custom_clear = QPushButton(self.tr("Clear"))
448
+ btn_custom_clear.clicked.connect(self._clear_custom_model)
449
+
450
+ row_custom.addWidget(self.chk_use_custom)
451
+ row_custom.addWidget(self.le_custom_model, 1)
322
452
 
453
+ row_custom.addWidget(btn_custom_clear)
454
+ v.addLayout(row_custom)
323
455
  # Providers row
324
456
  row2 = QHBoxLayout()
325
457
  self.chk_auto = QCheckBox(self.tr("Auto GPU (if available)"))
@@ -373,7 +505,9 @@ class AberrationAIDialog(QDialog):
373
505
  self._model_path = None
374
506
  self._refresh_providers()
375
507
  self._load_last_model_from_settings()
376
-
508
+ self._load_last_custom_model_from_settings()
509
+ use_custom = QSettings().value("AberrationAI/use_custom_model", False, type=bool)
510
+ self.chk_use_custom.setChecked(bool(use_custom))
377
511
  if IS_APPLE_ARM:
378
512
  self.chk_auto.setChecked(False)
379
513
  self.chk_auto.setEnabled(False)
@@ -395,11 +529,73 @@ class AberrationAIDialog(QDialog):
395
529
  if p and os.path.isfile(p):
396
530
  self._set_model_path(p)
397
531
 
398
- def _browse_model(self):
399
- start_dir = _app_model_dir()
532
+ def _browse_active_model(self):
533
+ """
534
+ Single Browse button.
535
+ - If user picks a file inside the app model folder -> treat as "downloaded" selection (use_custom_model=False)
536
+ - If user picks a file outside -> treat as "custom" (use_custom_model=True)
537
+ """
538
+ app_dir = os.path.abspath(_app_model_dir())
539
+
540
+ # Start in last-used folder if possible
541
+ last_custom = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
542
+ last_downloaded = QSettings().value("AberrationAI/model_path", type=str) or ""
543
+ start_dir = None
544
+ for candidate in (last_custom, last_downloaded):
545
+ if candidate and os.path.isfile(candidate):
546
+ d = os.path.dirname(candidate)
547
+ if os.path.isdir(d):
548
+ start_dir = d
549
+ break
550
+ if start_dir is None:
551
+ start_dir = app_dir
552
+
400
553
  p, _ = QFileDialog.getOpenFileName(self, "Select ONNX model", start_dir, "ONNX (*.onnx)")
401
- if p:
402
- self._set_model_path(p)
554
+ if not p:
555
+ return
556
+
557
+ p_abs = os.path.abspath(p)
558
+ # Determine if picked file is inside app model folder
559
+ in_app_dir = False
560
+ try:
561
+ in_app_dir = os.path.commonpath([app_dir, p_abs]) == app_dir
562
+ except Exception:
563
+ in_app_dir = p_abs.startswith(app_dir)
564
+
565
+ if in_app_dir:
566
+ # "Downloaded" selection
567
+ self._set_model_path(p_abs)
568
+ self._set_custom_model_path(None)
569
+ QSettings().setValue("AberrationAI/use_custom_model", False)
570
+ if hasattr(self, "chk_use_custom"):
571
+ self.chk_use_custom.setChecked(False)
572
+ else:
573
+ # "Custom" selection
574
+ self._set_custom_model_path(p_abs)
575
+ QSettings().setValue("AberrationAI/use_custom_model", True)
576
+ if hasattr(self, "chk_use_custom"):
577
+ self.chk_use_custom.setChecked(True)
578
+
579
+ # Keep visuals in sync
580
+ self._refresh_model_label()
581
+ self._refresh_custom_row_visibility()
582
+
583
+
584
+ def _refresh_model_label(self):
585
+ downloaded = QSettings().value("AberrationAI/model_path", type=str) or ""
586
+ custom = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
587
+ use_custom = QSettings().value("AberrationAI/use_custom_model", False, type=bool)
588
+
589
+ if use_custom and custom:
590
+ self.model_label.setText(f"Custom: {os.path.basename(custom)}")
591
+ self.model_label.setToolTip(custom)
592
+ elif downloaded:
593
+ self.model_label.setText(f"Downloaded: {os.path.basename(downloaded)}")
594
+ self.model_label.setToolTip(downloaded)
595
+ else:
596
+ self.model_label.setText("—")
597
+ self.model_label.setToolTip("")
598
+
403
599
 
404
600
  def _open_model_folder(self):
405
601
  d = _app_model_dir()
@@ -412,6 +608,108 @@ class AberrationAIDialog(QDialog):
412
608
  import subprocess; subprocess.Popen(["xdg-open", d])
413
609
  except Exception:
414
610
  webbrowser.open(f"file://{d}")
611
+ # ----- custom model helpers (NEW) -----
612
+ def _set_custom_model_path(self, p: str | None):
613
+ if p:
614
+ self.le_custom_model.setText(os.path.basename(p))
615
+ self.le_custom_model.setToolTip(p)
616
+ QSettings().setValue("AberrationAI/custom_model_path", p)
617
+ else:
618
+ self.le_custom_model.clear()
619
+ self.le_custom_model.setToolTip("")
620
+ QSettings().remove("AberrationAI/custom_model_path")
621
+
622
+ def _load_last_custom_model_from_settings(self):
623
+ p = QSettings().value("AberrationAI/custom_model_path", type=str)
624
+ if p:
625
+ if os.path.isfile(p):
626
+ self._set_custom_model_path(p)
627
+ else:
628
+ # Keep the broken path visible in tooltip for debugging
629
+ if hasattr(self, "le_custom_model"):
630
+ self.le_custom_model.setText(os.path.basename(p) + " (missing)")
631
+ self.le_custom_model.setToolTip(p)
632
+
633
+ # After both loads, sync labels/visibility
634
+ self._refresh_model_label()
635
+ self._refresh_custom_row_visibility()
636
+
637
+ def _refresh_custom_row_visibility(self):
638
+ """
639
+ If you keep the custom row in the UI, hide the path field unless custom is enabled.
640
+ """
641
+ if not hasattr(self, "le_custom_model"):
642
+ return
643
+ use_custom = QSettings().value("AberrationAI/use_custom_model", False, type=bool)
644
+ self.le_custom_model.setVisible(bool(use_custom))
645
+
646
+
647
+ def _refresh_model_label(self):
648
+ downloaded = QSettings().value("AberrationAI/model_path", type=str) or ""
649
+ custom = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
650
+ use_custom = QSettings().value("AberrationAI/use_custom_model", False, type=bool)
651
+
652
+ # Prefer custom only if enabled AND the file exists
653
+ if use_custom and custom:
654
+ if os.path.isfile(custom):
655
+ self.model_label.setText(f"Custom: {os.path.basename(custom)}")
656
+ self.model_label.setToolTip(custom)
657
+ return
658
+ else:
659
+ self.model_label.setText(f"Custom: {os.path.basename(custom)} (missing)")
660
+ self.model_label.setToolTip(custom)
661
+ return
662
+
663
+ # Otherwise show downloaded if valid
664
+ if downloaded and os.path.isfile(downloaded):
665
+ self.model_label.setText(f"Downloaded: {os.path.basename(downloaded)}")
666
+ self.model_label.setToolTip(downloaded)
667
+ else:
668
+ self.model_label.setText("—")
669
+ self.model_label.setToolTip("")
670
+
671
+
672
+ def _browse_custom_model(self):
673
+ # Start at last dir if possible, else app model dir
674
+ last = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
675
+ start_dir = os.path.dirname(last) if last and os.path.isdir(os.path.dirname(last)) else _app_model_dir()
676
+ p, _ = QFileDialog.getOpenFileName(self, "Select custom ONNX model", start_dir, "ONNX (*.onnx)")
677
+ if p:
678
+ self._set_custom_model_path(p)
679
+ QSettings().setValue("AberrationAI/use_custom_model", True)
680
+ if not self.chk_use_custom.isChecked():
681
+ self.chk_use_custom.setChecked(True)
682
+
683
+ def _clear_custom_model(self):
684
+ self._set_custom_model_path(None)
685
+ QSettings().setValue("AberrationAI/use_custom_model", False)
686
+ if hasattr(self, "chk_use_custom"):
687
+ self.chk_use_custom.setChecked(False)
688
+
689
+ self._refresh_model_label()
690
+ self._refresh_custom_row_visibility()
691
+
692
+
693
+ def _on_use_custom_toggled(self, on: bool):
694
+ QSettings().setValue("AberrationAI/use_custom_model", bool(on))
695
+
696
+ if on:
697
+ p = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
698
+ if not (p and os.path.isfile(p)):
699
+ # Don’t spawn another browse button path; use the ONE browse if they want
700
+ QMessageBox.information(
701
+ self,
702
+ self.tr("Custom model"),
703
+ self.tr("Custom model is enabled, but no custom file is selected.\n"
704
+ "Click Browse… to choose a model file.")
705
+ )
706
+ # Optional: auto-open the single browse:
707
+ # self._browse_active_model()
708
+ # return
709
+
710
+ self._refresh_model_label()
711
+ self._refresh_custom_row_visibility()
712
+
415
713
 
416
714
  # ----- provider UI -----
417
715
  def _log(self, msg: str): # NEW
@@ -477,8 +775,16 @@ class AberrationAIDialog(QDialog):
477
775
  def _on_download_ok(self, path: str):
478
776
  self.progress.setValue(100)
479
777
  self._set_model_path(path)
778
+
779
+ # Download becomes the active model unless custom is explicitly enabled
780
+ if not QSettings().value("AberrationAI/use_custom_model", False, type=bool):
781
+ self._set_custom_model_path(None)
782
+
480
783
  QMessageBox.information(self, "Model", f"Downloaded: {os.path.basename(path)}")
481
784
 
785
+ self._refresh_model_label()
786
+ self._refresh_custom_row_visibility()
787
+
482
788
  # ----- run -----
483
789
  def _run(self):
484
790
  if ort is None:
@@ -489,7 +795,22 @@ class AberrationAIDialog(QDialog):
489
795
  "Please try installing an earlier version (for example 1.19.x) and try again."
490
796
  )
491
797
  return
492
- if not self._model_path or not os.path.isfile(self._model_path):
798
+
799
+ # Choose model path (normal vs custom)
800
+ use_custom = QSettings().value("AberrationAI/use_custom_model", False, type=bool)
801
+ downloaded = QSettings().value("AberrationAI/model_path", type=str) or ""
802
+ custom = QSettings().value("AberrationAI/custom_model_path", type=str) or ""
803
+
804
+ model_path = custom if use_custom else downloaded
805
+ if self.chk_use_custom.isChecked():
806
+ cp = QSettings().value("AberrationAI/custom_model_path", type=str)
807
+ if cp and os.path.isfile(cp):
808
+ model_path = cp
809
+ else:
810
+ QMessageBox.warning(self, "Model", "Custom model is enabled but the file is missing. Please browse to a valid .onnx.")
811
+ return
812
+
813
+ if not model_path or not os.path.isfile(model_path):
493
814
  QMessageBox.warning(self, "Model", "Please select or download a valid .onnx model first.")
494
815
  return
495
816
 
@@ -516,7 +837,7 @@ class AberrationAIDialog(QDialog):
516
837
  providers = [sel] if sel else ["CPUExecutionProvider"]
517
838
 
518
839
  # --- make patch match the model's requirement (if fixed) ---
519
- req = _model_required_patch(self._model_path)
840
+ req = _model_required_patch(model_path)
520
841
  if req and req > 0:
521
842
  patch = req
522
843
  try:
@@ -537,26 +858,34 @@ class AberrationAIDialog(QDialog):
537
858
 
538
859
  self._t_start = time.perf_counter()
539
860
  prov_txt = ("auto" if self.chk_auto.isChecked() else self.cmb_provider.currentText() or "CPU")
540
- self._log(f"🚀 Aberration AI: model={os.path.basename(self._model_path)}, "
861
+ self._log(f"🚀 Aberration AI: model={os.path.basename(model_path)}, "
541
862
  f"provider={prov_txt}, patch={patch}, overlap={overlap}")
863
+
864
+ self._effective_model_path = model_path
542
865
 
543
866
  # -------- run worker --------
544
867
  self.progress.setValue(0)
545
868
  self.btn_run.setEnabled(False)
546
869
 
547
- self._worker = _ONNXWorker(self._model_path, img, patch, overlap, providers)
870
+ self._worker = _ONNXWorker(model_path, img, patch, overlap, providers)
548
871
  self._worker.progressed.connect(self.progress.setValue)
549
872
  self._worker.failed.connect(self._on_failed)
550
873
  self._worker.finished_ok.connect(self._on_ok)
551
874
  self._worker.finished.connect(self._on_worker_finished)
875
+ self._worker.log_message.connect(self._log) # Connect log messages to console
552
876
  self._worker.start()
553
877
 
554
878
 
555
879
  def _on_failed(self, msg: str):
556
- self._log(f"❌ Aberration AI failed: {msg}") # NEW
880
+ model_path = getattr(self, "_effective_model_path", self._model_path)
881
+ self._log(f"❌ Aberration AI failed: {msg}")
557
882
  QMessageBox.critical(self, "ONNX Error", msg)
883
+ self.reject() # closes the dialog
558
884
 
559
885
  def _on_ok(self, out: np.ndarray):
886
+ used = getattr(self._worker, "used_provider", None) or \
887
+ (self.cmb_provider.currentText() if not self.chk_auto.isChecked() else "auto")
888
+ model_path = getattr(self, "_effective_model_path", self._model_path)
560
889
  doc = self.get_active_doc()
561
890
  if doc is None or getattr(doc, "image", None) is None:
562
891
  QMessageBox.warning(self, "Image", "No active image.")
@@ -578,11 +907,10 @@ class AberrationAIDialog(QDialog):
578
907
  "processing_parameters": {
579
908
  **(getattr(doc, "metadata", {}) or {}).get("processing_parameters", {}),
580
909
  "AberrationAI": {
581
- "model_path": self._model_path,
910
+ "model_path": model_path,
582
911
  "patch_size": int(self.spin_patch.value()),
583
912
  "overlap": int(self.spin_overlap.value()),
584
- "provider": (self.cmb_provider.currentText()
585
- if not self.chk_auto.isChecked() else "auto"),
913
+ "provider": used,
586
914
  "border_px": BORDER_PX,
587
915
  }
588
916
  }
@@ -615,7 +943,7 @@ class AberrationAIDialog(QDialog):
615
943
  if main is not None:
616
944
  auto_gpu = bool(self.chk_auto.isChecked())
617
945
  preset = {
618
- "model": self._model_path,
946
+ "model": model_path,
619
947
  "patch": int(self.spin_patch.value()),
620
948
  "overlap": int(self.spin_overlap.value()),
621
949
  "border_px": int(BORDER_PX),
@@ -674,21 +1002,24 @@ class AberrationAIDialog(QDialog):
674
1002
  BORDER_PX = 10 # same value used above
675
1003
  self._log(
676
1004
  f"✅ Aberration AI applied "
677
- f"(model={os.path.basename(self._model_path)}, provider={used}, "
1005
+ f"(model={os.path.basename(model_path)}, provider={used}, "
678
1006
  f"patch={int(self.spin_patch.value())}, overlap={int(self.spin_overlap.value())}, "
679
1007
  f"border={BORDER_PX}px, time={dt:.2f}s)"
680
1008
  )
681
1009
 
682
1010
  self.progress.setValue(100)
683
- # Dialog stays open so user can apply to other images
1011
+ # NEW: close this UI after a successful run
1012
+ self.accept() # or self.close()
1013
+ return
684
1014
 
685
1015
  def _on_worker_finished(self):
686
- # If dialog is already gone, this method is never called because the receiver (self)
687
- # has been destroyed and Qt auto-disconnects the signal.
1016
+ # Dialog might have been closed by _on_ok()
1017
+ if not self.isVisible():
1018
+ return
1019
+
688
1020
  if hasattr(self, "btn_run"):
689
1021
  try:
690
1022
  self.btn_run.setEnabled(True)
691
1023
  except RuntimeError:
692
- # Button already deleted; ignore
693
1024
  pass
694
1025
  self._worker = None
@@ -4,7 +4,7 @@ import os
4
4
  import time
5
5
  import numpy as np
6
6
  from PyQt6.QtCore import QTimer
7
- from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel
7
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel, QApplication
8
8
 
9
9
  from PyQt6.QtCore import QSettings
10
10
  # reuse everything from the UI module
@@ -91,13 +91,29 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
91
91
  worker = _ONNXWorker(model, img, patch, overlap, providers)
92
92
  worker.progressed.connect(bar.setValue)
93
93
 
94
+ def _cancel_clicked():
95
+ btn.setEnabled(False)
96
+ btn.setText("Canceling…")
97
+ worker.cancel() # <-- SAFE
98
+ QApplication.processEvents()
99
+
94
100
  def _fail(msg: str):
95
101
  try:
96
102
  if hasattr(main, "_log"):
97
103
  main._log(f"❌ Aberration AI failed: {msg}")
98
104
  except Exception:
99
105
  pass
100
- QMessageBox.critical(main, "Aberration AI", msg)
106
+ # If canceled, don't pop an error box
107
+ if "Canceled" not in (msg or ""):
108
+ QMessageBox.critical(main, "Aberration AI", msg)
109
+ dlg.close()
110
+
111
+ def _canceled():
112
+ try:
113
+ if hasattr(main, "_log"):
114
+ main._log("⛔ Aberration AI canceled.")
115
+ except Exception:
116
+ pass
101
117
  dlg.close()
102
118
 
103
119
  def _ok(out: np.ndarray):
@@ -157,13 +173,23 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
157
173
  dlg.close()
158
174
 
159
175
  worker.failed.connect(_fail)
176
+ worker.canceled.connect(_canceled) # <-- NEW
160
177
  worker.finished_ok.connect(_ok)
161
178
  worker.finished.connect(lambda: btn.setEnabled(False))
162
- btn.clicked.connect(worker.terminate)
179
+
180
+ btn.clicked.connect(_cancel_clicked)
181
+
182
+ # If user closes dialog via window X, also cancel
183
+ dlg.rejected.connect(_cancel_clicked)
163
184
 
164
185
  worker.start()
165
186
  dlg.exec()
166
187
 
188
+ # Ensure the worker is not left running after the modal closes
189
+ if worker.isRunning():
190
+ worker.cancel()
191
+ worker.wait(2000) # don't hang forever; just give it a moment
192
+
167
193
  # clear the guard after a brief tick so downstream signals don’t re-open UI
168
194
  def _clear():
169
195
  for k in ("_aberration_ai_headless_running", "_aberration_ai_guard"):