setiastrosuitepro 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,382 @@
1
+ # pro/morphology.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import cv2
5
+
6
+ from PyQt6.QtCore import Qt, QTimer
7
+ from PyQt6.QtGui import QImage, QPixmap, QIcon
8
+ from PyQt6.QtWidgets import (
9
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QFormLayout,
10
+ QLabel, QPushButton, QSlider, QComboBox,
11
+ QGraphicsScene, QGraphicsPixmapItem, QMessageBox, QSpinBox, QDialogButtonBox
12
+ )
13
+
14
+ # Import centralized widgets
15
+ from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
16
+ from setiastro.saspro.widgets.image_utils import (
17
+ extract_mask_resized as _get_active_mask_resized,
18
+ blend_with_mask as _blend_with_mask
19
+ )
20
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
21
+
22
+
23
+ # ---------------- Core (unchanged) ----------------
24
+ def apply_morphology(image: np.ndarray, *, operation: str = "erosion",
25
+ kernel_size: int = 3, iterations: int = 1) -> np.ndarray:
26
+ # ... (existing body unchanged)
27
+ if image is None:
28
+ raise ValueError("image is None")
29
+ img = np.clip(np.asarray(image, dtype=np.float32), 0.0, 1.0)
30
+ was_hw1 = (img.ndim == 3 and img.shape[2] == 1)
31
+ if kernel_size % 2 == 0: kernel_size += 1
32
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
33
+
34
+ def _do(u8):
35
+ if operation == "erosion": return cv2.erode(u8, kernel, iterations=iterations)
36
+ if operation == "dilation": return cv2.dilate(u8, kernel, iterations=iterations)
37
+ if operation == "opening": return cv2.morphologyEx(u8, cv2.MORPH_OPEN, kernel, iterations=iterations)
38
+ if operation == "closing": return cv2.morphologyEx(u8, cv2.MORPH_CLOSE, kernel, iterations=iterations)
39
+ raise ValueError(f"Unsupported morphological operation: {operation}")
40
+
41
+ if img.ndim == 2 or was_hw1:
42
+ mono = (img.squeeze() * 255.0).astype(np.uint8)
43
+ out = _do(mono).astype(np.float32) / 255.0
44
+ out = np.clip(out, 0.0, 1.0)
45
+ return out[..., None] if was_hw1 else out
46
+
47
+ if img.ndim == 3 and img.shape[2] == 3:
48
+ u8 = (img * 255.0).astype(np.uint8)
49
+ ch = cv2.split(u8)
50
+ ch = [_do(c) for c in ch]
51
+ out = cv2.merge(ch).astype(np.float32) / 255.0
52
+ return np.clip(out, 0.0, 1.0)
53
+
54
+ raise ValueError("Input image must be mono (H,W)/(H,W,1) or RGB (H,W,3).")
55
+
56
+ def apply_morphology_to_doc(doc, preset: dict | None):
57
+ if doc is None or getattr(doc, "image", None) is None:
58
+ raise RuntimeError("Document has no image.")
59
+
60
+ img = np.asarray(doc.image)
61
+ op = (preset or {}).get("operation", "erosion")
62
+ ker = int((preset or {}).get("kernel", 3))
63
+ it = int((preset or {}).get("iterations", 1))
64
+
65
+ out = apply_morphology(img, operation=str(op), kernel_size=ker, iterations=it)
66
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
67
+
68
+ # Blend with active mask if present
69
+ H, W = out.shape[:2]
70
+ m = _get_active_mask_resized(doc, H, W)
71
+ if m is not None:
72
+ base = np.asarray(doc.image, dtype=np.float32)
73
+ if base.dtype.kind in "ui":
74
+ maxv = float(np.iinfo(base.dtype).max)
75
+ base = base / max(1.0, maxv)
76
+ else:
77
+ base = np.clip(base, 0.0, 1.0)
78
+ out = _blend_with_mask(base, out, m).astype(np.float32, copy=False)
79
+
80
+ if hasattr(doc, "set_image"): doc.set_image(out, step_name="Morphology")
81
+ elif hasattr(doc, "apply_numpy"): doc.apply_numpy(out, step_name="Morphology")
82
+ else: doc.image = out
83
+
84
+ # Note: _get_active_mask_resized and _blend_with_mask imported from setiastro.saspro.widgets.image_utils
85
+
86
+
87
+ # ---------------- Dialog ----------------
88
+ class MorphologyDialogPro(QDialog):
89
+ OPS = ["Erosion", "Dilation", "Opening", "Closing"]
90
+ OP_MAP = {"Erosion":"erosion","Dilation":"dilation","Opening":"opening","Closing":"closing"}
91
+
92
+ def __init__(self, parent, doc, icon: QIcon | None = None, initial: dict | None = None):
93
+ super().__init__(parent)
94
+ self.setWindowTitle("Morphological Operations")
95
+ if icon:
96
+ try: self.setWindowIcon(icon)
97
+ except Exception as e:
98
+ import logging
99
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
100
+
101
+ self.doc = doc
102
+ self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
103
+
104
+ disp = self.orig
105
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
106
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
107
+ self._disp_base = disp
108
+
109
+ v = QVBoxLayout(self)
110
+
111
+ # ---- Params (unchanged) ----
112
+ grp = QGroupBox("Morphological Parameters")
113
+ grid = QGridLayout(grp)
114
+ self.cb_op = QComboBox(); self.cb_op.addItems(self.OPS)
115
+ self.sp_kernel = QSpinBox(); self.sp_kernel.setRange(1, 31); self.sp_kernel.setSingleStep(2)
116
+ self.sp_iter = QSpinBox(); self.sp_iter.setRange(1, 10)
117
+
118
+ init = dict(initial or {})
119
+ op_text = {v:k for k,v in self.OP_MAP.items()}.get(str(init.get("operation","erosion")).lower(), "Erosion")
120
+ self.cb_op.setCurrentText(op_text)
121
+ k = int(init.get("kernel", 3)); self.sp_kernel.setValue(k if k % 2 == 1 else k + 1)
122
+ self.sp_iter.setValue(int(init.get("iterations", 1)))
123
+
124
+ self.cb_op.currentTextChanged.connect(self._debounce)
125
+ self.sp_kernel.valueChanged.connect(self._debounce)
126
+ self.sp_iter.valueChanged.connect(self._debounce)
127
+
128
+ grid.addWidget(QLabel("Operation:"), 0, 0); grid.addWidget(self.cb_op, 0, 1, 1, 2)
129
+ grid.addWidget(QLabel("Kernel size:"), 1, 0); grid.addWidget(self.sp_kernel, 1, 1)
130
+ grid.addWidget(QLabel("Iterations:"), 2, 0); grid.addWidget(self.sp_iter, 2, 1)
131
+ v.addWidget(grp)
132
+
133
+ # ---- Preview with zoom/pan ----
134
+ self.scene = QGraphicsScene(self)
135
+ self.view = ZoomableGraphicsView(self.scene)
136
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
137
+ self.pix = QGraphicsPixmapItem()
138
+ self.scene.addItem(self.pix)
139
+ v.addWidget(self.view, 1)
140
+
141
+ # ---- Zoom bar (themed) ----
142
+ z = QHBoxLayout()
143
+ z.addStretch(1)
144
+
145
+ btn_in = themed_toolbtn("zoom-in", "Zoom In")
146
+ btn_out = themed_toolbtn("zoom-out", "Zoom Out")
147
+ btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
148
+
149
+ btn_in.clicked.connect(self.view.zoom_in)
150
+ btn_out.clicked.connect(self.view.zoom_out)
151
+ btn_fit.clicked.connect(lambda: self.view.fit_to_item(self.pix))
152
+
153
+ z.addWidget(btn_in)
154
+ z.addWidget(btn_out)
155
+ z.addWidget(btn_fit)
156
+ v.addLayout(z)
157
+
158
+
159
+ # ---- Buttons (unchanged) ----
160
+ row = QHBoxLayout()
161
+ btn_apply = QPushButton("Apply"); btn_apply.clicked.connect(self._apply)
162
+ btn_reset = QPushButton("Reset"); btn_reset.clicked.connect(self._reset)
163
+ btn_cancel= QPushButton("Cancel"); btn_cancel.clicked.connect(self.reject)
164
+ row.addStretch(1); row.addWidget(btn_apply); row.addWidget(btn_reset); row.addWidget(btn_cancel)
165
+ v.addLayout(row)
166
+
167
+ self._timer = QTimer(self); self._timer.setSingleShot(True); self._timer.timeout.connect(self._update_preview)
168
+
169
+ self._set_pix(self._disp_base)
170
+ self._update_preview()
171
+ # initial fit
172
+ self.view.fit_to_item(self.pix)
173
+
174
+ def _debounce(self): self._timer.start(200)
175
+
176
+ def _set_pix(self, rgb):
177
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
178
+ h, w, _ = arr.shape
179
+ q = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
180
+ self.pix.setPixmap(QPixmap.fromImage(q))
181
+ self.scene.setSceneRect(self.pix.boundingRect())
182
+
183
+ def _params(self):
184
+ op = self.OP_MAP[self.cb_op.currentText()]
185
+ k = int(self.sp_kernel.value())
186
+ it = int(self.sp_iter.value())
187
+ if k % 2 == 0: k += 1
188
+ return op, k, it
189
+
190
+ def _update_preview(self):
191
+ op, k, it = self._params()
192
+ try:
193
+ out = apply_morphology(self._disp_base, operation=op, kernel_size=k, iterations=it)
194
+
195
+ # Blend preview with active mask (preview is on _disp_base size)
196
+ H, W = out.shape[:2]
197
+ m = _get_active_mask_resized(self.doc, H, W)
198
+ if m is not None:
199
+ base = self._disp_base.astype(np.float32)
200
+ out = _blend_with_mask(base, out.astype(np.float32), m)
201
+
202
+ self._set_pix(out)
203
+ except Exception as e:
204
+ QMessageBox.warning(self, "Morphology", f"Preview failed:\n{e}")
205
+
206
+ def _apply(self):
207
+ op, k, it = self._params()
208
+ try:
209
+ out = apply_morphology(self.orig, operation=op, kernel_size=k, iterations=it)
210
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
211
+
212
+ # Blend with active mask at full resolution
213
+ H, W = out.shape[:2]
214
+ m = _get_active_mask_resized(self.doc, H, W)
215
+ if m is not None:
216
+ base = np.asarray(self.doc.image, dtype=np.float32)
217
+ if base.dtype.kind in "ui":
218
+ maxv = float(np.iinfo(base.dtype).max)
219
+ base = base / max(1.0, maxv)
220
+ else:
221
+ base = np.clip(base, 0.0, 1.0)
222
+ out = _blend_with_mask(base, out, m).astype(np.float32, copy=False)
223
+
224
+ # Commit to document
225
+ if hasattr(self.doc, "set_image"):
226
+ self.doc.set_image(out, step_name="Morphology")
227
+ elif hasattr(self.doc, "apply_numpy"):
228
+ self.doc.apply_numpy(out, step_name="Morphology")
229
+ else:
230
+ self.doc.image = out
231
+
232
+ # ── Register as last_headless_command for replay ───────────
233
+ try:
234
+ main = self.parent()
235
+ if main is not None:
236
+ preset = {
237
+ "operation": op,
238
+ "kernel": int(k),
239
+ "iterations": int(it),
240
+ }
241
+ payload = {
242
+ "command_id": "morphology",
243
+ "preset": dict(preset),
244
+ }
245
+ setattr(main, "_last_headless_command", payload)
246
+
247
+ # optional log
248
+ try:
249
+ if hasattr(main, "_log"):
250
+ main._log(
251
+ f"[Replay] Registered Morphology as last action "
252
+ f"(op={op}, kernel={k}, iter={it})"
253
+ )
254
+ except Exception:
255
+ pass
256
+ except Exception:
257
+ # never break apply if replay wiring fails
258
+ pass
259
+ # ────────────────────────────────────────────────────────────
260
+
261
+ self.accept()
262
+ except Exception as e:
263
+ QMessageBox.critical(self, "Morphology", f"Failed to apply:\n{e}")
264
+
265
+
266
+
267
+ def _reset(self):
268
+ self.cb_op.setCurrentText("Erosion")
269
+ self.sp_kernel.setValue(3)
270
+ self.sp_iter.setValue(1)
271
+ self._set_pix(self._disp_base)
272
+ self.view.fit_to_item(self.pix)
273
+
274
+
275
+ # ---------------------- Preset editor (Shortcuts) ----------------------
276
+
277
+ class _MorphologyPresetDialog(QDialog):
278
+ """
279
+ Preset editor for Morphology shortcuts.
280
+ Stores JSON-safe dict:
281
+ { "operation": "erosion|dilation|opening|closing",
282
+ "kernel": int odd,
283
+ "iterations": int }
284
+ """
285
+ OPS = ["erosion", "dilation", "opening", "closing"]
286
+
287
+ def __init__(self, parent=None, initial: dict | None = None):
288
+ super().__init__(parent)
289
+ self.setWindowTitle("Morphology — Preset")
290
+ p = dict(initial or {})
291
+ f = QFormLayout(self)
292
+
293
+ self.cb_op = QComboBox()
294
+ self.cb_op.addItems([op.title() for op in self.OPS])
295
+ op0 = str(p.get("operation", "erosion")).lower()
296
+ if op0 not in self.OPS:
297
+ op0 = "erosion"
298
+ self.cb_op.setCurrentText(op0.title())
299
+
300
+ self.sp_kernel = QSpinBox()
301
+ self.sp_kernel.setRange(1, 31)
302
+ self.sp_kernel.setSingleStep(2)
303
+ k = int(p.get("kernel", 3))
304
+ if k % 2 == 0:
305
+ k += 1
306
+ self.sp_kernel.setValue(k)
307
+
308
+ self.sp_iter = QSpinBox()
309
+ self.sp_iter.setRange(1, 10)
310
+ self.sp_iter.setValue(int(p.get("iterations", 1)))
311
+
312
+ f.addRow("Operation:", self.cb_op)
313
+ f.addRow("Kernel size:", self.sp_kernel)
314
+ f.addRow("Iterations:", self.sp_iter)
315
+
316
+ btns = QDialogButtonBox(
317
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
318
+ parent=self
319
+ )
320
+ btns.accepted.connect(self.accept)
321
+ btns.rejected.connect(self.reject)
322
+ f.addRow(btns)
323
+
324
+ def result_dict(self) -> dict:
325
+ k = int(self.sp_kernel.value())
326
+ if k % 2 == 0:
327
+ k += 1
328
+ return {
329
+ "operation": self.cb_op.currentText().lower(),
330
+ "kernel": int(k),
331
+ "iterations": int(self.sp_iter.value()),
332
+ }
333
+
334
+
335
+ # ---------------------- Headless runner (Scripts / Presets / Replay) ----------------------
336
+
337
+ def run_morphology_via_preset(main, preset: dict | None = None, *, target_doc=None):
338
+ """
339
+ Headless Morphology runner.
340
+
341
+ preset keys:
342
+ - operation: "erosion" | "dilation" | "opening" | "closing"
343
+ - kernel: odd int (default 3)
344
+ - iterations: int >= 1 (default 1)
345
+ """
346
+ p = dict(preset or {})
347
+
348
+ # ---- Remember for Replay ----
349
+ try:
350
+ remember = getattr(main, "_remember_last_headless_command", None) \
351
+ or getattr(main, "remember_last_headless_command", None)
352
+ if callable(remember):
353
+ remember("morphology", p, description="Morphology")
354
+ else:
355
+ setattr(main, "_last_headless_command", {
356
+ "command_id": "morphology",
357
+ "preset": dict(p),
358
+ })
359
+ except Exception:
360
+ pass
361
+ # ----------------------------
362
+
363
+ dm = getattr(main, "doc_manager", None) or getattr(main, "dm", None)
364
+
365
+ # Resolve doc
366
+ doc = target_doc
367
+ if doc is None:
368
+ d = getattr(main, "_active_doc", None)
369
+ doc = d() if callable(d) else d
370
+
371
+ if doc is None or getattr(doc, "image", None) is None:
372
+ QMessageBox.warning(main, "Morphology", "Load an image first.")
373
+ return
374
+
375
+ try:
376
+ apply_morphology_to_doc(doc, p)
377
+ if hasattr(main, "_log"):
378
+ main._log(f"✅ Morphology (headless) preset={p}")
379
+ except Exception as e:
380
+ QMessageBox.critical(main, "Morphology", str(e))
381
+ if hasattr(main, "_log"):
382
+ main._log(f"❌ Morphology failed: {e}")