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.
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,1413 @@
1
+ # ops/scripts.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import uuid
7
+ import traceback
8
+ import importlib.util
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Callable, Optional, Any
12
+ import numpy as np
13
+ from PyQt6.QtCore import QStandardPaths, QObject, QSettings, Qt
14
+ from PyQt6.QtGui import QAction, QDesktopServices, QCursor
15
+ from PyQt6.QtWidgets import QMessageBox
16
+ from PyQt6.QtCore import QUrl
17
+
18
+ from setiastro.saspro.ops.command_runner import run_command as _run_command
19
+
20
+ # -----------------------------------------------------------------------------
21
+ # Scripts folder (FIXED ROOT: SASpro/scripts)
22
+ # -----------------------------------------------------------------------------
23
+ def get_scripts_dir() -> Path:
24
+ """
25
+ Per-user scripts folder, pinned to a stable 'SASpro/scripts' root.
26
+
27
+ Windows: %LOCALAPPDATA%/SASpro/scripts
28
+ macOS: ~/Library/Application Support/SASpro/scripts
29
+ Linux: ~/.local/share/SASpro/scripts (or $XDG_DATA_HOME)
30
+
31
+ This intentionally does NOT use Qt's AppLocalDataLocation so it won't
32
+ land under SetiAstro/Seti Astro Suite Pro.
33
+ """
34
+ # Windows
35
+ if sys.platform.startswith("win"):
36
+ base = os.getenv("LOCALAPPDATA")
37
+ if base:
38
+ root = Path(base)
39
+ else:
40
+ root = Path.home() / "AppData" / "Local"
41
+ scripts = root / "SASpro" / "scripts"
42
+
43
+ # macOS
44
+ elif sys.platform == "darwin":
45
+ root = Path.home() / "Library" / "Application Support"
46
+ scripts = root / "SASpro" / "scripts"
47
+
48
+ # Linux / other
49
+ else:
50
+ root = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
51
+ scripts = root / "SASpro" / "scripts"
52
+
53
+ scripts.mkdir(parents=True, exist_ok=True)
54
+ return scripts
55
+
56
+ def migrate_old_scripts_if_needed():
57
+ """
58
+ One-time best-effort migration from the old Qt-derived folder into
59
+ the new SASpro/scripts folder.
60
+
61
+ Safe: only copies *.py that don't already exist in new location.
62
+ """
63
+ try:
64
+ new_dir = get_scripts_dir()
65
+
66
+ old_dirs: list[Path] = []
67
+
68
+ if sys.platform.startswith("win"):
69
+ old_dirs.append(
70
+ Path.home() / "AppData" / "Local" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
71
+ )
72
+ elif sys.platform == "darwin":
73
+ old_dirs.append(
74
+ Path.home() / "Library" / "Application Support" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
75
+ )
76
+ else:
77
+ old_dirs.append(
78
+ Path.home() / ".local" / "share" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
79
+ )
80
+
81
+ for old in old_dirs:
82
+ if not old.exists() or not old.is_dir():
83
+ continue
84
+ for p in old.glob("*.py"):
85
+ dest = new_dir / p.name
86
+ if not dest.exists():
87
+ try:
88
+ dest.write_text(p.read_text(encoding="utf-8"), encoding="utf-8")
89
+ except Exception:
90
+ # fallback binary copy if encoding chokes
91
+ import shutil
92
+ shutil.copy2(p, dest)
93
+
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ # -----------------------------------------------------------------------------
99
+ # Script context exposed to user scripts
100
+ # -----------------------------------------------------------------------------
101
+ class ScriptContext:
102
+ """
103
+ Minimal, stable API for user scripts.
104
+ Add helpers over time; try not to break existing ones.
105
+ """
106
+ def __init__(self, app_window, *, on_base: bool = False):
107
+ self.app = app_window
108
+ self._on_base = bool(on_base)
109
+
110
+ def main_window(self):
111
+ """Return the main SASpro window (stable helper for scripts)."""
112
+ return self.app
113
+
114
+ # ---- logging ----
115
+ def log(self, msg: str):
116
+ try:
117
+ self.app._log(f"[Script] {msg}")
118
+ except Exception:
119
+ print("[Script]", msg)
120
+
121
+ # ------------------------------------------------------------------
122
+ # File-based image I/O (canonical SASpro routes)
123
+ # ------------------------------------------------------------------
124
+ def load_image(self, filename: str, *, return_metadata: bool = False,
125
+ max_retries: int = 3, wait_seconds: int = 3):
126
+ """
127
+ Load an image from disk using SASpro's canonical loader.
128
+
129
+ This does NOT open or register a document or subwindow.
130
+ It is purely file I/O.
131
+
132
+ Returns:
133
+ If return_metadata=False (default):
134
+ (img, original_header, bit_depth, is_mono)
135
+ If return_metadata=True:
136
+ whatever legacy.image_manager.load_image returns in metadata mode
137
+ (typically includes image_meta/file_meta)
138
+ """
139
+ from setiastro.saspro.legacy import image_manager # canonical route
140
+ return image_manager.load_image(
141
+ filename,
142
+ max_retries=max_retries,
143
+ wait_seconds=wait_seconds,
144
+ return_metadata=bool(return_metadata),
145
+ )
146
+
147
+ def save_image(self, img_array, filename: str, *,
148
+ original_format: str | None = None,
149
+ bit_depth=None,
150
+ original_header=None,
151
+ is_mono: bool = False,
152
+ image_meta=None,
153
+ file_meta=None):
154
+ """
155
+ Save an image to disk using SASpro's canonical saver.
156
+
157
+ This does NOT require an open document.
158
+ It writes exactly through legacy.image_manager.save_image.
159
+
160
+ Args:
161
+ img_array: numpy array (mono or RGB). Any dtype accepted; saver handles it.
162
+ filename: output path
163
+ original_format: e.g. "fits", "tiff", "png". If None, inferred from suffix.
164
+ bit_depth/original_header/is_mono/image_meta/file_meta:
165
+ passed through to legacy saver.
166
+
167
+ Returns:
168
+ Whatever legacy.image_manager.save_image returns (often None or success flag).
169
+ """
170
+ from setiastro.saspro.legacy import image_manager # canonical route
171
+ from pathlib import Path
172
+
173
+ p = Path(filename)
174
+ fmt = original_format
175
+ if fmt is None or not str(fmt).strip():
176
+ # infer from extension (".fits", ".fit", ".fz", ".tif", ".tiff", ".png", etc.)
177
+ ext = p.suffix.lower().lstrip(".")
178
+ if ext in ("fit", "fits", "fz", "fits.gz", "fit.gz"):
179
+ fmt = "fits"
180
+ elif ext in ("tif", "tiff"):
181
+ fmt = "tiff"
182
+ else:
183
+ fmt = ext # png/jpg/x
184
+
185
+ return image_manager.save_image(
186
+ img_array,
187
+ str(p),
188
+ fmt,
189
+ bit_depth=bit_depth,
190
+ original_header=original_header,
191
+ is_mono=bool(is_mono),
192
+ image_meta=image_meta,
193
+ file_meta=file_meta,
194
+ )
195
+
196
+ # Friendly aliases (optional, but nice UX)
197
+ open_image = load_image
198
+ write_image = save_image
199
+
200
+ # ---- active view/doc access ----
201
+ def active_subwindow(self):
202
+ try:
203
+ return self.app.mdi.activeSubWindow()
204
+ except Exception:
205
+ return None
206
+
207
+ def active_view(self):
208
+ sw = self.active_subwindow()
209
+ return sw.widget() if sw else None
210
+
211
+ def base_document(self):
212
+ sw = self.active_subwindow()
213
+ if sw and hasattr(self.app, "_target_doc_from_subwindow"):
214
+ try:
215
+ return self.app._target_doc_from_subwindow(sw)
216
+ except Exception:
217
+ pass
218
+ return self.active_document(fallback_to_base=False)
219
+
220
+ def _docman(self):
221
+ return getattr(self.app, "doc_manager", None)
222
+
223
+ def active_document(self):
224
+ """
225
+ Normal run:
226
+ - return DocManager.get_active_document() so Preview tabs yield _RoiViewDocument.
227
+ Run-on-base:
228
+ - force base doc even if Preview is active.
229
+ """
230
+ dm = self._docman()
231
+
232
+ if dm and hasattr(dm, "get_active_document"):
233
+ if self._on_base:
234
+ # focused base is sticky and ignores ROI wrappers
235
+ base = None
236
+ try:
237
+ base = dm.get_focused_base_document()
238
+ except Exception:
239
+ base = None
240
+ return base or self.base_document()
241
+
242
+ # normal run: ROI-aware
243
+ try:
244
+ return dm.get_active_document()
245
+ except Exception:
246
+ pass
247
+
248
+ # fallback (should rarely happen)
249
+ view = self.active_view()
250
+ return getattr(view, "document", None) if view else None
251
+
252
+ def get_image(self):
253
+ doc = self.active_document()
254
+ return getattr(doc, "image", None) if doc else None
255
+
256
+ def set_image(self, img, step_name: str = "Script"):
257
+ dm = self._docman()
258
+ if dm is None:
259
+ raise RuntimeError("DocManager not available.")
260
+
261
+ img = np.asarray(img)
262
+ if img.dtype != np.float32:
263
+ img = img.astype(np.float32, copy=False)
264
+
265
+ if self._on_base:
266
+ # ✅ Bypass ROI branch: write to base doc directly
267
+ base_doc = None
268
+ try:
269
+ base_doc = dm.get_focused_base_document()
270
+ except Exception:
271
+ base_doc = None
272
+ base_doc = base_doc or self.base_document()
273
+
274
+ if base_doc is None:
275
+ raise RuntimeError("No base document to update.")
276
+
277
+ base_doc.apply_edit(img, metadata={}, step_name=step_name)
278
+
279
+ # force full repaint, including any preview
280
+ try:
281
+ dm.imageRegionUpdated.emit(base_doc, None)
282
+ except Exception:
283
+ pass
284
+
285
+ # if a preview is active, ask it to repaint too
286
+ try:
287
+ roi = dm._active_preview_roi() # returns (x,y,w,h) or None
288
+ if roi:
289
+ dm.previewRepaintRequested.emit(base_doc, roi)
290
+ except Exception:
291
+ pass
292
+ return
293
+
294
+ # ✅ Normal run: let DocManager decide (ROI preview vs full)
295
+ dm.update_active_document(img, metadata={}, step_name=step_name)
296
+
297
+ # ---- convenience wrappers into main window ----
298
+ def run_command(self, command_id: str, preset=None, **kwargs):
299
+ return _run_command(self, command_id, preset, **kwargs)
300
+
301
+ def is_frozen(self) -> bool:
302
+ return bool(getattr(sys, "frozen", False))
303
+
304
+ # ------------------------------------------------------------------
305
+ # View / document lookup helpers for scripts
306
+ # ------------------------------------------------------------------
307
+ def _iter_open_subwindows(self):
308
+ """Yield (subwindow, widget) for all open MDI subwindows."""
309
+ try:
310
+ mdi = getattr(self.app, "mdi", None)
311
+ if mdi is None:
312
+ return
313
+ for sw in mdi.subWindowList():
314
+ try:
315
+ w = sw.widget()
316
+ except Exception:
317
+ w = None
318
+ if w is not None:
319
+ yield sw, w
320
+ except Exception:
321
+ return
322
+
323
+ def _base_doc_for_widget(self, w):
324
+ """
325
+ Best-effort unwrap:
326
+ - ImageSubWindow.base_document / _base_document / document
327
+ - LiveViewDocument -> underlying base (_base)
328
+ - ROI wrapper -> parent
329
+ """
330
+ doc = (
331
+ getattr(w, "base_document", None)
332
+ or getattr(w, "_base_document", None)
333
+ or getattr(w, "document", None)
334
+ )
335
+ if doc is None:
336
+ return None
337
+
338
+ # LiveViewDocument exposes _base
339
+ base = getattr(doc, "_base", None)
340
+ if base is not None:
341
+ doc = base
342
+
343
+ # ROI wrapper -> parent base
344
+ parent = getattr(doc, "_parent_doc", None)
345
+ if parent is not None:
346
+ doc = parent
347
+
348
+ return doc
349
+
350
+ def list_views(self):
351
+ """
352
+ Return list of open views with stable info.
353
+ Each item:
354
+ {
355
+ "title": <window title>,
356
+ "name": <doc display name>,
357
+ "uid": <doc uid or None>,
358
+ "file_path": <metadata file_path or ''>,
359
+ "is_active": bool
360
+ }
361
+ """
362
+ out = []
363
+ active_sw = None
364
+ try:
365
+ active_sw = self.active_subwindow()
366
+ except Exception:
367
+ active_sw = None
368
+
369
+ for sw, w in self._iter_open_subwindows():
370
+ base_doc = self._base_doc_for_widget(w)
371
+ if base_doc is None:
372
+ continue
373
+
374
+ # titles / names
375
+ try:
376
+ title = str(sw.windowTitle() or "")
377
+ except Exception:
378
+ title = ""
379
+ try:
380
+ name = str(base_doc.display_name())
381
+ except Exception:
382
+ name = str(getattr(base_doc, "metadata", {}).get("display_name", title) or title)
383
+
384
+ uid = getattr(base_doc, "uid", None)
385
+ file_path = ""
386
+ try:
387
+ file_path = str(getattr(base_doc, "metadata", {}).get("file_path", "") or "")
388
+ except Exception:
389
+ pass
390
+
391
+ out.append({
392
+ "title": title,
393
+ "name": name,
394
+ "uid": uid,
395
+ "file_path": file_path,
396
+ "is_active": (sw is active_sw),
397
+ })
398
+ return out
399
+
400
+ def list_view_names(self):
401
+ """Convenience: return a list of human-visible names for open views."""
402
+ return [v["name"] or v["title"] for v in self.list_views()]
403
+
404
+ def get_document(self, view_name_or_uid: str, *, prefer_title: bool = False):
405
+ """
406
+ Look up an open document by:
407
+ - display name (doc.display_name())
408
+ - or window title (subwindow.windowTitle())
409
+ - or uid (exact)
410
+ Matching is case-insensitive for names/titles.
411
+ Returns base ImageDocument (never a ROI wrapper).
412
+ """
413
+ if not view_name_or_uid:
414
+ return None
415
+
416
+ key = str(view_name_or_uid).strip()
417
+ key_low = key.lower()
418
+
419
+ for sw, w in self._iter_open_subwindows():
420
+ base_doc = self._base_doc_for_widget(w)
421
+ if base_doc is None:
422
+ continue
423
+
424
+ uid = getattr(base_doc, "uid", None)
425
+ if uid is not None and str(uid) == key:
426
+ return base_doc
427
+
428
+ # compare names/titles
429
+ try:
430
+ doc_name = str(base_doc.display_name() or "").strip()
431
+ except Exception:
432
+ doc_name = str(getattr(base_doc, "metadata", {}).get("display_name", "") or "").strip()
433
+
434
+ try:
435
+ title = str(sw.windowTitle() or "").strip()
436
+ except Exception:
437
+ title = ""
438
+
439
+ if prefer_title:
440
+ if title and title.lower() == key_low:
441
+ return base_doc
442
+ if doc_name and doc_name.lower() == key_low:
443
+ return base_doc
444
+ else:
445
+ if doc_name and doc_name.lower() == key_low:
446
+ return base_doc
447
+ if title and title.lower() == key_low:
448
+ return base_doc
449
+
450
+ return None
451
+
452
+ def get_image_for(self, view_name_or_uid: str):
453
+ """Get image ndarray for a named/uid view (base doc)."""
454
+ doc = self.get_document(view_name_or_uid)
455
+ return getattr(doc, "image", None) if doc else None
456
+
457
+ def set_image_for(self, view_name_or_uid: str, img, step_name: str = "Script"):
458
+ """
459
+ Set image on a named/uid view (base doc), with undo + repaint.
460
+ This updates the full doc, not an ROI preview.
461
+ """
462
+ dm = self._docman()
463
+ if dm is None:
464
+ raise RuntimeError("DocManager not available.")
465
+
466
+ doc = self.get_document(view_name_or_uid)
467
+ if doc is None:
468
+ raise RuntimeError(f"No open view matches '{view_name_or_uid}'")
469
+
470
+ arr = np.asarray(img)
471
+ if arr.dtype != np.float32:
472
+ arr = arr.astype(np.float32, copy=False)
473
+
474
+ # Apply edit to that doc directly (full-image semantics)
475
+ doc.apply_edit(arr, metadata={}, step_name=step_name)
476
+
477
+ # Clear/invalidate any ROI caches for this base doc so previews don't stale
478
+ try:
479
+ dm._invalidate_roi_cache(doc, None)
480
+ except Exception:
481
+ pass
482
+
483
+ # Repaint any views showing this doc
484
+ try:
485
+ dm.imageRegionUpdated.emit(doc, None)
486
+ except Exception:
487
+ pass
488
+
489
+ def activate_view(self, view_name_or_uid: str) -> bool:
490
+ """
491
+ Bring a view to front by name/title/uid.
492
+ Returns True if activated.
493
+ """
494
+ key = str(view_name_or_uid).strip().lower()
495
+ mdi = getattr(self.app, "mdi", None)
496
+ if mdi is None:
497
+ return False
498
+
499
+ for sw, w in self._iter_open_subwindows():
500
+ base_doc = self._base_doc_for_widget(w)
501
+ if base_doc is None:
502
+ continue
503
+
504
+ uid = getattr(base_doc, "uid", None)
505
+ try:
506
+ doc_name = str(base_doc.display_name() or "").strip().lower()
507
+ except Exception:
508
+ doc_name = ""
509
+ try:
510
+ title = str(sw.windowTitle() or "").strip().lower()
511
+ except Exception:
512
+ title = ""
513
+
514
+ if (uid is not None and str(uid) == view_name_or_uid) or doc_name == key or title == key:
515
+ try:
516
+ mdi.setActiveSubWindow(sw)
517
+ except Exception:
518
+ pass
519
+ try:
520
+ sw.show()
521
+ sw.raise_()
522
+ except Exception:
523
+ pass
524
+ return True
525
+ return False
526
+
527
+ # ---- view enumeration / lookup by user-visible view name ----
528
+ def list_image_views(self):
529
+ """
530
+ Return a list of (view_title, doc) for all open image subwindows.
531
+ The title is the current MDI window title (what the user renamed it to).
532
+ """
533
+ out = []
534
+ mdi = getattr(self.app, "mdi", None)
535
+ if mdi is None:
536
+ return out
537
+
538
+ try:
539
+ subwins = mdi.subWindowList()
540
+ except Exception:
541
+ subwins = []
542
+
543
+ for sw in subwins:
544
+ try:
545
+ w = sw.widget()
546
+ except Exception:
547
+ continue
548
+
549
+ doc = (
550
+ getattr(w, "document", None)
551
+ or getattr(w, "base_document", None)
552
+ or getattr(w, "_base_document", None)
553
+ )
554
+ if doc is None or getattr(doc, "image", None) is None:
555
+ continue
556
+
557
+ try:
558
+ title = sw.windowTitle() or ""
559
+ except Exception:
560
+ title = ""
561
+
562
+ if not title:
563
+ # fallback to doc display name if window title missing
564
+ try:
565
+ title = doc.display_name()
566
+ except Exception:
567
+ title = "Untitled"
568
+
569
+ out.append((title, doc))
570
+
571
+ return out
572
+
573
+ def get_document_by_view_name(self, name: str):
574
+ """
575
+ Find the first open image doc whose *view title* matches name.
576
+ Matching is case-insensitive; exact match preferred, else unique prefix.
577
+ """
578
+ name_l = (name or "").strip().lower()
579
+ if not name_l:
580
+ return None
581
+
582
+ views = self.list_image_views()
583
+
584
+ # exact match
585
+ for title, doc in views:
586
+ if title.strip().lower() == name_l:
587
+ return doc
588
+
589
+ # unique prefix match
590
+ pref = [(t, d) for (t, d) in views if t.strip().lower().startswith(name_l)]
591
+ if len(pref) == 1:
592
+ return pref[0][1]
593
+
594
+ return None
595
+
596
+ def get_image_by_view_name(self, name: str):
597
+ doc = self.get_document_by_view_name(name)
598
+ return getattr(doc, "image", None) if doc else None
599
+
600
+ def open_new_document(self, img, metadata=None, name: str | None = None):
601
+ """
602
+ Convenience for scripts: create/register a new ImageDocument from an array.
603
+ """
604
+ dm = self._docman()
605
+ if dm is None:
606
+ raise RuntimeError("DocManager not available.")
607
+ return dm.open_array(np.asarray(img, dtype=np.float32), metadata=metadata, title=name)
608
+
609
+
610
+ # -----------------------------------------------------------------------------
611
+ # Script registry entries
612
+ # -----------------------------------------------------------------------------
613
+ @dataclass
614
+ class ScriptEntry:
615
+ script_id: str # NEW
616
+ path: Path
617
+ name: str
618
+ group: str = ""
619
+ shortcut: Optional[str] = None # default shortcut from script
620
+ module: Any = None
621
+ run: Optional[Callable[[ScriptContext], None]] = None
622
+
623
+
624
+
625
+ # -----------------------------------------------------------------------------
626
+ # Script manager
627
+ # -----------------------------------------------------------------------------
628
+ class ScriptManager(QObject):
629
+ """
630
+ Owns script discovery/loading and menu binding.
631
+ Main window delegates to this.
632
+ """
633
+ def __init__(self, app_window):
634
+ super().__init__(app_window)
635
+ self.app = app_window
636
+ self.registry: list[ScriptEntry] = []
637
+
638
+ # ---- internal log ----
639
+ def _log(self, msg: str):
640
+ try:
641
+ self.app._log(msg)
642
+ except Exception:
643
+ print(msg)
644
+
645
+ # ---- loading ----
646
+ def load_registry(self):
647
+ migrate_old_scripts_if_needed()
648
+ scripts_dir = get_scripts_dir()
649
+ self.registry = []
650
+
651
+ for path in sorted(scripts_dir.glob("*.py")):
652
+ try:
653
+ entry = self._load_one_script(path)
654
+ if entry:
655
+ self.registry.append(entry)
656
+ except Exception:
657
+ self._log(f"[Scripts] Failed to load {path.name}:\n{traceback.format_exc()}")
658
+
659
+ self._log(f"[Scripts] Loaded {len(self.registry)} script(s) from {scripts_dir}")
660
+
661
+ def _load_one_script(self, path: Path) -> ScriptEntry | None:
662
+ # Make a unique module name so reload actually reloads
663
+ try:
664
+ mtime_ns = path.stat().st_mtime_ns
665
+ except Exception:
666
+ mtime_ns = 0
667
+ module_name = f"saspro_user_script_{path.stem}_{mtime_ns}"
668
+
669
+ spec = importlib.util.spec_from_file_location(module_name, path)
670
+ if not spec or not spec.loader:
671
+ return None
672
+
673
+ mod = importlib.util.module_from_spec(spec)
674
+ script_id = self._script_id_for_path(path, mod)
675
+ try:
676
+ spec.loader.exec_module(mod) # type: ignore
677
+ except Exception:
678
+ self._log(f"[Scripts] Error importing {path.name}:\n{traceback.format_exc()}")
679
+ return None
680
+
681
+ # ---- entrypoint: allow run(ctx) OR main(ctx) ----
682
+ run_func = getattr(mod, "run", None)
683
+ if not callable(run_func):
684
+ run_func = getattr(mod, "main", None)
685
+
686
+ if not callable(run_func):
687
+ self._log(f"[Scripts] {path.name} has no run(ctx) or main(ctx); skipping.")
688
+ return None
689
+
690
+ # ---- metadata: allow CAPS or lowercase ----
691
+ def _pick(*names, default=None):
692
+ for n in names:
693
+ if hasattr(mod, n):
694
+ return getattr(mod, n)
695
+ return default
696
+
697
+ name = _pick("SCRIPT_NAME", "script_name", default=path.stem)
698
+ group = _pick("SCRIPT_GROUP", "script_group", default="")
699
+ shortcut = _pick("SCRIPT_SHORTCUT", "script_shortcut", default=None)
700
+
701
+ entry = ScriptEntry(
702
+ script_id=script_id,
703
+ path=path,
704
+ name=str(name),
705
+ group=str(group or ""),
706
+ shortcut=str(shortcut) if shortcut else None,
707
+ module=mod,
708
+ run=run_func,
709
+ )
710
+ return entry
711
+
712
+
713
+ # ---- menu wiring ----
714
+ def rebuild_menu(self, menu_scripts):
715
+ """
716
+ Clears and rebuilds the Scripts menu from registry.
717
+
718
+ Expects base actions already created on app window:
719
+ act_script_editor, act_open_scripts_folder, act_reload_scripts, act_create_sample_script
720
+ (optionally) act_open_user_scripts_github, act_open_scripts_discord
721
+
722
+ Integrates scripts into ShortcutManager using command ids:
723
+ "script:<script_id>"
724
+
725
+ Also adds "Pin Script to Canvas" submenu to create desktop shortcuts for scripts.
726
+ """
727
+ from typing import Any
728
+ from PyQt6.QtCore import Qt, QPoint
729
+ from PyQt6.QtGui import QAction, QCursor
730
+
731
+ menu_scripts.clear()
732
+
733
+ # --- fixed top actions ---
734
+ if getattr(self.app, "act_script_editor", None):
735
+ menu_scripts.addAction(self.app.act_script_editor)
736
+ menu_scripts.addSeparator()
737
+
738
+ if getattr(self.app, "act_open_user_scripts_github", None):
739
+ menu_scripts.addAction(self.app.act_open_user_scripts_github)
740
+ if getattr(self.app, "act_open_scripts_discord", None):
741
+ menu_scripts.addAction(self.app.act_open_scripts_discord)
742
+
743
+ menu_scripts.addSeparator()
744
+
745
+ if getattr(self.app, "act_open_scripts_folder", None):
746
+ menu_scripts.addAction(self.app.act_open_scripts_folder)
747
+ if getattr(self.app, "act_reload_scripts", None):
748
+ menu_scripts.addAction(self.app.act_reload_scripts)
749
+ if getattr(self.app, "act_create_sample_script", None):
750
+ menu_scripts.addAction(self.app.act_create_sample_script)
751
+
752
+ menu_scripts.addSeparator()
753
+
754
+ # ShortcutManager (optional)
755
+ sc = getattr(self.app, "shortcuts", None)
756
+ can_register = callable(getattr(sc, "register_action", None))
757
+ can_add_sc = callable(getattr(sc, "add_shortcut", None))
758
+
759
+ # Helper: pin a command id to canvas at cursor pos
760
+ def _pin_to_canvas(cmdid: str):
761
+ if not (sc and can_add_sc):
762
+ return
763
+ mdi = getattr(self.app, "mdi", None)
764
+ if mdi is None:
765
+ return
766
+ vp = mdi.viewport()
767
+ if vp is None:
768
+ return
769
+
770
+ pos = vp.mapFromGlobal(QCursor.pos())
771
+ if not vp.rect().contains(pos):
772
+ pos = vp.rect().center()
773
+
774
+ try:
775
+ sc.add_shortcut(cmdid, QPoint(int(pos.x()), int(pos.y())))
776
+ except Exception:
777
+ pass
778
+
779
+ # "Pin Script to Canvas" submenu (grouped)
780
+ pin_root = menu_scripts.addMenu("Pin Script to Canvas")
781
+ pin_group_menus: dict[str, Any] = {}
782
+
783
+ # group -> submenu for run items
784
+ group_menus: dict[str, Any] = {}
785
+
786
+ for entry in self.registry:
787
+ script_id = getattr(entry, "script_id", None)
788
+ if not script_id:
789
+ # If a script entry has no id, we can still show it in the menu,
790
+ # but it can't be pinned/registered reliably.
791
+ cmdid = None
792
+ else:
793
+ cmdid = f"script:{script_id}"
794
+
795
+ group = (entry.group or "").strip()
796
+
797
+ # ---- RUN menu placement ----
798
+ if group:
799
+ run_sub = group_menus.get(group)
800
+ if run_sub is None:
801
+ run_sub = menu_scripts.addMenu(group)
802
+ group_menus[group] = run_sub
803
+ target_menu = run_sub
804
+ else:
805
+ target_menu = menu_scripts
806
+
807
+ from PyQt6.QtGui import QIcon
808
+ from setiastro.saspro.resources import get_icons
809
+
810
+ icons = get_icons()
811
+
812
+ act = QAction(entry.name, self.app)
813
+ act.setIcon(QIcon(icons.SCRIPT)) # NEW
814
+
815
+ # IMPORTANT: make shortcuts/global binds work regardless of focus
816
+ act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
817
+
818
+ # store command_id on the action if we have one
819
+ if cmdid:
820
+ act.setProperty("command_id", cmdid)
821
+
822
+ # Default shortcut from script metadata (optional)
823
+ if getattr(entry, "shortcut", None):
824
+ try:
825
+ act.setShortcut(entry.shortcut)
826
+ except Exception:
827
+ pass
828
+
829
+ # Register with ShortcutManager so persisted overrides can apply
830
+ if cmdid and can_register:
831
+ try:
832
+ sc.register_action(cmdid, act)
833
+ except Exception:
834
+ pass
835
+
836
+ act.triggered.connect(lambda _=False, e=entry: self.run_entry(e))
837
+ target_menu.addAction(act)
838
+
839
+ # ---- PIN menu placement ----
840
+ # Only add to pin menu if we have a stable cmdid
841
+ if cmdid:
842
+ if group:
843
+ pin_sub = pin_group_menus.get(group)
844
+ if pin_sub is None:
845
+ pin_sub = pin_root.addMenu(group)
846
+ pin_group_menus[group] = pin_sub
847
+ pin_menu = pin_sub
848
+ else:
849
+ pin_menu = pin_root
850
+
851
+ act_pin = QAction(entry.name, self.app)
852
+ act_pin.setIcon(QIcon(icons.SCRIPT)) # NEW
853
+ act_pin.triggered.connect(lambda _=False, c=cmdid: _pin_to_canvas(c))
854
+ pin_menu.addAction(act_pin)
855
+
856
+ # If there are no pinnable scripts, disable the pin root nicely
857
+ if pin_root.actions() == []:
858
+ a = pin_root.addAction("No scripts to pin")
859
+ a.setEnabled(False)
860
+
861
+
862
+ def _script_command_id(self, entry: ScriptEntry, *, on_base: bool = False) -> str:
863
+ # Keep it stable and unique. Path is perfect because scripts are per-user.
864
+ p = entry.path.as_posix()
865
+ return f"script:{'base:' if on_base else ''}{p}"
866
+
867
+ def _pin_command_to_canvas(self, command_id: str):
868
+ mgr = getattr(self.app, "shortcuts", None)
869
+ mdi = getattr(self.app, "mdi", None)
870
+ if mgr is None or mdi is None:
871
+ return
872
+
873
+ vp = mdi.viewport()
874
+ pos = vp.mapFromGlobal(QCursor.pos())
875
+ if not vp.rect().contains(pos):
876
+ pos = vp.rect().center()
877
+
878
+ mgr.add_shortcut(command_id, pos)
879
+
880
+ # ---- running ----
881
+ def run_entry(self, entry: ScriptEntry, *, on_base: bool = False):
882
+ ctx = ScriptContext(self.app, on_base=on_base)
883
+ try:
884
+ self._log(f"[Scripts] Running '{entry.name}' ({entry.path.name}) on_base={on_base}")
885
+ entry.run(ctx) # type: ignore
886
+ self._log(f"[Scripts] Finished '{entry.name}'")
887
+ except Exception as e:
888
+ tb = traceback.format_exc()
889
+ self._log(f"[Scripts] ERROR in '{entry.name}':\n{tb}")
890
+ try:
891
+ QMessageBox.critical(self.app, "Script Error",
892
+ f"{entry.name} failed:\n\n{e}")
893
+ except Exception:
894
+ pass
895
+
896
+
897
+ # ---- convenience actions ----
898
+ def open_scripts_folder(self):
899
+ folder = get_scripts_dir()
900
+ try:
901
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder)))
902
+ except Exception:
903
+ # OS fallback
904
+ try:
905
+ if sys.platform.startswith("win"):
906
+ os.startfile(folder) # type: ignore
907
+ elif sys.platform == "darwin":
908
+ os.system(f'open "{folder}"')
909
+ else:
910
+ os.system(f'xdg-open "{folder}"')
911
+ except Exception:
912
+ self._log(f"[Scripts] Couldn't open scripts folder: {folder}")
913
+
914
+ def create_sample_script(self):
915
+ folder = get_scripts_dir()
916
+
917
+ samples: dict[str, str] = {}
918
+
919
+ # ------------------------------------------------------------------
920
+ # 1) sample_invert.py (existing)
921
+ # ------------------------------------------------------------------
922
+ samples["sample_invert.py"] = """\
923
+ # Sample SASpro script
924
+ # Put scripts in this folder; they appear in Scripts menu.
925
+ # Required entrypoint:
926
+ # def run(ctx):
927
+ # ...
928
+
929
+ SCRIPT_NAME = "Invert Image (Sample)"
930
+ SCRIPT_GROUP = "Samples"
931
+
932
+ import numpy as np
933
+
934
+ def run(ctx):
935
+ img = ctx.get_image()
936
+ if img is None:
937
+ ctx.log("No active image.")
938
+ return
939
+
940
+ ctx.log(f"Inverting image... shape={img.shape}, dtype={img.dtype}")
941
+
942
+ f = img.astype(np.float32)
943
+ mx = float(np.nanmax(f)) if f.size else 1.0
944
+ if mx > 1.0:
945
+ f = f / mx
946
+ f = np.clip(f, 0.0, 1.0)
947
+
948
+ out = 1.0 - f
949
+ ctx.set_image(out, step_name="Invert via Script")
950
+ ctx.log("Done.")
951
+ """
952
+
953
+ # ------------------------------------------------------------------
954
+ # 2) sample_star_preview_ui.py (SEP demo)
955
+ # ------------------------------------------------------------------
956
+ samples["sample_star_preview_ui.py"] = """\
957
+ from __future__ import annotations
958
+
959
+ # =========================
960
+ # SASpro Script Metadata
961
+ # =========================
962
+ SCRIPT_NAME = "Star Preview UI (SEP Demo)"
963
+ SCRIPT_GROUP = "Samples"
964
+ SCRIPT_SHORTCUT = "" # optional
965
+
966
+ # -------------------------
967
+ # Star Preview UI sample
968
+ # -------------------------
969
+
970
+ import numpy as np
971
+
972
+ from PyQt6.QtCore import Qt, QTimer
973
+ from PyQt6.QtGui import QImage, QPixmap
974
+ from PyQt6.QtWidgets import (
975
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
976
+ QSlider, QCheckBox, QMessageBox, QApplication, QWidget
977
+ )
978
+
979
+ # your libs already bundled in SASpro
980
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
981
+ from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
982
+
983
+ # (optional) for applying result back to active doc
984
+ from setiastro.saspro.whitebalance import apply_white_balance_to_doc
985
+
986
+ # Shared utilities
987
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
988
+
989
+
990
+ class StarPreviewDialog(QDialog):
991
+ \"""
992
+ Sample script UI:
993
+ - Shows active image (auto-updates when subwindow changes)
994
+ - Runs SEP detection + ellipse overlay
995
+ - Zoom controls + Fit/1:1
996
+ - Demo Apply WB to active image
997
+ \"""
998
+ def __init__(self, ctx, parent: QWidget | None = None):
999
+ super().__init__(parent)
1000
+ self.ctx = ctx
1001
+ self.setWindowTitle("Sample Script: Star Preview UI")
1002
+ self.resize(980, 640)
1003
+
1004
+ self._zoom = 1.0
1005
+ self._img01: np.ndarray | None = None
1006
+ self._overlay01: np.ndarray | None = None
1007
+
1008
+ self._build_ui()
1009
+ self._wire()
1010
+
1011
+ # debounce for slider/checkbox
1012
+ self._debounce = QTimer(self)
1013
+ self._debounce.setSingleShot(True)
1014
+ self._debounce.setInterval(500)
1015
+ self._debounce.timeout.connect(self._rebuild_overlay)
1016
+
1017
+ # watch active base doc so preview isn't blank
1018
+ try:
1019
+ dm = getattr(self.ctx.app, "doc_manager", None)
1020
+ if dm is not None and hasattr(dm, "activeBaseChanged"):
1021
+ dm.activeBaseChanged.connect(lambda _=None: self._load_active_image())
1022
+ except Exception:
1023
+ pass
1024
+
1025
+ # initial load
1026
+ QTimer.singleShot(0, self._load_active_image)
1027
+
1028
+ # ---------------- UI ----------------
1029
+ def _build_ui(self):
1030
+ root = QVBoxLayout(self)
1031
+
1032
+ self.preview = QLabel("No active image.")
1033
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
1034
+ self.preview.setStyleSheet("border: 1px solid #333; background:#1f1f1f;")
1035
+ self.preview.setMinimumSize(720, 420)
1036
+ root.addWidget(self.preview, stretch=1)
1037
+
1038
+ # Zoom bar
1039
+ zrow = QHBoxLayout()
1040
+ self.btn_zoom_in = QPushButton("Zoom +")
1041
+ self.btn_zoom_out = QPushButton("Zoom −")
1042
+ self.btn_fit = QPushButton("Fit")
1043
+ self.btn_1to1 = QPushButton("1:1")
1044
+ zrow.addWidget(self.btn_zoom_in)
1045
+ zrow.addWidget(self.btn_zoom_out)
1046
+ zrow.addWidget(self.btn_fit)
1047
+ zrow.addWidget(self.btn_1to1)
1048
+ zrow.addStretch(1)
1049
+ root.addLayout(zrow)
1050
+
1051
+ # SEP controls
1052
+ ctrl = QHBoxLayout()
1053
+ ctrl.addWidget(QLabel("SEP threshold (σ):"))
1054
+ self.thr_slider = QSlider(Qt.Orientation.Horizontal)
1055
+ self.thr_slider.setRange(1, 100)
1056
+ self.thr_slider.setValue(50)
1057
+ self.thr_slider.setTickInterval(10)
1058
+ self.thr_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
1059
+ ctrl.addWidget(self.thr_slider, stretch=1)
1060
+
1061
+ self.thr_label = QLabel("50")
1062
+ self.thr_label.setFixedWidth(30)
1063
+ ctrl.addWidget(self.thr_label)
1064
+
1065
+ self.chk_autostretch = QCheckBox("Autostretch preview")
1066
+ self.chk_autostretch.setChecked(True)
1067
+ ctrl.addWidget(self.chk_autostretch)
1068
+
1069
+ root.addLayout(ctrl)
1070
+
1071
+ # bottom buttons
1072
+ brow = QHBoxLayout()
1073
+ brow.addStretch(1)
1074
+ self.btn_apply_demo = QPushButton("Apply WB to Active Image (demo)")
1075
+ self.btn_close = QPushButton("Close")
1076
+ brow.addWidget(self.btn_apply_demo)
1077
+ brow.addWidget(self.btn_close)
1078
+ root.addLayout(brow)
1079
+
1080
+ def _wire(self):
1081
+ self.btn_close.clicked.connect(self.reject)
1082
+
1083
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
1084
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
1085
+ self.btn_fit.clicked.connect(self._zoom_fit)
1086
+ self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
1087
+
1088
+ self.thr_slider.valueChanged.connect(self._on_thr_changed)
1089
+ self.chk_autostretch.toggled.connect(lambda _=None: self._debounce.start())
1090
+
1091
+ self.btn_apply_demo.clicked.connect(self._apply_demo_wb)
1092
+
1093
+ # ------------- Active image -------------
1094
+ def _load_active_image(self):
1095
+ try:
1096
+ doc = self.ctx.active_document()
1097
+ except Exception:
1098
+ doc = None
1099
+
1100
+ if doc is None or getattr(doc, "image", None) is None:
1101
+ self._img01 = None
1102
+ self._overlay01 = None
1103
+ self.preview.setText("No active image.")
1104
+ self.preview.setPixmap(QPixmap())
1105
+ return
1106
+
1107
+ img = _to_float01(np.asarray(doc.image))
1108
+ self._img01 = img
1109
+ self._zoom_fit()
1110
+ self._rebuild_overlay()
1111
+
1112
+ # ------------- SEP overlay -------------
1113
+ def _on_thr_changed(self, v: int):
1114
+ self.thr_label.setText(str(v))
1115
+ self._debounce.start()
1116
+
1117
+ def _rebuild_overlay(self):
1118
+ if self._img01 is None:
1119
+ return
1120
+ try:
1121
+ thr = float(self.thr_slider.value())
1122
+ auto = bool(self.chk_autostretch.isChecked())
1123
+
1124
+ img = self._img01
1125
+ # if mono, make a fake RGB for visualization / SEP expects gray anyway
1126
+ if img.ndim == 2:
1127
+ rgb = np.repeat(img[..., None], 3, axis=2)
1128
+ elif img.ndim == 3 and img.shape[2] == 1:
1129
+ rgb = np.repeat(img, 3, axis=2)
1130
+ else:
1131
+ rgb = img
1132
+
1133
+ # Use your WB star detector just for overlay
1134
+ # (balanced output ignored; we only want overlay + count)
1135
+ _balanced, count, overlay = apply_star_based_white_balance(
1136
+ rgb, threshold=thr, autostretch=auto,
1137
+ reuse_cached_sources=False, return_star_colors=False
1138
+ )
1139
+
1140
+ self._overlay01 = overlay
1141
+ self._render_pixmap()
1142
+ self.setWindowTitle(f"Sample Script: Star Preview UI — {count} stars")
1143
+
1144
+ except Exception as e:
1145
+ self._overlay01 = None
1146
+ self.preview.setText(f"Star detection failed:\\n{e}")
1147
+
1148
+ # ------------- Rendering / zoom -------------
1149
+ def _render_pixmap(self):
1150
+ if self._overlay01 is None:
1151
+ return
1152
+ ov = np.clip(self._overlay01, 0, 1)
1153
+ h, w, c = ov.shape
1154
+ qimg = QImage((ov * 255).astype(np.uint8).data, w, h, 3*w, QImage.Format.Format_RGB888)
1155
+ pm = QPixmap.fromImage(qimg)
1156
+
1157
+ # apply zoom
1158
+ zw = int(pm.width() * self._zoom)
1159
+ zh = int(pm.height() * self._zoom)
1160
+ pmz = pm.scaled(zw, zh, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
1161
+ self.preview.setPixmap(pmz)
1162
+
1163
+ def _set_zoom(self, z: float):
1164
+ self._zoom = float(np.clip(z, 0.05, 20.0))
1165
+ self._render_pixmap()
1166
+
1167
+ def _zoom_fit(self):
1168
+ if self._overlay01 is None and self._img01 is None:
1169
+ return
1170
+ # fit based on raw image size
1171
+ base = self._overlay01 if self._overlay01 is not None else self._img01
1172
+ h, w = base.shape[:2]
1173
+ vw = max(1, self.preview.width())
1174
+ vh = max(1, self.preview.height())
1175
+ self._zoom = min(vw / w, vh / h)
1176
+ self._render_pixmap()
1177
+
1178
+ # ------------- Demo apply -------------
1179
+ def _apply_demo_wb(self):
1180
+ try:
1181
+ doc = self.ctx.active_document()
1182
+ if doc is None:
1183
+ raise RuntimeError("No active document.")
1184
+ # Reuse your headless preset WB as an example of applying edits
1185
+ preset = {"mode": "star", "threshold": float(self.thr_slider.value())}
1186
+ apply_white_balance_to_doc(doc, preset)
1187
+ QMessageBox.information(self, "Demo", "White Balance applied to active image.")
1188
+ # refresh preview after edit
1189
+ self._load_active_image()
1190
+ except Exception as e:
1191
+ QMessageBox.critical(self, "Demo", f"Failed to apply WB:\\n{e}")
1192
+
1193
+
1194
+ def run(ctx):
1195
+ \"""
1196
+ SASpro entry point.
1197
+ \"""
1198
+ w = StarPreviewDialog(ctx, parent=ctx.app)
1199
+ w.exec()
1200
+ """
1201
+
1202
+ # ------------------------------------------------------------------
1203
+ # 3) sample_average_two_docs_ui.py (NEW)
1204
+ # ------------------------------------------------------------------
1205
+ samples["sample_average_two_docs_ui.py"] = """\
1206
+ # Sample SASpro script
1207
+ # UI with two dropdowns listing open views by their CURRENT window titles.
1208
+ # Averages the two selected documents and opens a new document.
1209
+
1210
+ from __future__ import annotations
1211
+
1212
+ SCRIPT_NAME = "Average Two Documents (UI Sample)"
1213
+ SCRIPT_GROUP = "Samples"
1214
+
1215
+ import numpy as np
1216
+
1217
+ from PyQt6.QtWidgets import (
1218
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
1219
+ QPushButton, QMessageBox
1220
+ )
1221
+
1222
+
1223
+ class AverageTwoDocsDialog(QDialog):
1224
+ def __init__(self, ctx):
1225
+ super().__init__(parent=ctx.app)
1226
+ self.ctx = ctx
1227
+ self.setWindowTitle("Average Two Documents")
1228
+ self.resize(520, 180)
1229
+
1230
+ self._title_to_doc = {}
1231
+
1232
+ root = QVBoxLayout(self)
1233
+
1234
+ # Row A
1235
+ row_a = QHBoxLayout()
1236
+ row_a.addWidget(QLabel("Document A:"))
1237
+ self.combo_a = QComboBox()
1238
+ row_a.addWidget(self.combo_a, 1)
1239
+ root.addLayout(row_a)
1240
+
1241
+ # Row B
1242
+ row_b = QHBoxLayout()
1243
+ row_b.addWidget(QLabel("Document B:"))
1244
+ self.combo_b = QComboBox()
1245
+ row_b.addWidget(self.combo_b, 1)
1246
+ root.addLayout(row_b)
1247
+
1248
+ # Buttons
1249
+ brow = QHBoxLayout()
1250
+ self.btn_refresh = QPushButton("Refresh List")
1251
+ self.btn_avg = QPushButton("Average → New Doc")
1252
+ self.btn_close = QPushButton("Close")
1253
+ brow.addStretch(1)
1254
+ brow.addWidget(self.btn_refresh)
1255
+ brow.addWidget(self.btn_avg)
1256
+ brow.addWidget(self.btn_close)
1257
+ root.addLayout(brow)
1258
+
1259
+ self.btn_refresh.clicked.connect(self._populate)
1260
+ self.btn_avg.clicked.connect(self._do_average)
1261
+ self.btn_close.clicked.connect(self.reject)
1262
+
1263
+ self._populate()
1264
+
1265
+ def _populate(self):
1266
+ self.combo_a.clear()
1267
+ self.combo_b.clear()
1268
+ self._title_to_doc.clear()
1269
+
1270
+ try:
1271
+ views = self.ctx.list_image_views()
1272
+ except Exception:
1273
+ views = []
1274
+
1275
+ for title, doc in views:
1276
+ # if duplicate names exist, disambiguate slightly
1277
+ key = title
1278
+ if key in self._title_to_doc:
1279
+ # add uid or a counter suffix
1280
+ try:
1281
+ uid = getattr(doc, "uid", "")[:6]
1282
+ key = f"{title} [{uid}]"
1283
+ except Exception:
1284
+ n = 2
1285
+ while f"{title} ({n})" in self._title_to_doc:
1286
+ n += 1
1287
+ key = f"{title} ({n})"
1288
+
1289
+ self._title_to_doc[key] = doc
1290
+ self.combo_a.addItem(key)
1291
+ self.combo_b.addItem(key)
1292
+
1293
+ if self.combo_a.count() == 0:
1294
+ self.combo_a.addItem("<no image views>")
1295
+ self.combo_b.addItem("<no image views>")
1296
+ self.btn_avg.setEnabled(False)
1297
+ else:
1298
+ self.btn_avg.setEnabled(True)
1299
+
1300
+ def _do_average(self):
1301
+ key_a = self.combo_a.currentText()
1302
+ key_b = self.combo_b.currentText()
1303
+
1304
+ doc_a = self._title_to_doc.get(key_a)
1305
+ doc_b = self._title_to_doc.get(key_b)
1306
+
1307
+ if doc_a is None or doc_b is None:
1308
+ QMessageBox.warning(self, "Average", "Please select two valid documents.")
1309
+ return
1310
+
1311
+ img_a = getattr(doc_a, "image", None)
1312
+ img_b = getattr(doc_b, "image", None)
1313
+
1314
+ if img_a is None or img_b is None:
1315
+ QMessageBox.warning(self, "Average", "One of the selected documents has no image.")
1316
+ return
1317
+
1318
+ a = np.asarray(img_a, dtype=np.float32)
1319
+ b = np.asarray(img_b, dtype=np.float32)
1320
+
1321
+ # reconcile mono/color
1322
+ if a.ndim == 2:
1323
+ a = a[..., None]
1324
+ if b.ndim == 2:
1325
+ b = b[..., None]
1326
+ if a.shape[2] == 1 and b.shape[2] == 3:
1327
+ a = np.repeat(a, 3, axis=2)
1328
+ if b.shape[2] == 1 and a.shape[2] == 3:
1329
+ b = np.repeat(b, 3, axis=2)
1330
+
1331
+ if a.shape != b.shape:
1332
+ QMessageBox.warning(
1333
+ self, "Average",
1334
+ f"Shape mismatch:\\nA: {a.shape}\\nB: {b.shape}\\n\\n"
1335
+ "For this sample, images must match exactly."
1336
+ )
1337
+ return
1338
+
1339
+ out = 0.5 * (a + b)
1340
+
1341
+ # name the new doc based on view titles
1342
+ new_name = f"Average({key_a}, {key_b})"
1343
+
1344
+ try:
1345
+ self.ctx.open_new_document(out, metadata={}, name=new_name)
1346
+ QMessageBox.information(self, "Average", f"Created new document:\\n{new_name}")
1347
+ except Exception as e:
1348
+ QMessageBox.critical(self, "Average", f"Failed to create new doc:\\n{e}")
1349
+
1350
+
1351
+ def run(ctx):
1352
+ dlg = AverageTwoDocsDialog(ctx)
1353
+ dlg.exec()
1354
+ """
1355
+
1356
+ created = []
1357
+ skipped = []
1358
+
1359
+ for fname, text in samples.items():
1360
+ path = folder / fname
1361
+ if path.exists():
1362
+ skipped.append(fname)
1363
+ continue
1364
+ try:
1365
+ path.write_text(text, encoding="utf-8")
1366
+ created.append(fname)
1367
+ self._log(f"[Scripts] Wrote sample script: {path}")
1368
+ except Exception:
1369
+ self._log(f"[Scripts] Failed to write {fname}:\n{traceback.format_exc()}")
1370
+
1371
+ # user message
1372
+ try:
1373
+ if created and not skipped:
1374
+ QMessageBox.information(
1375
+ self.app, "Sample Scripts Created",
1376
+ "Created sample scripts:\n\n" + "\n".join(created) +
1377
+ "\n\nReload Scripts to see them."
1378
+ )
1379
+ elif created and skipped:
1380
+ QMessageBox.information(
1381
+ self.app, "Sample Scripts Created",
1382
+ "Created:\n" + "\n".join(created) +
1383
+ "\n\nAlready existed:\n" + "\n".join(skipped) +
1384
+ "\n\nReload Scripts to see new ones."
1385
+ )
1386
+ else:
1387
+ QMessageBox.information(
1388
+ self.app, "Sample Scripts",
1389
+ "All sample scripts already exist:\n\n" + "\n".join(skipped)
1390
+ )
1391
+ except Exception:
1392
+ pass
1393
+
1394
+ self._log(f"[Scripts] Failed to write sample script:\n{traceback.format_exc()}")
1395
+
1396
+
1397
+ def _script_id_for_path(self, path: Path, mod) -> str:
1398
+ # 1) Prefer explicit SCRIPT_ID in the script file (best, survives renames)
1399
+ sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
1400
+ if isinstance(sid, str) and sid.strip():
1401
+ return sid.strip()
1402
+
1403
+ # 2) Fallback: persist per-path in QSettings (ok for existing scripts)
1404
+ s = QSettings()
1405
+ key = f"Scripts/ids/{str(path)}"
1406
+ sid = s.value(key, "", type=str) or ""
1407
+ if sid:
1408
+ return sid
1409
+
1410
+ sid = uuid.uuid4().hex
1411
+ s.setValue(key, sid)
1412
+ s.sync()
1413
+ return sid