setiastrosuitepro 1.6.1__py3-none-any.whl → 1.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/qml/ResourceMonitor.qml +126 -0
  3. setiastro/saspro/__main__.py +159 -23
  4. setiastro/saspro/_generated/build_info.py +2 -1
  5. setiastro/saspro/abe.py +62 -11
  6. setiastro/saspro/aberration_ai.py +3 -3
  7. setiastro/saspro/add_stars.py +5 -2
  8. setiastro/saspro/astrobin_exporter.py +3 -0
  9. setiastro/saspro/astrospike_python.py +3 -1
  10. setiastro/saspro/autostretch.py +4 -2
  11. setiastro/saspro/backgroundneutral.py +52 -10
  12. setiastro/saspro/batch_convert.py +3 -0
  13. setiastro/saspro/batch_renamer.py +3 -0
  14. setiastro/saspro/blemish_blaster.py +3 -0
  15. setiastro/saspro/cheat_sheet.py +50 -15
  16. setiastro/saspro/clahe.py +27 -1
  17. setiastro/saspro/comet_stacking.py +103 -38
  18. setiastro/saspro/convo.py +3 -0
  19. setiastro/saspro/copyastro.py +3 -0
  20. setiastro/saspro/cosmicclarity.py +70 -45
  21. setiastro/saspro/crop_dialog_pro.py +17 -0
  22. setiastro/saspro/curve_editor_pro.py +18 -0
  23. setiastro/saspro/debayer.py +3 -0
  24. setiastro/saspro/doc_manager.py +39 -16
  25. setiastro/saspro/fitsmodifier.py +3 -0
  26. setiastro/saspro/frequency_separation.py +8 -2
  27. setiastro/saspro/function_bundle.py +2 -0
  28. setiastro/saspro/generate_translations.py +715 -1
  29. setiastro/saspro/ghs_dialog_pro.py +3 -0
  30. setiastro/saspro/graxpert.py +3 -0
  31. setiastro/saspro/gui/main_window.py +275 -32
  32. setiastro/saspro/gui/mixins/dock_mixin.py +100 -1
  33. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  34. setiastro/saspro/gui/mixins/menu_mixin.py +28 -0
  35. setiastro/saspro/gui/statistics_dialog.py +47 -0
  36. setiastro/saspro/halobgon.py +29 -3
  37. setiastro/saspro/histogram.py +3 -0
  38. setiastro/saspro/history_explorer.py +2 -0
  39. setiastro/saspro/i18n.py +22 -10
  40. setiastro/saspro/image_combine.py +3 -0
  41. setiastro/saspro/image_peeker_pro.py +3 -0
  42. setiastro/saspro/imageops/stretch.py +5 -13
  43. setiastro/saspro/isophote.py +3 -0
  44. setiastro/saspro/legacy/numba_utils.py +64 -47
  45. setiastro/saspro/linear_fit.py +3 -0
  46. setiastro/saspro/live_stacking.py +13 -2
  47. setiastro/saspro/mask_creation.py +3 -0
  48. setiastro/saspro/mfdeconv.py +5 -0
  49. setiastro/saspro/morphology.py +30 -5
  50. setiastro/saspro/multiscale_decomp.py +3 -0
  51. setiastro/saspro/nbtorgb_stars.py +12 -2
  52. setiastro/saspro/numba_utils.py +148 -47
  53. setiastro/saspro/ops/scripts.py +77 -17
  54. setiastro/saspro/ops/settings.py +1 -43
  55. setiastro/saspro/perfect_palette_picker.py +1 -0
  56. setiastro/saspro/pixelmath.py +6 -2
  57. setiastro/saspro/plate_solver.py +2 -1
  58. setiastro/saspro/remove_green.py +18 -1
  59. setiastro/saspro/remove_stars.py +136 -162
  60. setiastro/saspro/resources.py +7 -0
  61. setiastro/saspro/rgb_combination.py +1 -0
  62. setiastro/saspro/rgbalign.py +4 -4
  63. setiastro/saspro/save_options.py +1 -0
  64. setiastro/saspro/sfcc.py +50 -8
  65. setiastro/saspro/signature_insert.py +3 -0
  66. setiastro/saspro/stacking_suite.py +630 -341
  67. setiastro/saspro/star_alignment.py +16 -1
  68. setiastro/saspro/star_spikes.py +116 -32
  69. setiastro/saspro/star_stretch.py +38 -1
  70. setiastro/saspro/stat_stretch.py +35 -3
  71. setiastro/saspro/subwindow.py +63 -2
  72. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  73. setiastro/saspro/translations/all_source_strings.json +3654 -0
  74. setiastro/saspro/translations/ar_translations.py +3865 -0
  75. setiastro/saspro/translations/de_translations.py +16 -0
  76. setiastro/saspro/translations/es_translations.py +16 -0
  77. setiastro/saspro/translations/fr_translations.py +16 -0
  78. setiastro/saspro/translations/hi_translations.py +3571 -0
  79. setiastro/saspro/translations/integrate_translations.py +36 -0
  80. setiastro/saspro/translations/it_translations.py +16 -0
  81. setiastro/saspro/translations/ja_translations.py +16 -0
  82. setiastro/saspro/translations/pt_translations.py +16 -0
  83. setiastro/saspro/translations/ru_translations.py +2848 -0
  84. setiastro/saspro/translations/saspro_ar.qm +0 -0
  85. setiastro/saspro/translations/saspro_ar.ts +255 -0
  86. setiastro/saspro/translations/saspro_de.qm +0 -0
  87. setiastro/saspro/translations/saspro_de.ts +3 -3
  88. setiastro/saspro/translations/saspro_es.qm +0 -0
  89. setiastro/saspro/translations/saspro_es.ts +3 -3
  90. setiastro/saspro/translations/saspro_fr.qm +0 -0
  91. setiastro/saspro/translations/saspro_fr.ts +3 -3
  92. setiastro/saspro/translations/saspro_hi.qm +0 -0
  93. setiastro/saspro/translations/saspro_hi.ts +257 -0
  94. setiastro/saspro/translations/saspro_it.qm +0 -0
  95. setiastro/saspro/translations/saspro_it.ts +3 -3
  96. setiastro/saspro/translations/saspro_ja.qm +0 -0
  97. setiastro/saspro/translations/saspro_ja.ts +4 -4
  98. setiastro/saspro/translations/saspro_pt.qm +0 -0
  99. setiastro/saspro/translations/saspro_pt.ts +3 -3
  100. setiastro/saspro/translations/saspro_ru.qm +0 -0
  101. setiastro/saspro/translations/saspro_ru.ts +237 -0
  102. setiastro/saspro/translations/saspro_sw.qm +0 -0
  103. setiastro/saspro/translations/saspro_sw.ts +257 -0
  104. setiastro/saspro/translations/saspro_uk.qm +0 -0
  105. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  106. setiastro/saspro/translations/saspro_zh.qm +0 -0
  107. setiastro/saspro/translations/saspro_zh.ts +3 -3
  108. setiastro/saspro/translations/sw_translations.py +3671 -0
  109. setiastro/saspro/translations/uk_translations.py +3700 -0
  110. setiastro/saspro/translations/zh_translations.py +16 -0
  111. setiastro/saspro/versioning.py +12 -6
  112. setiastro/saspro/view_bundle.py +3 -0
  113. setiastro/saspro/wavescale_hdr.py +22 -1
  114. setiastro/saspro/wavescalede.py +23 -1
  115. setiastro/saspro/whitebalance.py +39 -3
  116. setiastro/saspro/widgets/minigame/game.js +986 -0
  117. setiastro/saspro/widgets/minigame/index.html +53 -0
  118. setiastro/saspro/widgets/minigame/style.css +241 -0
  119. setiastro/saspro/widgets/resource_monitor.py +237 -0
  120. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  121. setiastro/saspro/wimi.py +7996 -0
  122. setiastro/saspro/wims.py +578 -0
  123. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/METADATA +15 -4
  124. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/RECORD +128 -103
  125. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/WHEEL +0 -0
  126. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/entry_points.txt +0 -0
  127. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/LICENSE +0 -0
  128. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/license.txt +0 -0
@@ -42,12 +42,23 @@ def background_neutralize_rgb(img: np.ndarray, rect_xywh: tuple[int, int, int, i
42
42
 
43
43
  out = img.copy()
44
44
  eps = 1e-8
45
- for c in range(3):
46
- diff = float(medians[c] - avg_med)
47
- denom = 1.0 - diff
48
- if abs(denom) < eps:
49
- denom = eps if denom >= 0 else -eps
50
- out[..., c] = np.clip((out[..., c] - diff) / denom, 0.0, 1.0)
45
+
46
+ # Vectorized neutralization
47
+ # diff shape: (3,) -> (1, 1, 3)
48
+ diffs = (medians - avg_med).reshape(1, 1, 3)
49
+
50
+ # denom shape: (1, 1, 3)
51
+ denoms = 1.0 - diffs
52
+
53
+ # Avoid div-by-zero (vectorized)
54
+ # logic: if abs(denom) < eps, set to eps (sign matched)
55
+ # We can do this efficiently:
56
+ small_mask = np.abs(denoms) < eps
57
+ denoms[small_mask] = np.where(denoms[small_mask] >= 0, eps, -eps)
58
+
59
+ # Apply formula: (pixel - diff) / denom
60
+ out = (out - diffs) / denoms
61
+ out = np.clip(out, 0.0, 1.0)
51
62
 
52
63
  return out.astype(np.float32, copy=False)
53
64
 
@@ -237,14 +248,21 @@ def apply_background_neutral_to_doc(doc, preset: dict | None = None):
237
248
  class BackgroundNeutralizationDialog(QDialog):
238
249
  def __init__(self, parent, doc, icon: QIcon | None = None):
239
250
  super().__init__(parent)
251
+ self._main = parent
240
252
  self.doc = doc
253
+
254
+ # Connect to active document change signal
255
+ if hasattr(self._main, "currentDocumentChanged"):
256
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
257
+
241
258
  if icon:
242
259
  self.setWindowIcon(icon)
243
260
  self.setWindowTitle(self.tr("Background Neutralization"))
244
261
  self.resize(900, 600)
245
262
 
246
263
  self.setWindowFlag(Qt.WindowType.Window, True)
247
- self.setWindowModality(Qt.WindowModality.ApplicationModal)
264
+ # Non-modal: allow user to switch between images while dialog is open
265
+ self.setWindowModality(Qt.WindowModality.NonModal)
248
266
  self.setModal(False)
249
267
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
250
268
 
@@ -313,7 +331,14 @@ class BackgroundNeutralizationDialog(QDialog):
313
331
 
314
332
  self._load_image()
315
333
 
316
-
334
+ # ---- active document change ------------------------------------
335
+ def _on_active_doc_changed(self, doc):
336
+ """Called when user clicks a different image window."""
337
+ if doc is None or getattr(doc, "image", None) is None:
338
+ return
339
+ self.doc = doc
340
+ self.selection_item = None
341
+ self._load_image()
317
342
 
318
343
  # ---------- image display ----------
319
344
  def _doc_image_normalized(self) -> np.ndarray:
@@ -509,8 +534,25 @@ class BackgroundNeutralizationDialog(QDialog):
509
534
  metadata=meta,
510
535
  step_name="Background Neutralization",
511
536
  )
512
- self.accept()
513
-
537
+ # Dialog stays open so user can apply to other images
538
+ # Refresh to use the now-active document for next operation
539
+ self._refresh_document_from_active()
540
+
541
+ def _refresh_document_from_active(self):
542
+ """
543
+ Refresh the dialog's document reference to the currently active document.
544
+ This allows reusing the same dialog on different images.
545
+ """
546
+ try:
547
+ main = self.parent()
548
+ if main and hasattr(main, "_active_doc"):
549
+ new_doc = main._active_doc()
550
+ if new_doc is not None and new_doc is not self.doc:
551
+ self.doc = new_doc
552
+ # Refresh the preview image
553
+ self._load_preview()
554
+ except Exception:
555
+ pass
514
556
 
515
557
  def _zoom(self, factor: float):
516
558
  self._user_zoomed = True
@@ -169,6 +169,9 @@ class BatchConvertDialog(QDialog):
169
169
  def __init__(self, parent=None):
170
170
  super().__init__(parent)
171
171
  self.setWindowTitle(self.tr("Batch Convert"))
172
+ self.setWindowFlag(Qt.WindowType.Window, True)
173
+ self.setWindowModality(Qt.WindowModality.NonModal)
174
+ self.setModal(False)
172
175
  self.setMinimumWidth(560)
173
176
  self.worker: _BatchWorker | None = None
174
177
 
@@ -46,6 +46,9 @@ class BatchRenamerDialog(QDialog):
46
46
  self.setWindowFlag(Qt.WindowType.WindowTitleHint, True)
47
47
  self.setWindowFlag(Qt.WindowType.WindowMinMaxButtonsHint, True)
48
48
  self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
49
+ self.setWindowFlag(Qt.WindowType.Window, True)
50
+ self.setWindowModality(Qt.WindowModality.NonModal)
51
+ self.setModal(False)
49
52
  self.setSizeGripEnabled(True)
50
53
 
51
54
  self._build_ui()
@@ -155,6 +155,9 @@ class BlemishBlasterDialogPro(QDialog):
155
155
  def __init__(self, parent, doc):
156
156
  super().__init__(parent)
157
157
  self.setWindowTitle(self.tr("Blemish Blaster"))
158
+ self.setWindowFlag(Qt.WindowType.Window, True)
159
+ self.setWindowModality(Qt.WindowModality.NonModal)
160
+ self.setModal(False)
158
161
  self.setMinimumSize(900, 650)
159
162
 
160
163
  self._doc = doc
@@ -11,6 +11,7 @@ from PyQt6.QtWidgets import (
11
11
  QApplication, QMessageBox
12
12
  )
13
13
  from PyQt6.QtGui import QAction, QShortcut, QKeySequence
14
+ from PyQt6.QtCore import Qt, QCoreApplication
14
15
 
15
16
 
16
17
  def _qs_to_str(seq: QKeySequence) -> str:
@@ -46,37 +47,68 @@ def _seqs_for_action(act: QAction):
46
47
 
47
48
  def _where_for_action(act: QAction) -> str:
48
49
  """Determine where an action is available (Menus/Toolbar or Window)."""
49
- if act.parent():
50
- pn = act.parent().__class__.__name__
51
- if pn.startswith("QMenu") or pn.startswith("QToolBar"):
52
- from PyQt6.QtCore import QCoreApplication
53
- return QCoreApplication.translate("CheatSheet", "Menus/Toolbar")
50
+ try:
51
+ parent = act.parent()
52
+ if parent is not None:
53
+ pn = parent.__class__.__name__
54
+ if pn.startswith("QMenu") or pn.startswith("QToolBar"):
55
+ return QCoreApplication.translate("CheatSheet", "Menus/Toolbar")
56
+ except Exception:
57
+ pass
54
58
  return QCoreApplication.translate("CheatSheet", "Window")
55
59
 
56
60
 
57
61
  def _describe_action(act: QAction) -> str:
58
62
  """Get a human-readable description for an action."""
59
- desc = _clean_text(act.statusTip() or act.toolTip() or act.text() or act.objectName() or "Action")
60
- if desc == "Action":
61
- from PyQt6.QtCore import QCoreApplication
63
+ try:
64
+ desc = _clean_text(
65
+ act.statusTip() or act.toolTip() or act.text() or act.objectName() or ""
66
+ )
67
+ except Exception:
68
+ desc = ""
69
+
70
+ if not desc:
62
71
  desc = QCoreApplication.translate("CheatSheet", "Action")
63
72
  return desc
64
73
 
65
74
 
66
75
  def _describe_shortcut(sc: QShortcut) -> str:
67
76
  """Get a human-readable description for a shortcut."""
68
- desc = _clean_text(sc.property("hint") or sc.whatsThis() or sc.objectName() or "Shortcut")
69
- if desc == "Shortcut":
70
- from PyQt6.QtCore import QCoreApplication
77
+ try:
78
+ hint = sc.property("hint")
79
+ desc = _clean_text(hint or sc.whatsThis() or sc.objectName() or "")
80
+ except Exception:
81
+ desc = ""
82
+
83
+ if not desc:
71
84
  desc = QCoreApplication.translate("CheatSheet", "Shortcut")
72
85
  return desc
73
86
 
74
-
87
+ def add_extra_shortcuts(rows):
88
+ """
89
+ Add app-level shortcuts that aren't represented by QActions/QShortcuts.
90
+ rows: list of (shortcut_str, action_str, where_str)
91
+ """
92
+ rows.append((
93
+ _clean_text("Ctrl+K"),
94
+ _clean_text("Toggle Show/Hide Mask"),
95
+ QCoreApplication.translate("CheatSheet", "Window"),
96
+ ))
97
+ rows.append((
98
+ _clean_text("Ctrl+Alt+M"),
99
+ _clean_text("SASpro Neon Invaders (Easter Egg)"),
100
+ QCoreApplication.translate("CheatSheet", "Window"),
101
+ ))
102
+
75
103
  def _where_for_shortcut(sc: QShortcut) -> str:
76
104
  """Determine where a shortcut is available."""
77
- par = sc.parent()
78
- from PyQt6.QtCore import QCoreApplication
79
- return par.__class__.__name__ if par is not None else QCoreApplication.translate("CheatSheet", "Window")
105
+ try:
106
+ par = sc.parent()
107
+ if par is not None:
108
+ return par.__class__.__name__
109
+ except Exception:
110
+ pass
111
+ return QCoreApplication.translate("CheatSheet", "Window")
80
112
 
81
113
 
82
114
  class CheatSheetDialog(QDialog):
@@ -91,6 +123,9 @@ class CheatSheetDialog(QDialog):
91
123
  def __init__(self, parent, keyboard_rows, gesture_rows):
92
124
  super().__init__(parent)
93
125
  self.setWindowTitle(self.tr("Keyboard Shortcut Cheat Sheet"))
126
+ self.setWindowFlag(Qt.WindowType.Window, True)
127
+ self.setWindowModality(Qt.WindowModality.NonModal)
128
+ self.setModal(False)
94
129
  self.resize(780, 520)
95
130
 
96
131
  self._keyboard_rows = keyboard_rows
setiastro/saspro/clahe.py CHANGED
@@ -114,6 +114,9 @@ class CLAHEDialogPro(QDialog):
114
114
  def __init__(self, parent, doc, icon: QIcon | None = None):
115
115
  super().__init__(parent)
116
116
  self.setWindowTitle(self.tr("CLAHE"))
117
+ self.setWindowFlag(Qt.WindowType.Window, True)
118
+ self.setWindowModality(Qt.WindowModality.NonModal)
119
+ self.setModal(False)
117
120
  if icon:
118
121
  try: self.setWindowIcon(icon)
119
122
  except Exception as e:
@@ -319,11 +322,34 @@ class CLAHEDialogPro(QDialog):
319
322
  pass
320
323
  # ─────────────────────────────────────────────────────────────
321
324
 
322
- self.accept()
325
+ # Dialog stays open so user can apply to other images
326
+ # Refresh document reference for next operation
327
+ self._refresh_document_from_active()
323
328
 
324
329
  except Exception as e:
325
330
  QMessageBox.critical(self, "CLAHE", f"Failed to apply:\n{e}")
326
331
 
332
+ def _refresh_document_from_active(self):
333
+ """
334
+ Refresh the dialog's document reference to the currently active document.
335
+ This allows reusing the same dialog on different images.
336
+ """
337
+ try:
338
+ main = self.parent()
339
+ if main and hasattr(main, "_active_doc"):
340
+ new_doc = main._active_doc()
341
+ if new_doc is not None and new_doc is not self.doc:
342
+ self.doc = new_doc
343
+ # Refresh original image and preview for new document
344
+ self.orig = np.clip(np.asarray(new_doc.image, dtype=np.float32), 0.0, 1.0)
345
+ disp = self.orig
346
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
347
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
348
+ self._disp_base = disp
349
+ self._update_preview()
350
+ except Exception:
351
+ pass
352
+
327
353
  def _tile_grid_from_px(self, tile_px: int, hw: tuple[int, int]) -> tuple[int, int]:
328
354
  """
329
355
  Convert desired tile size (pixels) into OpenCV tileGridSize=(n,n)
@@ -90,28 +90,48 @@ def starnet_starless_pair_from_array(
90
90
  # -------- normalize & shape: float32 [0..1], keep note if mono ----------
91
91
  x = np.asarray(src_rgb01, dtype=np.float32)
92
92
  was_mono = (x.ndim == 2) or (x.ndim == 3 and x.shape[2] == 1)
93
- if x.ndim == 2:
94
- x3 = np.stack([x]*3, axis=-1)
95
- elif x.ndim == 3 and x.shape[2] == 1:
96
- x3 = np.repeat(x, 3, axis=2)
97
- else:
98
- x3 = x
99
- x3 = np.nan_to_num(x3, nan=0.0, posinf=0.0, neginf=0.0)
100
-
101
- # -------- pre-StarNet stretch (per channel), only if the data are linear ----------
93
+
94
+ # DELAY expansion: work with 'x' (mono or rgb) directly where possible
95
+ x_input = x
96
+ if x_input.ndim == 3 and x_input.shape[2] == 1:
97
+ x_input = x_input[..., 0] # collapse to 2D for processing if needed, or keep 2D
98
+
99
+ # For StarNet save, we need 3 channels usually, but check if we can save mono?
100
+ # Actually StarNet usually expects RGB Tiff. So we might need to expand just for saving.
101
+ # But let's avoid `x3 = np.repeat` globally.
102
+
103
+ # Optimization: Create x3 ON DEMAND or virtually using broadcasting only when needed.
104
+ # But `save_image` might handle mono TIFs. If StarNet accepts Mono TIF, we save huge RAM.
105
+ # Standard StarNet typically wants RGB. We will enable "is_mono" flag in `save_image` if it is mono,
106
+ # but StarNet is finicky. Let's stick to RGB for StarNet input but avoid `np.repeat` for the WHOLE array
107
+ # if we can just broadcast or slice.
108
+ # Actually, `stretch_color_image` handles broadcasting? No.
109
+ # Let's simple optimize:
110
+
102
111
  if is_linear:
103
- # channel-wise stretch to avoid red cast; your funcs expect [0..1]
104
- pre = stretch_color_image(x3, 0.25, False, False, False)
105
-
112
+ # stretch; if mono use mono stretch
113
+ if was_mono:
114
+ if x.ndim == 3: x = x[..., 0]
115
+ pre = stretch_mono_image(x, 0.25, False, False)
116
+ # expand ONLY for save
117
+ pre_to_save = np.dstack([pre, pre, pre])
118
+ else:
119
+ pre = stretch_color_image(x, 0.25, False, False, False)
120
+ pre_to_save = pre
106
121
  else:
107
- pre = x3 # already non-linear; pass through
122
+ pre = x # floating point 0..1
123
+ if was_mono:
124
+ if pre.ndim == 3: pre = pre[..., 0]
125
+ pre_to_save = np.dstack([pre, pre, pre])
126
+ else:
127
+ pre_to_save = pre
108
128
 
109
129
  # -------- StarNet I/O (write float->16b TIFF; read back float) ----------
110
130
  starnet_dir = os.path.dirname(exe) or os.getcwd()
111
131
  in_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
112
132
  out_path = os.path.join(starnet_dir, "starless.tif")
113
133
 
114
- save_image(pre, in_path, original_format="tif", bit_depth="16-bit",
134
+ save_image(pre_to_save, in_path, original_format="tif", bit_depth="16-bit",
115
135
  original_header=None, is_mono=False, image_meta=None, file_meta=None)
116
136
 
117
137
  exe_name = os.path.basename(exe).lower()
@@ -134,32 +154,61 @@ def starnet_starless_pair_from_array(
134
154
  except Exception:
135
155
  pass
136
156
 
137
- if starless_pre.ndim == 2:
138
- starless_pre = np.stack([starless_pre]*3, axis=-1)
139
- elif starless_pre.ndim == 3 and starless_pre.shape[2] == 1:
140
- starless_pre = np.repeat(starless_pre, 3, axis=2)
157
+ # Don't expand starless_pre yet if we don't need to.
141
158
  starless_pre = starless_pre.astype(np.float32, copy=False)
142
-
159
+ if was_mono and starless_pre.ndim == 3:
160
+ # StarNet output is usually RGB even for mono input. Convert back to mono?
161
+ # Or just use one channel.
162
+ starless_pre = starless_pre[..., 0]
163
+
164
+ # Maintain `pre` as the stretched input (mono or rgb)
165
+
143
166
  # ---- mask-protect in the SAME (stretched) domain as pre/starless_pre ----
144
167
  if core_mask is not None:
145
168
  m = np.clip(core_mask.astype(np.float32), 0.0, 1.0)
146
- m3 = np.repeat(m[..., None], 3, axis=2)
147
- protected_stretched = starless_pre * (1.0 - m3) + pre * m3
169
+ # broadcast mask
170
+ if not was_mono:
171
+ if m.ndim == 2: m = m[..., None]
172
+
173
+ protected_stretched = starless_pre * (1.0 - m) + pre * m
148
174
  else:
149
175
  protected_stretched = starless_pre
176
+
177
+ # Return to 3-channel ONLY if requested by the caller's context?
178
+ # The signature `starnet_starless_pair_from_array` implies it might return what it got.
179
+ # The original returned `protected_unstretch`.
180
+ pass # logic flow continues below...
150
181
 
151
182
  # -------- “unstretch” → shared pseudo-linear space (once, after blend) ----------
152
183
  if is_linear:
153
- protected_unstretch = stretch_color_image(
154
- protected_stretched, 0.05, False, False, False
155
- )
184
+ # choose stretcher based on channels
185
+ if was_mono:
186
+ # ensure 2d
187
+ if protected_stretched.ndim == 3 and protected_stretched.shape[2] == 1:
188
+ protected_stretched = protected_stretched[..., 0]
189
+ elif protected_stretched.ndim == 3:
190
+ # collapse rgb to mono if needed? likely StarNet gave RGB.
191
+ # Keep RGB if StarNet created color artifacts we want to keep?
192
+ # Usually for mono data we want to kill color.
193
+ protected_stretched = protected_stretched.mean(axis=2)
194
+
195
+ protected_unstretch = stretch_mono_image(
196
+ protected_stretched, 0.05, False, False
197
+ )
198
+ # Expand finally for return constraint?
199
+ # The older function returned RGB-like.
200
+ # Let's expand here at the VERY END.
201
+ protected_unstretch = np.dstack([protected_unstretch]*3)
202
+ else:
203
+ protected_unstretch = stretch_color_image(
204
+ protected_stretched, 0.05, False, False, False
205
+ )
156
206
  else:
157
207
  protected_unstretch = protected_stretched
208
+ if was_mono and protected_unstretch.ndim == 2:
209
+ protected_unstretch = np.dstack([protected_unstretch]*3)
158
210
 
159
- protected_unstretch = np.clip(
160
- protected_unstretch.astype(np.float32, copy=False), 0.0, 1.0
161
- )
162
- return protected_unstretch, protected_unstretch
211
+ return np.clip(protected_unstretch, 0.0, 1.0), np.clip(protected_unstretch, 0.0, 1.0)
163
212
 
164
213
 
165
214
 
@@ -170,8 +219,17 @@ def darkstar_starless_from_array(src_rgb01: np.ndarray, settings, **_ignored) ->
170
219
  """
171
220
  # normalize channels
172
221
  img = src_rgb01.astype(np.float32, copy=False)
173
- if img.ndim == 2: img = np.stack([img]*3, axis=-1)
174
- if img.ndim == 3 and img.shape[2] == 1: img = np.repeat(img, 3, axis=2)
222
+ # Delay expansion: if it's 2D/Mono, send it as-is if DarkStar supports it,
223
+ # but DarkStar expects 3-channel TIF usually.
224
+ # We'll just expand for the save call, not "in place" if possible.
225
+ # Actually DarkStar runner saves `img` directly.
226
+ # So we'll expand just for that save to avoid holding 2 copies in memory.
227
+ if img.ndim == 2:
228
+ img_to_save = np.stack([img]*3, axis=-1)
229
+ elif img.ndim == 3 and img.shape[2] == 1:
230
+ img_to_save = np.repeat(img, 3, axis=2)
231
+ else:
232
+ img_to_save = img
175
233
 
176
234
  # resolve exe and base folder
177
235
  exe, base = _resolve_darkstar_exe(type("Dummy", (), {"settings": settings})())
@@ -192,7 +250,8 @@ def darkstar_starless_from_array(src_rgb01: np.ndarray, settings, **_ignored) ->
192
250
  out_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
193
251
 
194
252
  # save input as float32 TIFF
195
- save_image(img, in_path, original_format="tif", bit_depth="32-bit floating point",
253
+ # save input as float32 TIFF
254
+ save_image(img_to_save, in_path, original_format="tif", bit_depth="32-bit floating point",
196
255
  original_header=None, is_mono=False, image_meta=None, file_meta=None)
197
256
 
198
257
  # build command (SASv2 parity): default unscreen, show extracted stars off, stride 512
@@ -218,8 +277,12 @@ def darkstar_starless_from_array(src_rgb01: np.ndarray, settings, **_ignored) ->
218
277
  if starless is None:
219
278
  raise RuntimeError("DarkStar produced no output.")
220
279
 
221
- if starless.ndim == 2: starless = np.stack([starless]*3, axis=-1)
222
- if starless.shape[2] == 1: starless = np.repeat(starless, 3, axis=2)
280
+ # Delayed expansion
281
+ if starless.ndim == 2:
282
+ starless = np.stack([starless]*3, axis=-1)
283
+ elif starless.ndim == 3 and starless.shape[2] == 1:
284
+ starless = np.repeat(starless, 3, axis=2)
285
+
223
286
  return np.clip(starless.astype(np.float32, copy=False), 0.0, 1.0)
224
287
 
225
288
  # ---------- small helpers ----------
@@ -239,6 +302,10 @@ def _inv_affine_2x3(M: np.ndarray) -> np.ndarray:
239
302
  def _to_luma(img: np.ndarray) -> np.ndarray:
240
303
  if img.ndim == 2: return img.astype(np.float32, copy=False)
241
304
  if img.ndim == 3 and img.shape[-1] == 3:
305
+ try:
306
+ return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32, copy=False)
307
+ except Exception:
308
+ pass
242
309
  r,g,b = img[...,0], img[...,1], img[...,2]
243
310
  return (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32, copy=False)
244
311
  if img.ndim == 3 and img.shape[-1] == 1:
@@ -748,11 +815,9 @@ def _shift_to_comet(img: np.ndarray, xy: Tuple[float,float], ref_xy: Tuple[float
748
815
  M = np.array([[1.0, 0.0, dx], [0.0, 1.0, dy]], dtype=np.float32)
749
816
  H, W = img.shape[:2]
750
817
  interp = cv2.INTER_LANCZOS4
751
- if img.ndim == 2:
752
- return cv2.warpAffine(img, M, (W, H), flags=interp, borderMode=cv2.BORDER_REFLECT)
753
- # 3-channel
754
- ch = [cv2.warpAffine(img[...,c], M, (W, H), flags=interp, borderMode=cv2.BORDER_REFLECT) for c in range(img.shape[-1])]
755
- return np.stack(ch, axis=-1)
818
+
819
+ # Vectorized warp for both 2D (mono) and 3D (RGB)
820
+ return cv2.warpAffine(img, M, (W, H), flags=interp, borderMode=cv2.BORDER_REFLECT)
756
821
 
757
822
  def stack_comet_aligned(file_list: List[str],
758
823
  comet_xy: Dict[str, Tuple[float,float]],
setiastro/saspro/convo.py CHANGED
@@ -153,6 +153,9 @@ class ConvoDeconvoDialog(QDialog):
153
153
  self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
154
154
 
155
155
  self.setWindowTitle(self.tr("Convolution / Deconvolution"))
156
+ self.setWindowFlag(Qt.WindowType.Window, True)
157
+ self.setWindowModality(Qt.WindowModality.NonModal)
158
+ self.setModal(False)
156
159
  self.resize(1000, 650)
157
160
  self._use_custom_psf = False
158
161
  self._custom_psf: Optional[np.ndarray] = None
@@ -15,6 +15,9 @@ class CopyAstrometryDialog(QDialog):
15
15
  def __init__(self, parent=None, target=None):
16
16
  super().__init__(parent)
17
17
  self.setWindowTitle("Copy Astrometric Solution")
18
+ self.setWindowFlag(Qt.WindowType.Window, True)
19
+ self.setWindowModality(Qt.WindowModality.NonModal)
20
+ self.setModal(False)
18
21
  self.setMinimumWidth(420)
19
22
 
20
23
  self._mw = parent
@@ -271,6 +271,9 @@ class CosmicClarityDialogPro(QDialog):
271
271
  QTimer.singleShot(0, self.reject)
272
272
  return
273
273
  self.setWindowTitle(self.tr("Cosmic Clarity"))
274
+ self.setWindowFlag(Qt.WindowType.Window, True)
275
+ self.setWindowModality(Qt.WindowModality.NonModal)
276
+ self.setModal(False)
274
277
  if icon:
275
278
  try: self.setWindowIcon(icon)
276
279
  except Exception as e:
@@ -632,7 +635,53 @@ class CosmicClarityDialogPro(QDialog):
632
635
  if self._wait: self._wait.close(); self._wait = None
633
636
  if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
634
637
 
635
- # Load processed image we just got
638
+ has_more = bool(self._op_queue)
639
+
640
+ # --- Optimization: Chained Execution Fast Path ---
641
+ # If we have more steps, skip the expensive load/display/save cycle.
642
+ # Just move the output file to be the input for the next step.
643
+ if has_more:
644
+ if not out_path or not os.path.exists(out_path):
645
+ QMessageBox.critical(self, "Cosmic Clarity", "Output file missing during chain execution.")
646
+ self._op_queue.clear()
647
+ return
648
+
649
+ base = self._base_name()
650
+ next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
651
+ prev_in = getattr(self, "_current_input", None)
652
+
653
+ try:
654
+ # Direct move/copy instead of decode+encode
655
+ if os.path.abspath(out_path) != os.path.abspath(next_in):
656
+ # Windows cannot atomic replace if target exists via os.rename usually,
657
+ # but shutil.move is generally robust.
658
+ # We remove target first to be sure.
659
+ if os.path.exists(next_in):
660
+ os.remove(next_in)
661
+ shutil.move(out_path, next_in)
662
+
663
+ # Ensure stability of the *new* input
664
+ if not _wait_stable_file(next_in):
665
+ QMessageBox.critical(self, "Cosmic Clarity", "Staged input for next step is unstable.")
666
+ self._op_queue.clear()
667
+ return
668
+
669
+ self._current_input = next_in
670
+
671
+ # Cleanup previous input if distinct
672
+ if prev_in and prev_in != next_in and os.path.exists(prev_in):
673
+ os.remove(prev_in)
674
+
675
+ except Exception as e:
676
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to stage next step:\n{e}")
677
+ self._op_queue.clear()
678
+ return
679
+
680
+ # Trigger next step immediately
681
+ QTimer.singleShot(50, self._run_next)
682
+ return
683
+
684
+ # --- Final Step (or Single Step): Load and Display ---
636
685
  try:
637
686
  img, hdr, bd, mono = load_image(out_path)
638
687
  if img is None:
@@ -643,61 +692,34 @@ class CosmicClarityDialogPro(QDialog):
643
692
 
644
693
  dest = img.astype(np.float32, copy=False)
645
694
 
646
- # Apply to document (so the user sees the step result immediately)
695
+ # Apply to document
647
696
  step_title = f"Cosmic Clarity – {mode.title()}"
648
697
  create_new = (self.cmb_target.currentIndex() == 1)
649
698
 
650
699
  if create_new:
651
700
  ok = self._spawn_new_doc_from_numpy(dest, step_title)
652
701
  if not ok:
653
- # fall back to overwriting if we couldn’t spawn a new doc
654
702
  self._apply_to_active(dest, step_title)
655
703
  else:
656
704
  self._apply_to_active(dest, step_title)
657
705
 
658
- # Will we run another step (i.e., we're in "Both")?
659
- has_more = bool(self._op_queue)
660
- base = self._base_name()
661
- next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
662
- prev_in = getattr(self, "_current_input", None)
663
-
706
+ # Cleanup final output
707
+ if out_path and os.path.exists(out_path):
708
+ try: os.remove(out_path)
709
+ except OSError: pass
710
+
711
+ # Cleanup final input
712
+ prev_in = getattr(self, "_current_input", None)
713
+ if prev_in and os.path.exists(prev_in):
714
+ try: os.remove(prev_in)
715
+ except OSError: pass
716
+
717
+ # Final purge
664
718
  try:
665
- if has_more:
666
- def _writer(tmp_path, arr=dest):
667
- save_image(arr, tmp_path, "tiff", "32-bit floating point",
668
- getattr(self.doc, "original_header", None),
669
- getattr(self.doc, "is_mono", False))
670
- _atomic_fsync_replace(_writer, next_in)
671
- if not _wait_stable_file(next_in):
672
- QMessageBox.critical(self, "Cosmic Clarity", "Staging for next step failed (not stable).")
673
- self._op_queue.clear()
674
- return
675
- self._current_input = next_in
676
-
677
- # Now it’s safe to clean up the produced output
678
- if out_path and os.path.exists(out_path):
679
- os.remove(out_path)
680
-
681
- # Remove the previous input file if it’s different from the new one
682
- if prev_in and prev_in != next_in and os.path.exists(prev_in):
683
- os.remove(prev_in)
684
-
685
- except Exception as e:
686
- QMessageBox.critical(self, "Cosmic Clarity", f"Failed while staging next step:\n{e}")
687
- self._op_queue.clear()
688
- return
689
-
690
- # Continue or finish
691
- if has_more:
692
- QTimer.singleShot(100, self._run_next)
693
- else:
694
- # Nothing else queued — we're done
695
- try:
696
- # 🔸 Final cleanup: clear both input & output
697
- _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
698
- except Exception:
699
- pass
700
- self.accept()
719
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
720
+ except Exception:
721
+ pass
722
+ self.accept()
701
723
 
702
724
 
703
725
  def _on_wait_error(self, msg: str):
@@ -1009,6 +1031,9 @@ class CosmicClaritySatelliteDialogPro(QDialog):
1009
1031
  def __init__(self, parent, doc=None, icon: QIcon | None = None):
1010
1032
  super().__init__(parent)
1011
1033
  self.setWindowTitle("Cosmic Clarity – Satellite Removal")
1034
+ self.setWindowFlag(Qt.WindowType.Window, True)
1035
+ self.setWindowModality(Qt.WindowModality.NonModal)
1036
+ self.setModal(False)
1012
1037
  if icon:
1013
1038
  try: self.setWindowIcon(icon)
1014
1039
  except Exception as e: