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,445 @@
1
+ # pro/header_viewer.py
2
+ from __future__ import annotations
3
+ import os
4
+ import csv
5
+ from typing import Optional, Dict, Any
6
+
7
+ from PyQt6.QtWidgets import (
8
+ QDockWidget, QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem,
9
+ QPushButton, QFileDialog, QMessageBox
10
+ )
11
+ from PyQt6.QtCore import Qt
12
+
13
+ from astropy.io import fits
14
+ try:
15
+ from astropy.io.fits.verify import VerifyError
16
+ except Exception:
17
+ class VerifyError(Exception):
18
+ pass
19
+
20
+ from setiastro.saspro.xisf import XISF
21
+
22
+ # we’ll reuse your loader helper for FITS headers
23
+ from setiastro.saspro.legacy.image_manager import get_valid_header, _drop_invalid_cards
24
+ from setiastro.saspro.doc_manager import ImageDocument
25
+
26
+
27
+ class HeaderViewerDock(QDockWidget):
28
+ """
29
+ Dock that shows metadata for the currently active ImageDocument.
30
+ Supports FITS headers and XISF file & image metadata.
31
+ """
32
+ def __init__(self, parent=None):
33
+ super().__init__("Header Viewer", parent)
34
+ self._doc: Optional[ImageDocument] = None
35
+ self._doc_conn = False
36
+
37
+ self._tree = QTreeWidget()
38
+ self._tree.setHeaderLabels(["Key", "Value"])
39
+ self._tree.setColumnWidth(0, 220)
40
+
41
+ self._save_btn = QPushButton("Save Metadata…")
42
+ self._save_btn.clicked.connect(self._save_metadata)
43
+ self._dm = None # <-- NEW: DocManager to query "active"
44
+ self._follow_hover = False # <-- optional toggle if you ever want hover-follow
45
+ w = QWidget(self)
46
+ lay = QVBoxLayout(w)
47
+ lay.setContentsMargins(6, 6, 6, 6)
48
+ lay.addWidget(self._tree)
49
+ lay.addWidget(self._save_btn)
50
+ self.setWidget(w)
51
+
52
+ def _same_base(self, a, b) -> bool:
53
+ return self._unwrap_base_doc(a) is self._unwrap_base_doc(b)
54
+
55
+ def attach_doc_manager(self, dm):
56
+ self._dm = dm
57
+ try:
58
+ # When docs are added/removed, re-evaluate the focused base
59
+ dm.documentAdded.connect(lambda _doc: self._maybe_refresh_for_active())
60
+ dm.documentRemoved.connect(lambda _doc: self._maybe_refresh_for_active())
61
+
62
+ # DO NOT use imageRegionUpdated to retarget; it can fire from hover-driven previews.
63
+ # If you want to repaint the same doc on region changes, _on_doc_changed handles that.
64
+
65
+ mdi = getattr(dm, "_mdi", None)
66
+ if mdi and hasattr(mdi, "subWindowActivated"):
67
+ mdi.subWindowActivated.connect(lambda _sw: self._maybe_refresh_for_active())
68
+
69
+ # NEW: snap to truly active base (sticky, click-activated only)
70
+ if hasattr(dm, "activeBaseChanged"):
71
+ dm.activeBaseChanged.connect(lambda _doc: self._maybe_refresh_for_active())
72
+ except Exception:
73
+ pass
74
+
75
+ self._maybe_refresh_for_active()
76
+
77
+
78
+ def set_follow_hover(self, enabled: bool):
79
+ self._follow_hover = bool(enabled)
80
+
81
+ # Prefer base (true) doc over transient wrappers/proxies
82
+ def _unwrap_base_doc(self, d):
83
+ if d is None:
84
+ return None
85
+ # ROI preview wrapper → parent
86
+ p = getattr(d, "_parent_doc", None)
87
+ if isinstance(p, ImageDocument):
88
+ return p
89
+ # LiveViewDocument proxy → base
90
+ b = getattr(d, "_base", None)
91
+ if isinstance(b, ImageDocument):
92
+ return b
93
+ return d
94
+
95
+ def _active_base_doc(self):
96
+ if not self._dm:
97
+ return None
98
+ # Prefer DocManager’s sticky focused base if available
99
+ if hasattr(self._dm, "get_focused_base_document"):
100
+ try:
101
+ return self._dm.get_focused_base_document()
102
+ except Exception:
103
+ pass
104
+ # Fallback: unwrap whatever get_active_document returns
105
+ try:
106
+ cur = self._dm.get_active_document()
107
+ return self._unwrap_base_doc(cur)
108
+ except Exception:
109
+ return None
110
+
111
+
112
+ def _maybe_refresh_for_active(self):
113
+ """Rebuild only if our bound document == current active base document."""
114
+ active_base = self._active_base_doc()
115
+ if active_base is None:
116
+ return
117
+ # If we already show the same base doc, just ignore
118
+ if self._unwrap_base_doc(self._doc) is active_base:
119
+ return
120
+ # Else bind to the active base doc
121
+ self.set_document(active_base)
122
+
123
+ # ---- public API ----
124
+ def set_document(self, doc: Optional[ImageDocument]):
125
+ """
126
+ Hard-lock behavior:
127
+ - If attached to a DocManager AND hover-follow is OFF, ignore the caller's 'doc'
128
+ and always bind to the DocManager's *active base* doc.
129
+ - Otherwise, behave like a normal setter.
130
+ """
131
+ if self._dm and not self._follow_hover:
132
+ # Caller cannot hijack focus: resolve from DM every time
133
+ doc = self._active_base_doc()
134
+
135
+ # Always resolve to base (true) document for internal storage
136
+ base_doc = self._unwrap_base_doc(doc)
137
+
138
+ # No-op if unchanged
139
+ if self._same_base(self._doc, base_doc):
140
+ return
141
+
142
+ # Disconnect old
143
+ if self._doc and hasattr(self._doc, "changed"):
144
+ try:
145
+ self._doc.changed.disconnect(self._on_doc_changed)
146
+ except Exception:
147
+ pass
148
+
149
+ self._doc = base_doc
150
+
151
+ # Listen for internal changes on the *bound* doc
152
+ if self._doc and hasattr(self._doc, "changed"):
153
+ try:
154
+ self._doc.changed.connect(self._on_doc_changed)
155
+ except Exception:
156
+ pass
157
+
158
+ self._rebuild()
159
+
160
+
161
+ def _on_doc_changed(self):
162
+ """
163
+ Only rebuild if our bound doc is STILL the active base doc.
164
+ Prevents spurious rebuilds when focus changed between signal emit and slot run.
165
+ """
166
+ if self._dm and not self._follow_hover:
167
+ active_base = self._active_base_doc()
168
+ if not self._same_base(self._doc, active_base):
169
+ # We got a change from an old/hover doc — ignore and snap to active.
170
+ self._maybe_refresh_for_active()
171
+ return
172
+ self._rebuild()
173
+
174
+
175
+ # --- helpers ---------------------------------------------------------
176
+ def _populate_header_dict(self, d: dict, title="Header (dict)"):
177
+ root = QTreeWidgetItem([title])
178
+ self._tree.addTopLevelItem(root)
179
+ for k, v in d.items():
180
+ root.addChild(QTreeWidgetItem([str(k), str(v)]))
181
+
182
+ def _populate_header_snapshot(self, snap: dict):
183
+ fmt = (snap or {}).get("format", "")
184
+ if fmt == "fits-cards":
185
+ cards = snap.get("cards") or []
186
+ hdr = fits.Header()
187
+ for k, v, c in cards:
188
+ try:
189
+ hdr[str(k)] = (v, c)
190
+ except Exception:
191
+ # extremely defensive: skip bad card entries
192
+ pass
193
+ try:
194
+ hdr = _drop_invalid_cards(hdr)
195
+ except Exception:
196
+ pass
197
+ self._populate_fits_header(hdr)
198
+ elif fmt == "dict":
199
+ self._populate_header_dict(snap.get("items") or {}, "Header (snapshot)")
200
+ else:
201
+ # generic repr fallback
202
+ txt = (snap or {}).get("text", "")
203
+ node = QTreeWidgetItem(["Header (snapshot)"])
204
+ self._tree.addTopLevelItem(node)
205
+ node.addChild(QTreeWidgetItem(["repr", str(txt)]))
206
+
207
+
208
+ def _try_populate_from_doc(self, meta: dict) -> bool:
209
+ """Return True if we showed any header from the document metadata."""
210
+ # 1) direct astropy header
211
+ hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
212
+ if isinstance(hdr, fits.Header):
213
+ try:
214
+ hdr = _drop_invalid_cards(hdr.copy())
215
+ except Exception:
216
+ pass
217
+ self._populate_fits_header(hdr)
218
+ return True
219
+
220
+ # 2) dict-style header (e.g., XISF-style properties captured as dict)
221
+ if isinstance(hdr, dict):
222
+ self._populate_header_dict(hdr, "Header (dict from document)")
223
+ return True
224
+
225
+ # 3) JSON-safe snapshot captured by DocManager
226
+ snap = meta.get("__header_snapshot__")
227
+ if isinstance(snap, dict):
228
+ self._populate_header_snapshot(snap)
229
+ return True
230
+
231
+ # 4) XISF properties stored in metadata (common keys)
232
+ for k in ("xisf_header", "xisf_properties"):
233
+ if isinstance(meta.get(k), dict):
234
+ self._populate_header_dict(meta[k], "XISF Properties (document)")
235
+ return True
236
+
237
+ return False
238
+
239
+ def _try_populate_from_file(self, path: str, meta: dict) -> bool:
240
+ """Return True if we read & showed header from the backing file."""
241
+ if not path:
242
+ return False
243
+ p = path.lower()
244
+
245
+ # FITS (and MEF and .fz) via legacy helper
246
+ if p.endswith((".fits", ".fit", ".fz", ".fits.fz", ".fit.fz")):
247
+ # prefer the on-disk header if not already in meta
248
+ file_hdr = meta.get("original_header")
249
+ if isinstance(file_hdr, fits.Header):
250
+ try:
251
+ file_hdr = _drop_invalid_cards(file_hdr.copy())
252
+ except Exception:
253
+ pass
254
+ else:
255
+ file_hdr, _ = get_valid_header(path)
256
+
257
+ if isinstance(file_hdr, fits.Header):
258
+ self._populate_fits_header(file_hdr)
259
+ return True
260
+
261
+ # XISF: try to open and show basic properties if available
262
+ if p.endswith(".xisf"):
263
+ try:
264
+ xisf = XISF(path)
265
+ props = getattr(xisf, "properties", None)
266
+ if isinstance(props, dict):
267
+ self._populate_header_dict(props, "XISF Properties")
268
+ return True
269
+ except Exception:
270
+ pass
271
+
272
+ return False
273
+
274
+
275
+ # --- main ------------------------------------------------------------
276
+ def _rebuild(self):
277
+ self._tree.clear()
278
+ base_doc = self._unwrap_base_doc(self._doc)
279
+ if not base_doc:
280
+ self.setWindowTitle("Header Viewer")
281
+ return
282
+ self._doc = base_doc
283
+
284
+ meta = self._doc.metadata or {}
285
+ path = (meta.get("file_path") or "") if isinstance(meta.get("file_path"), str) else ""
286
+ base = os.path.basename(path) if path else (meta.get("display_name") or "Untitled")
287
+ self.setWindowTitle(f"Header: {base}")
288
+
289
+ try:
290
+ # 1) Prefer header data already stored with the document
291
+ shown_any = self._try_populate_from_doc(meta)
292
+
293
+ # 2) If we didn't render anything yet, fall back to the file on disk
294
+ if not shown_any:
295
+ shown_any = self._try_populate_from_file(path, meta)
296
+
297
+ # 3) If there is a real astropy.wcs.WCS object, render it as key/value rows
298
+ try:
299
+ from astropy.wcs import WCS as _WCS
300
+ wcs_obj = meta.get("wcs")
301
+ if isinstance(wcs_obj, _WCS):
302
+ self._populate_wcs(wcs_obj)
303
+ except Exception:
304
+ pass
305
+
306
+ # 4) Always show remaining lightweight metadata (skip heavy blobs we already rendered)
307
+ info_root = QTreeWidgetItem(["Metadata"])
308
+ self._tree.addTopLevelItem(info_root)
309
+ for k, v in meta.items():
310
+ if k in ("original_header", "fits_header", "header", "wcs", "__header_snapshot__", "xisf_header", "xisf_properties"):
311
+ continue
312
+ info_root.addChild(QTreeWidgetItem([str(k), str(v)]))
313
+
314
+ self._tree.expandAll()
315
+
316
+ except Exception:
317
+ # per request: fail silently on final exception
318
+ pass
319
+
320
+
321
+ # ---- population helpers ----
322
+ def _populate_fits_header(self, header: Any):
323
+ root = QTreeWidgetItem(["FITS Header"])
324
+ self._tree.addTopLevelItem(root)
325
+
326
+ # FITS Header: sanitize and iterate cards defensively
327
+ if isinstance(header, fits.Header):
328
+ try:
329
+ header = _drop_invalid_cards(header)
330
+ except Exception:
331
+ pass
332
+
333
+ for card in header.cards:
334
+ try:
335
+ k = str(card.keyword)
336
+ v = str(card.value)
337
+ except VerifyError as e:
338
+ # Skip invalid/unparsable card
339
+ print(f"[HeaderViewer] Skipping invalid FITS card {getattr(card, 'keyword', '?')!r}: {e}")
340
+ continue
341
+ except Exception as e:
342
+ print(f"[HeaderViewer] Error reading FITS card: {e}")
343
+ continue
344
+ root.addChild(QTreeWidgetItem([k, v]))
345
+
346
+ # Plain dict fallback (e.g., XISF-style dict)
347
+ elif isinstance(header, dict):
348
+ for k, v in header.items():
349
+ try:
350
+ root.addChild(QTreeWidgetItem([str(k), str(v)]))
351
+ except Exception:
352
+ continue
353
+
354
+
355
+ def _populate_wcs(self, wcs_obj):
356
+ """Show a real astropy.wcs.WCS as header-like key/values."""
357
+ root = QTreeWidgetItem(["WCS"])
358
+ self._tree.addTopLevelItem(root)
359
+ try:
360
+ # Use relax=True so SIP/etc. are included if present.
361
+ wcs_hdr = wcs_obj.to_header(relax=True)
362
+ for k, v in wcs_hdr.items():
363
+ root.addChild(QTreeWidgetItem([str(k), str(v)]))
364
+ except Exception:
365
+ # Fallback: parse the repr into lines (better than a single blob).
366
+ for line in str(wcs_obj).splitlines():
367
+ s = line.strip()
368
+ if not s:
369
+ continue
370
+ if ":" in s:
371
+ a, b = s.split(":", 1)
372
+ root.addChild(QTreeWidgetItem([a.strip(), b.strip()]))
373
+ else:
374
+ root.addChild(QTreeWidgetItem(["", s]))
375
+
376
+
377
+ def _populate_from_xisf(self, path: str):
378
+ x = XISF(path)
379
+ file_meta: Dict[str, Any] = x.get_file_metadata()
380
+ img_meta_list = x.get_images_metadata()
381
+ img_meta: Dict[str, Any] = img_meta_list[0] if img_meta_list else {}
382
+
383
+ # File-level metadata
384
+ froot = QTreeWidgetItem(["XISF File Metadata"])
385
+ self._tree.addTopLevelItem(froot)
386
+ for k, v in file_meta.items():
387
+ vstr = v.get("value", "") if isinstance(v, dict) else v
388
+ froot.addChild(QTreeWidgetItem([str(k), str(vstr)]))
389
+
390
+ # Image-level metadata
391
+ iroot = QTreeWidgetItem(["XISF Image Metadata"])
392
+ self._tree.addTopLevelItem(iroot)
393
+
394
+ # FITS-like keywords (nested)
395
+ if "FITSKeywords" in img_meta:
396
+ fits_item = QTreeWidgetItem(["FITSKeywords"])
397
+ iroot.addChild(fits_item)
398
+ for kw, entries in img_meta["FITSKeywords"].items():
399
+ for ent in entries:
400
+ fits_item.addChild(QTreeWidgetItem([kw, str(ent.get("value", ""))]))
401
+
402
+ # XISFProperties (nested)
403
+ if "XISFProperties" in img_meta:
404
+ props_item = QTreeWidgetItem(["XISFProperties"])
405
+ iroot.addChild(props_item)
406
+ for prop_name, prop in img_meta["XISFProperties"].items():
407
+ props_item.addChild(QTreeWidgetItem([prop_name, str(prop.get("value", ""))]))
408
+
409
+ # Any remaining flat fields
410
+ for k, v in img_meta.items():
411
+ if k in ("FITSKeywords", "XISFProperties"):
412
+ continue
413
+ iroot.addChild(QTreeWidgetItem([k, str(v)]))
414
+
415
+ self._tree.expandAll()
416
+
417
+ # ---- export ----
418
+ def _save_metadata(self):
419
+ if not self._doc:
420
+ return
421
+ path, _ = QFileDialog.getSaveFileName(self, "Save Metadata", "", "CSV (*.csv)")
422
+ if not path:
423
+ return
424
+
425
+ # Flatten the QTreeWidget contents into key/value rows
426
+ rows = []
427
+ def walk(item: QTreeWidgetItem, prefix: str = ""):
428
+ key = item.text(0)
429
+ val = item.text(1)
430
+ full = f"{prefix}.{key}" if prefix else key
431
+ if key and val:
432
+ rows.append((full, val))
433
+ for i in range(item.childCount()):
434
+ walk(item.child(i), full)
435
+
436
+ for i in range(self._tree.topLevelItemCount()):
437
+ walk(self._tree.topLevelItem(i))
438
+
439
+ try:
440
+ with open(path, "w", newline="", encoding="utf-8") as f:
441
+ w = csv.writer(f)
442
+ w.writerow(["Key", "Value"])
443
+ w.writerows(rows)
444
+ except Exception as e:
445
+ QMessageBox.critical(self, "Save Metadata", f"Failed to save:\n{e}")
@@ -0,0 +1,88 @@
1
+ # pro/headless_utils.py
2
+ from __future__ import annotations
3
+
4
+ def unwrap_docproxy(x, max_depth: int = 8):
5
+ """
6
+ Safely unwrap live/roi/doc proxies to a real ImageDocument when possible.
7
+ - Recurses a few levels.
8
+ - Understands LiveViewDocument (_current/_base) and ROI wrappers (_parent_doc).
9
+ - Never unwraps to None unless input was None.
10
+ """
11
+ if x is None:
12
+ return None
13
+
14
+ seen = set()
15
+ y = x
16
+
17
+ for _ in range(max_depth):
18
+ if y is None or id(y) in seen:
19
+ break
20
+ seen.add(id(y))
21
+
22
+ # LiveViewDocument / similar: prefer its resolver
23
+ cur = getattr(y, "_current", None)
24
+ if callable(cur):
25
+ try:
26
+ z = cur()
27
+ if z is not None and z is not y:
28
+ y = z
29
+ continue
30
+ except Exception:
31
+ pass
32
+
33
+ # Common doc proxy fields (ordered)
34
+ for attr in (
35
+ "_base", "base",
36
+ "_parent_doc", "parent_doc",
37
+ "base_document", "_base_document",
38
+ "_target", "target",
39
+ "_doc", "doc",
40
+ "_obj", "obj",
41
+ "_proxied", "proxied",
42
+ "_wrapped", "wrapped",
43
+ ):
44
+ try:
45
+ z = getattr(y, attr, None)
46
+ except Exception:
47
+ z = None
48
+ if z is not None and z is not y:
49
+ y = z
50
+ break
51
+ else:
52
+ break
53
+
54
+ return y
55
+
56
+
57
+
58
+ def normalize_headless_main(main_or_ctx, target_doc=None):
59
+ """
60
+ Returns (main_window, doc, doc_manager)
61
+ Ensures doc + dm are fully unwrapped and ROI-aware.
62
+ """
63
+ ctx = None
64
+ main = main_or_ctx
65
+
66
+ if hasattr(main_or_ctx, "app") and hasattr(main_or_ctx, "active_document"):
67
+ ctx = main_or_ctx
68
+ main = getattr(ctx, "app", None)
69
+ if target_doc is None:
70
+ try:
71
+ # Prefer dm.get_active_document() if possible (ROI-aware, real doc type)
72
+ dm0 = getattr(main, "doc_manager", None) or getattr(main, "dm", None)
73
+ dm0 = unwrap_docproxy(dm0)
74
+ if dm0 is not None and hasattr(dm0, "get_active_document"):
75
+ target_doc = dm0.get_active_document()
76
+ else:
77
+ target_doc = ctx.active_document()
78
+ except Exception:
79
+ target_doc = None
80
+
81
+ doc = unwrap_docproxy(target_doc)
82
+
83
+ dm = None
84
+ if main is not None:
85
+ dm = getattr(main, "doc_manager", None) or getattr(main, "dm", None)
86
+
87
+
88
+ return main, doc, dm