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,309 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+ import cv2
4
+ from typing import Optional
5
+
6
+ from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
7
+ from setiastro.saspro.ops.command_runner import CommandError
8
+ import numpy as np
9
+
10
+ # Shared utilities
11
+ from setiastro.saspro.widgets.image_utils import (
12
+ extract_mask_from_document as _active_mask_array_from_doc,
13
+ to_float01_strict as _to_float01_strict,
14
+ )
15
+
16
+ # Linear luma weights
17
+ _LUMA_REC709 = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
18
+ _LUMA_REC601 = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
19
+ _LUMA_REC2020 = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
20
+
21
+ # ---------- helpers ----------
22
+
23
+ def _estimate_noise_sigma_per_channel(img01: np.ndarray) -> np.ndarray:
24
+ # unchanged (but call with strict input)
25
+ a = img01
26
+ if a.ndim == 2:
27
+ a = a[..., None]
28
+ a = a[::4, ::4, :].astype(np.float32, copy=False)
29
+ med = np.median(a, axis=(0,1))
30
+ mad = np.median(np.abs(a - med), axis=(0,1))
31
+ sigma = 1.4826 * mad
32
+ sigma[sigma <= 1e-12] = 1e-12
33
+ return sigma.astype(np.float32)
34
+
35
+ # ---------- luminance compute (linear) ----------
36
+
37
+ def compute_luminance(
38
+ img: np.ndarray,
39
+ method: str | None = "rec709",
40
+ weights: Optional[np.ndarray] = None,
41
+ noise_sigma: Optional[np.ndarray] = None,
42
+ normalize_weights: bool = True
43
+ ) -> np.ndarray:
44
+ """
45
+ Returns 2-D linear luminance Y in [0,1] (float32).
46
+ No per-image normalization. If custom `weights` are supplied and
47
+ `normalize_weights=False`, their absolute sum is respected.
48
+ """
49
+ f = _to_float01_strict(img)
50
+
51
+ if f.ndim == 2:
52
+ return np.ascontiguousarray(f.astype(np.float32, copy=False))
53
+ if f.ndim != 3:
54
+ raise ValueError("compute_luminance: expected 2-D or 3-D array.")
55
+
56
+ H, W, C = f.shape
57
+ if C == 1:
58
+ return np.ascontiguousarray(f[..., 0].astype(np.float32, copy=False))
59
+
60
+ if weights is not None:
61
+ w = np.asarray(weights, dtype=np.float32)
62
+ if w.ndim != 1 or w.size not in (C, 3):
63
+ raise ValueError("weights must be 1-D with length equal to channel count or 3.")
64
+ if normalize_weights:
65
+ s = float(w.sum())
66
+ if s != 0.0:
67
+ w = w / s
68
+ useC = w.size
69
+ lum = np.tensordot(f[..., :useC], w, axes=([2], [0]))
70
+ elif method == "equal":
71
+ lum = f[..., :3].mean(axis=2)
72
+ elif method == "snr":
73
+ if noise_sigma is None:
74
+ raise ValueError("snr method requires noise_sigma per channel.")
75
+ ns = np.asarray(noise_sigma, dtype=np.float32)
76
+ if ns.ndim != 1 or ns.size not in (C, 3):
77
+ raise ValueError("noise_sigma must be 1-D with length equal to channel count or 3.")
78
+ useC = ns.size
79
+ w = 1.0 / (ns[:useC]**2 + 1e-12)
80
+ w = w / w.sum()
81
+ lum = np.tensordot(f[..., :useC], w, axes=([2],[0]))
82
+ elif method == "max":
83
+ lum = f.max(axis=2)
84
+ elif method == "median":
85
+ lum = np.median(f, axis=2)
86
+ else: # default rec709
87
+ lum = np.tensordot(f[..., :3], _LUMA_REC709, axes=([2],[0]))
88
+
89
+ return np.clip(lum.astype(np.float32, copy=False), 0.0, 1.0)
90
+
91
+ # ---------- luminance recombine (linear scaling) ----------
92
+
93
+ def recombine_luminance_linear_scale(
94
+ target_rgb: np.ndarray,
95
+ new_L: np.ndarray,
96
+ weights: np.ndarray = _LUMA_REC709,
97
+ eps: float = 1e-6,
98
+ blend: float = 1.0, # 0..1, 1=full replace
99
+ highlight_soft_knee: float = 0.0 # 0..1, optional protection
100
+ ) -> np.ndarray:
101
+ """
102
+ Replace linear luminance Y (w·RGB) with `new_L` by per-pixel scaling:
103
+ s = new_L / (Y + eps); RGB' = RGB * s
104
+ This preserves hue/chroma in linear space and round-trips when new_L==Y.
105
+ Optional: blend (mix with original) and highlight soft-knee protection.
106
+ """
107
+ rgb = _to_float01_strict(target_rgb)
108
+ if rgb.ndim != 3 or rgb.shape[2] != 3:
109
+ raise ValueError("Recombine Luminance requires an RGB target image.")
110
+
111
+ H, W, _ = rgb.shape
112
+ L = new_L.astype(np.float32)
113
+ if L.shape[:2] != (H, W):
114
+ L = cv2.resize(L, (W, H), interpolation=cv2.INTER_LINEAR)
115
+
116
+ w = np.asarray(weights, dtype=np.float32)
117
+ if w.shape != (3,):
118
+ raise ValueError("weights must be length-3 for RGB recombine.")
119
+
120
+ # current Y
121
+ Y = rgb[..., 0]*w[0] + rgb[..., 1]*w[1] + rgb[..., 2]*w[2]
122
+ s = L / (Y + eps)
123
+
124
+ if highlight_soft_knee > 0.0:
125
+ # compress extreme upsizing to avoid blowing out tiny Y
126
+ # knee in [0..1], higher = more protection
127
+ k = np.clip(highlight_soft_knee, 0.0, 1.0)
128
+ s = s / (1.0 + k*(s - 1.0))
129
+
130
+ out = rgb * s[..., None]
131
+ out = np.clip(out, 0.0, 1.0)
132
+
133
+ if 0.0 <= blend < 1.0:
134
+ out = rgb*(1.0 - blend) + out*blend
135
+
136
+ return out.astype(np.float32, copy=False)
137
+
138
+ def _resolve_active_doc_from(main, target_doc=None):
139
+ doc = target_doc
140
+ if doc is None:
141
+ d = getattr(main, "_active_doc", None)
142
+ doc = d() if callable(d) else d
143
+ doc = unwrap_docproxy(doc)
144
+ return doc
145
+
146
+
147
+ def apply_recombine_to_doc(
148
+ target_doc,
149
+ luminance_source_img: np.ndarray,
150
+ method: str = "rec709",
151
+ weights: Optional[np.ndarray] = None,
152
+ noise_sigma: Optional[np.ndarray] = None,
153
+ blend: float = 1.0,
154
+ soft_knee: float = 0.0
155
+ ):
156
+ """
157
+ Overwrite target_doc.image by recombining with luminance from source (RGB or mono).
158
+ Uses linear scaling recombine; honors destination mask if present.
159
+ """
160
+ base = _to_float01_strict(np.asarray(target_doc.image))
161
+
162
+ # Decide weights for both compute+recombine
163
+ if method == "rec601":
164
+ w = _LUMA_REC601
165
+ elif method == "rec2020":
166
+ w = _LUMA_REC2020
167
+ elif weights is not None:
168
+ w = np.asarray(weights, dtype=np.float32)
169
+ if w.size != 3:
170
+ raise ValueError("Custom weights must be length-3.")
171
+ else:
172
+ w = _LUMA_REC709
173
+
174
+ # Build L (mono source passes through; RGB is weighted)
175
+ src = _to_float01_strict(luminance_source_img)
176
+ if src.ndim == 2 or (src.ndim == 3 and src.shape[2] == 1):
177
+ L = src if src.ndim == 2 else src[..., 0]
178
+ else:
179
+ ns = None
180
+ if method == "snr":
181
+ ns = _estimate_noise_sigma_per_channel(src)
182
+ L = compute_luminance(src, method=method, weights=w if weights is not None else None, noise_sigma=ns)
183
+
184
+ replaced = recombine_luminance_linear_scale(base, L, weights=w, blend=blend, highlight_soft_knee=soft_knee)
185
+
186
+ # destination-mask blend if active
187
+ m = _active_mask_array_from_doc(target_doc)
188
+ if m is not None:
189
+ m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32)
190
+ replaced = base * (1.0 - m3) + replaced * m3
191
+
192
+ target_doc.apply_edit(
193
+ replaced,
194
+ metadata={"step_name": "Recombine Luminance", "luma_method": method, "luma_weights": w.tolist()},
195
+ step_name="Recombine Luminance",
196
+ )
197
+
198
+
199
+ def run_recombine_luminance_via_preset(main_or_ctx, preset=None, target_doc=None):
200
+ """
201
+ Headless entrypoint for recombine_luminance.
202
+
203
+ preset supports:
204
+ - source_doc_ptr: int (id(doc)) [highest priority]
205
+ - source_title: str [next priority]
206
+ - method, weights, blend, soft_knee (existing)
207
+ If neither source_* is given, first eligible non-target open doc is used.
208
+ """
209
+ from setiastro.saspro.luminancerecombine import apply_recombine_to_doc
210
+
211
+ p = dict(preset or {})
212
+ main, doc, dm = normalize_headless_main(main_or_ctx, target_doc)
213
+
214
+ # ---- Validate target ----
215
+ if doc is None or getattr(doc, "image", None) is None:
216
+ raise CommandError("recombine_luminance: no active RGB ImageDocument. Load an image first.")
217
+
218
+ # ---- Collect open docs (unwrapped) ----
219
+ open_docs = []
220
+ if dm is not None:
221
+ try:
222
+ if hasattr(dm, "all_documents") and callable(dm.all_documents):
223
+ open_docs = [unwrap_docproxy(d) for d in dm.all_documents()]
224
+ elif hasattr(dm, "_docs"):
225
+ open_docs = [unwrap_docproxy(d) for d in dm._docs]
226
+ except Exception:
227
+ open_docs = []
228
+
229
+ # Filter to docs that look like images
230
+ def _has_image(d):
231
+ return d is not None and getattr(d, "image", None) is not None
232
+
233
+ open_docs = [d for d in open_docs if _has_image(d)]
234
+
235
+ # ---- Resolve luminance source ----
236
+ src_doc = None
237
+
238
+ # 1) source_doc_ptr
239
+ src_ptr = p.get("source_doc_ptr", None)
240
+ if src_ptr is not None:
241
+ try:
242
+ src_ptr = int(src_ptr)
243
+ for d in open_docs:
244
+ if id(d) == src_ptr:
245
+ src_doc = d
246
+ break
247
+ except Exception:
248
+ src_doc = None
249
+
250
+ # 2) source_title
251
+ if src_doc is None:
252
+ st = p.get("source_title", None)
253
+ if st:
254
+ st_low = str(st).strip().lower()
255
+
256
+ def _title_of(d):
257
+ # prefer display_name() if available
258
+ try:
259
+ if hasattr(d, "display_name") and callable(d.display_name):
260
+ return str(d.display_name())
261
+ except Exception:
262
+ pass
263
+ # fallback to metadata display_name or file basename
264
+ try:
265
+ md = getattr(d, "metadata", {}) or {}
266
+ if md.get("display_name"):
267
+ return str(md["display_name"])
268
+ fp = md.get("file_path")
269
+ if fp:
270
+ import os
271
+ return os.path.basename(fp)
272
+ except Exception:
273
+ pass
274
+ return ""
275
+
276
+ for d in open_docs:
277
+ if d is doc:
278
+ continue
279
+ if _title_of(d).lower() == st_low:
280
+ src_doc = d
281
+ break
282
+
283
+ # 3) auto-pick first eligible non-target doc
284
+ if src_doc is None:
285
+ for d in open_docs:
286
+ if d is doc:
287
+ continue
288
+ src_doc = d
289
+ break
290
+
291
+ if src_doc is None:
292
+ raise CommandError(
293
+ "recombine_luminance: no luminance source found. "
294
+ "Open another image, or pass preset {'source_title': ...} "
295
+ "or {'source_doc_ptr': id(doc)}."
296
+ )
297
+
298
+ # ---- Execute recombine ----
299
+ src_img = np.asarray(src_doc.image)
300
+
301
+ apply_recombine_to_doc(
302
+ doc,
303
+ src_img,
304
+ method=p.get("method", "rec709"),
305
+ weights=p.get("weights", None),
306
+ blend=float(p.get("blend", 1.0)),
307
+ soft_knee=float(p.get("soft_knee", 0.0)),
308
+ )
309
+
@@ -0,0 +1,201 @@
1
+ # pro/main_helpers.py
2
+ """
3
+ Helper functions extracted from the main module.
4
+
5
+ Contains utility functions used throughout the main window:
6
+ - File path utilities
7
+ - Document name/type detection
8
+ - Widget safety checks
9
+ - WCS/FITS header utilities
10
+ """
11
+
12
+ import os
13
+ from typing import Optional, Tuple
14
+
15
+ from PyQt6 import sip
16
+
17
+ from setiastro.saspro.file_utils import (
18
+ _normalize_ext,
19
+ _sanitize_filename,
20
+ _exts_from_filter,
21
+ REPLACE_SPACES_WITH_UNDERSCORES,
22
+ WIN_RESERVED_NAMES,
23
+ )
24
+
25
+
26
+ def safe_join_dir_and_name(directory: str, basename: str) -> str:
27
+ """
28
+ Join directory + sanitized basename.
29
+ Ensures the directory exists or raises a clear error.
30
+ """
31
+ safe_name = _sanitize_filename(basename)
32
+ final_dir = directory or ""
33
+ if final_dir and not os.path.isdir(final_dir):
34
+ try:
35
+ os.makedirs(final_dir, exist_ok=True)
36
+ except Exception:
37
+ pass
38
+ return os.path.join(final_dir, safe_name)
39
+
40
+
41
+ def normalize_save_path_chosen_filter(path: str, selected_filter: str) -> Tuple[str, str]:
42
+ """
43
+ Returns (final_path, final_ext_norm). Ensures:
44
+ - appends extension if missing (from chosen filter)
45
+ - avoids double extensions (*.png.png)
46
+ - if user provided a conflicting ext, enforce the chosen filter's default
47
+ - sanitizes the basename (spaces, illegal chars, trailing dots)
48
+ """
49
+ raw_path = (path or "").strip().rstrip(".")
50
+ allowed = _exts_from_filter(selected_filter) or ["png"] # safe fallback
51
+ default_ext = allowed[0]
52
+
53
+ # Split dir + basename (sanitize only the basename)
54
+ directory, base = os.path.split(raw_path)
55
+ if not base:
56
+ base = "untitled"
57
+
58
+ # If the user typed something like "name.png" but selected TIFF, fix after sanitization
59
+ base_stem, base_ext = os.path.splitext(base)
60
+ typed = _normalize_ext(base_ext) if base_ext else ""
61
+
62
+ def strip_trailing_allowed(stem: str) -> str:
63
+ """Remove repeated extension in stem (e.g. 'foo.png' then + '.png')."""
64
+ lowered = stem.lower()
65
+ for a in allowed:
66
+ suf = "." + a
67
+ if lowered.endswith(suf):
68
+ return stem[:-len(suf)]
69
+ return stem
70
+
71
+ base_stem = strip_trailing_allowed(base_stem)
72
+
73
+ # Choose final extension
74
+ if not typed:
75
+ final_ext = default_ext
76
+ else:
77
+ final_ext = typed if typed in allowed else default_ext
78
+
79
+ # Rebuild name with the chosen extension, then sanitize the WHOLE basename
80
+ basename_target = f"{base_stem}.{final_ext}"
81
+ basename_safe = _sanitize_filename(basename_target, replace_spaces=REPLACE_SPACES_WITH_UNDERSCORES)
82
+
83
+ # Final join (create dir if missing)
84
+ final_path = safe_join_dir_and_name(directory, basename_safe)
85
+ return final_path, final_ext
86
+
87
+
88
+ def display_name(doc) -> str:
89
+ """Best-effort title for any doc-like object."""
90
+ # Prefer a method
91
+ for attr in ("display_name", "title", "name"):
92
+ v = getattr(doc, attr, None)
93
+ if callable(v):
94
+ try:
95
+ s = v()
96
+ if isinstance(s, str) and s.strip():
97
+ return s
98
+ except Exception:
99
+ pass
100
+ elif isinstance(v, str) and v.strip():
101
+ return v
102
+
103
+ # Metadata fallbacks
104
+ md = getattr(doc, "metadata", {}) or {}
105
+ if isinstance(md, dict):
106
+ for k in ("display_name", "title", "name", "filename", "basename"):
107
+ s = md.get(k)
108
+ if isinstance(s, str) and s.strip():
109
+ return s
110
+
111
+ # Last resort: id snippet
112
+ return f"Document-{id(doc) & 0xFFFF:04X}"
113
+
114
+
115
+ def best_doc_name(doc) -> str:
116
+ """Get the best available name for a document."""
117
+ # Try common attributes in order
118
+ for attr in ("display_name", "name", "title"):
119
+ v = getattr(doc, attr, None)
120
+ if callable(v):
121
+ try:
122
+ v = v()
123
+ except Exception:
124
+ v = None
125
+ if isinstance(v, str) and v.strip():
126
+ return v.strip()
127
+
128
+ # Fallback: derive from original path if we have it
129
+ try:
130
+ meta = getattr(doc, "metadata", {}) or {}
131
+ fp = meta.get("file_path")
132
+ if isinstance(fp, str) and fp:
133
+ return os.path.splitext(os.path.basename(fp))[0]
134
+ except Exception:
135
+ pass
136
+
137
+ return "untitled"
138
+
139
+
140
+ def doc_looks_like_table(doc) -> bool:
141
+ """Determine if a document represents tabular data rather than an image."""
142
+ md = getattr(doc, "metadata", {}) or {}
143
+
144
+ # Explicit type hints from own pipeline
145
+ if str(md.get("doc_type", "")).lower() in {"table", "catalog", "fits_table"}:
146
+ return True
147
+ if str(md.get("fits_hdu_type", "")).lower().endswith("tablehdu"):
148
+ return True
149
+ if str(md.get("hdu_class", "")).lower().endswith("tablehdu"):
150
+ return True
151
+
152
+ # FITS header inspection (common with astropy)
153
+ hdr = md.get("original_header") or md.get("fits_header") or {}
154
+ try:
155
+ xt = str(hdr.get("XTENSION", "")).upper()
156
+ if xt in {"TABLE", "BINTABLE", "ASCIITABLE"}:
157
+ return True
158
+ except Exception:
159
+ pass
160
+
161
+ # Structural hints from the doc
162
+ if hasattr(doc, "table"):
163
+ return True
164
+ if hasattr(doc, "columns"):
165
+ return True
166
+ if hasattr(doc, "rows") or hasattr(doc, "headers"):
167
+ return True
168
+
169
+ # Last resort: no image but we clearly have column metadata
170
+ if getattr(doc, "image", None) is None and isinstance(md.get("columns"), (list, tuple)):
171
+ return True
172
+
173
+ return False
174
+
175
+
176
+ def is_alive(obj) -> bool:
177
+ """True if obj is a live Qt wrapper (not deleted)."""
178
+ if obj is None:
179
+ return False
180
+ if sip is not None:
181
+ try:
182
+ return not sip.isdeleted(obj)
183
+ except Exception:
184
+ pass
185
+ # Touch-test: some cheap attribute access; if wrapper is dead this raises RuntimeError
186
+ try:
187
+ getattr(obj, "objectName", None)
188
+ return True
189
+ except RuntimeError:
190
+ return False
191
+
192
+
193
+ def safe_widget(sw) -> Optional[object]:
194
+ """Returns sw.widget() if both subwindow and its widget are alive; else None."""
195
+ try:
196
+ if not is_alive(sw):
197
+ return None
198
+ w = sw.widget()
199
+ return w if is_alive(w) else None
200
+ except Exception:
201
+ return None