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,2634 @@
1
+ # pro/doc_manager.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import QObject, pyqtSignal, Qt, QTimer
4
+ from PyQt6.QtWidgets import QApplication, QMessageBox
5
+ import os
6
+ import numpy as np
7
+ from setiastro.saspro.xisf import XISF as XISFReader
8
+ from astropy.io import fits # local import; optional dep
9
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image, save_image as legacy_save_image
10
+ from setiastro.saspro.legacy.image_manager import list_fits_extensions, load_fits_extension
11
+ import uuid
12
+ from setiastro.saspro.legacy.image_manager import attach_wcs_to_metadata # or wherever you put it
13
+ from astropy.wcs import WCS # only if not already imported in this module
14
+ from setiastro.saspro.debug_utils import debug_dump_metadata
15
+
16
+ # Memory utilities for lazy loading and caching
17
+ try:
18
+ from setiastro.saspro.memory_utils import get_thumbnail_cache, LazyImage
19
+ except ImportError:
20
+ get_thumbnail_cache = None
21
+ LazyImage = None
22
+
23
+ from setiastro.saspro.swap_manager import get_swap_manager
24
+ from setiastro.saspro.widgets.image_utils import ensure_contiguous
25
+ from typing import Any
26
+
27
+ # --- WCS DEBUGGING ------------------------------------------------------
28
+ _DEBUG_WCS = False # flip to False when you’re done debugging
29
+
30
+ def _debug_log_wcs_context(context: str, meta_or_hdr):
31
+ """
32
+ Tiny helper to print key WCS bits:
33
+ - NAXIS1/2
34
+ - CRPIX1/2
35
+ - CRVAL1/2
36
+ - CDELT / CD if present
37
+ Works if you pass either a metadata dict or a FITS-like header dict.
38
+ """
39
+ if not _DEBUG_WCS:
40
+ return
41
+
42
+ # Try to resolve a header from a metadata dict
43
+ hdr = None
44
+ if isinstance(meta_or_hdr, dict):
45
+ # metadata dict with possible header keys
46
+ hdr = (meta_or_hdr.get("original_header")
47
+ or meta_or_hdr.get("fits_header")
48
+ or meta_or_hdr.get("header"))
49
+ if hdr is None:
50
+ # maybe you passed the header dict directly
51
+ hdr = meta_or_hdr
52
+ else:
53
+ hdr = meta_or_hdr
54
+
55
+ if hdr is None:
56
+ print(f"[WCS DEBUG] {context}: no header found")
57
+ return
58
+
59
+ # Normalize dict-like header
60
+ if hasattr(hdr, "keys"): # astropy Header or dict
61
+ try:
62
+ keys = list(hdr.keys())
63
+ except Exception:
64
+ keys = []
65
+ else:
66
+ print(f"[WCS DEBUG] {context}: header is non-mapping type {type(hdr)}")
67
+ return
68
+
69
+ def _get(k, default=None):
70
+ try:
71
+ return hdr.get(k, default)
72
+ except Exception:
73
+ try:
74
+ return hdr[k]
75
+ except Exception:
76
+ return default
77
+
78
+ naxis1 = _get("NAXIS1")
79
+ naxis2 = _get("NAXIS2")
80
+ crpix1 = _get("CRPIX1")
81
+ crpix2 = _get("CRPIX2")
82
+ crval1 = _get("CRVAL1")
83
+ crval2 = _get("CRVAL2")
84
+
85
+ cd11 = _get("CD1_1")
86
+ cd12 = _get("CD1_2")
87
+ cd21 = _get("CD2_1")
88
+ cd22 = _get("CD2_2")
89
+ cdelt1 = _get("CDELT1")
90
+ cdelt2 = _get("CDELT2")
91
+
92
+ print(f"[WCS DEBUG] {context}:")
93
+ print(f" NAXIS1={naxis1} NAXIS2={naxis2}")
94
+ print(f" CRPIX1={crpix1} CRPIX2={crpix2}")
95
+ print(f" CRVAL1={crval1} CRVAL2={crval2}")
96
+ if any(v is not None for v in (cd11, cd12, cd21, cd22)):
97
+ print(f" CD = [[{cd11}, {cd12}], [{cd21}, {cd22}]]")
98
+ if cdelt1 is not None or cdelt2 is not None:
99
+ print(f" CDELT1={cdelt1} CDELT2={cdelt2}")
100
+ print("")
101
+
102
+ _DEBUG_UNDO = False # set True while chasing the GraXpert crash
103
+
104
+
105
+ def _debug_log_undo(context: str, **info):
106
+ """
107
+ Lightweight logger for undo/redo/update activity.
108
+ Safe: never raises, even if repr() is weird.
109
+ """
110
+ if not _DEBUG_UNDO:
111
+ return
112
+ try:
113
+ bits = []
114
+ for k, v in info.items():
115
+ try:
116
+ s = str(v)
117
+ except Exception:
118
+ try:
119
+ s = repr(v)
120
+ except Exception:
121
+ s = f"<unrepr {type(v)}>"
122
+ bits.append(f"{k}={s}")
123
+ print(f"[UNDO DEBUG] {context}: " + ", ".join(bits))
124
+ except Exception as e:
125
+ # Last-resort safety – don't let logging itself kill us
126
+ try:
127
+ print(f"[UNDO DEBUG] {context}: <logging failed: {e}>")
128
+ except Exception:
129
+ pass
130
+
131
+ from setiastro.saspro.file_utils import _normalize_ext
132
+
133
+ def _normalize_image_01(arr: np.ndarray) -> np.ndarray:
134
+ """
135
+ Normalize an image to [0,1] in-place-ish:
136
+
137
+ 1. If min < 0 → shift so min becomes 0.
138
+ 2. Then if max > 1 → divide by max.
139
+
140
+ NaNs/Infs are ignored when computing min/max.
141
+ Returns float32 array.
142
+ """
143
+ if arr is None:
144
+ return arr
145
+
146
+ a = np.asarray(arr, dtype=np.float32)
147
+ finite = np.isfinite(a)
148
+ if not finite.any():
149
+ # completely bogus; give back zeros
150
+ return np.zeros_like(a, dtype=np.float32)
151
+
152
+ # Step 1: shift up if we have negatives
153
+ min_val = a[finite].min()
154
+ if min_val < 0.0:
155
+ a = a - min_val
156
+ finite = np.isfinite(a)
157
+
158
+ # Step 2: scale down if we exceed 1
159
+ max_val = a[finite].max()
160
+ if max_val > 1.0 and max_val > 0.0:
161
+ a = a / max_val
162
+
163
+ return a
164
+
165
+ _ALLOWED_DEPTHS = {
166
+ "png": {"8-bit"},
167
+ "jpg": {"8-bit"},
168
+ "fits": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
169
+ "fit": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
170
+ "tif": {"8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"},
171
+ "xisf": {"16-bit", "32-bit unsigned", "32-bit floating point"},
172
+ }
173
+
174
+ class TableDocument(QObject):
175
+ changed = pyqtSignal()
176
+
177
+ def __init__(self, rows: list[list], headers: list[str], metadata: dict | None = None, parent=None):
178
+ super().__init__(parent)
179
+ self.rows = rows # list of list (2D) for QAbstractTableModel
180
+ self.headers = headers # list of column names
181
+ self.metadata = dict(metadata or {})
182
+ self._undo = []
183
+ self._redo = []
184
+
185
+ def display_name(self) -> str:
186
+ dn = self.metadata.get("display_name")
187
+ if dn:
188
+ return dn
189
+ p = self.metadata.get("file_path")
190
+ return os.path.basename(p) if p else "Untitled Table"
191
+
192
+ def can_undo(self) -> bool: return False
193
+ def can_redo(self) -> bool: return False
194
+ def last_undo_name(self) -> str | None: return None
195
+ def last_redo_name(self) -> str | None: return None
196
+ def undo(self) -> str | None: return None
197
+ def redo(self) -> str | None: return None
198
+
199
+ class ImageDocument(QObject):
200
+ changed = pyqtSignal()
201
+
202
+ def __init__(self, image: np.ndarray, metadata: dict | None = None, parent=None):
203
+ super().__init__(parent)
204
+ self.image = image
205
+ self.metadata = dict(metadata or {})
206
+ self.mask = None
207
+ # _undo / _redo now store tuples: (swap_id: str, metadata: dict, step_name: str)
208
+ self._undo: list[tuple[str, dict, str]] = []
209
+ self._redo: list[tuple[str, dict, str]] = []
210
+ self.masks: dict[str, np.ndarray] = {}
211
+ self.active_mask_id: str | None = None
212
+ self.uid = uuid.uuid4().hex # stable identity for DnD, layers, masks, etc.
213
+
214
+ # NEW: operation log — list of simple dicts
215
+ # Each entry: {
216
+ # "id": str,
217
+ # "step": str,
218
+ # "params": dict,
219
+ # "roi": (x,y,w,h) | None,
220
+ # "source": "full" | "roi",
221
+ # "ts": float
222
+ # }
223
+ self._op_log: list[dict] = []
224
+
225
+ # Track unsaved changes explicitly
226
+ self.dirty: bool = False
227
+
228
+ # Copy-on-write support: if this document shares image data with another,
229
+ # _cow_source holds reference to the source. On first write (apply_edit),
230
+ # we copy the image data and clear _cow_source.
231
+ self._cow_source: 'ImageDocument | None' = None
232
+ # --- history helpers (NEW) ---
233
+ # --- operation log helpers (NEW) -----------------------------------
234
+ def record_operation(
235
+ self,
236
+ step_name: str,
237
+ params: dict | None = None,
238
+ roi: tuple[int, int, int, int] | None = None,
239
+ source: str = "full",
240
+ ) -> str:
241
+ """
242
+ Append a param-record for this edit. This is *lightweight* metadata
243
+ used for replaying ROI recipes etc; it does NOT affect undo/redo.
244
+ """
245
+ import time as _time
246
+ op_id = uuid.uuid4().hex
247
+ entry = {
248
+ "id": op_id,
249
+ "step": step_name or "Edit",
250
+ "params": _dm_json_sanitize(params or {}),
251
+ "roi": tuple(roi) if roi else None,
252
+ "source": str(source or "full"),
253
+ "ts": float(_time.time()),
254
+ }
255
+ self._op_log.append(entry)
256
+ return op_id
257
+
258
+ def get_operation_log(self) -> list[dict]:
259
+ """Return a copy of the operation log (for UI / replay)."""
260
+ return list(self._op_log)
261
+
262
+ def clear_operation_log(self):
263
+ """Clear the operation log (does not touch pixel history)."""
264
+ self._op_log.clear()
265
+
266
+
267
+ def can_undo(self) -> bool:
268
+ return bool(self._undo)
269
+
270
+ def can_redo(self) -> bool:
271
+ return bool(self._redo)
272
+
273
+ def last_undo_name(self) -> str | None:
274
+ return self._undo[-1][2] if self._undo else None
275
+
276
+ def last_redo_name(self) -> str | None:
277
+ return self._redo[-1][2] if self._redo else None
278
+
279
+
280
+ def add_mask(self, mask: Any, mask_id: str | None = None, make_active: bool = True) -> str:
281
+ """
282
+ Store a mask on this document.
283
+
284
+ - `mask` can be a numpy array or any mask-like object.
285
+ - If `mask_id` is None, a random UUID is generated.
286
+ - Returns the mask_id used.
287
+ """
288
+ if mask_id is None:
289
+ mask_id = getattr(mask, "id", None) or uuid.uuid4().hex
290
+
291
+ # If it's an array, normalize to float32; otherwise just store as-is.
292
+ try:
293
+ arr = np.asarray(mask, dtype=np.float32)
294
+ self.masks[mask_id] = arr
295
+ except Exception:
296
+ self.masks[mask_id] = mask
297
+
298
+ if make_active:
299
+ self.active_mask_id = mask_id
300
+
301
+ return mask_id
302
+
303
+ def remove_mask(self, mask_id: str):
304
+ self.masks.pop(mask_id, None)
305
+ if self.active_mask_id == mask_id:
306
+ self.active_mask_id = None
307
+
308
+ def get_active_mask(self):
309
+ return self.masks.get(self.active_mask_id) if self.active_mask_id else None
310
+
311
+ def close(self):
312
+ """
313
+ Explicit cleanup of swap files.
314
+ """
315
+ sm = get_swap_manager()
316
+ # Clean up undo stack
317
+ for swap_id, _, _ in self._undo:
318
+ sm.delete_state(swap_id)
319
+ self._undo.clear()
320
+
321
+ # Clean up redo stack
322
+ for swap_id, _, _ in self._redo:
323
+ sm.delete_state(swap_id)
324
+ self._redo.clear()
325
+
326
+ def __del__(self):
327
+ # Fallback cleanup if close() wasn't called (though explicit close is better)
328
+ try:
329
+ self.close()
330
+ except Exception:
331
+ pass
332
+
333
+
334
+ # in class ImageDocument
335
+ def apply_edit(self, new_image: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
336
+ """
337
+ Smart edit:
338
+ - If this is an ROI view (has _roi_info), paste back into parent and emit region update.
339
+ - Else: push history on self and emit full-image update.
340
+ - IMPORTANT: merge metadata without nuking FITS/WCS headers.
341
+ """
342
+ import numpy as np
343
+
344
+ def _merge_meta(old_meta: dict | None, new_meta: dict | None, step_name: str):
345
+ """
346
+ Merge new_meta into old_meta but preserve critical header fields
347
+ unless they are explicitly overridden with non-None values.
348
+ """
349
+ old = dict(old_meta or {})
350
+ incoming = dict(new_meta or {})
351
+
352
+ critical_keys = (
353
+ "original_header",
354
+ "fits_header",
355
+ "wcs_header",
356
+ "file_meta",
357
+ "image_meta",
358
+ )
359
+
360
+ # Preserve critical keys unless caller *deliberately* overrides
361
+ for k in critical_keys:
362
+ if k in incoming:
363
+ if incoming[k] is not None:
364
+ old[k] = incoming[k]
365
+ # if not in incoming → leave old value alone
366
+
367
+ # Merge all remaining keys normally
368
+ for k, v in incoming.items():
369
+ if k in critical_keys:
370
+ continue
371
+ old[k] = v
372
+
373
+ if step_name:
374
+ old.setdefault("step_name", step_name)
375
+ return old
376
+
377
+ # ------ ROI-aware branch (auto-pasteback) ------
378
+ roi_info = getattr(self, "_roi_info", None)
379
+ if roi_info:
380
+ parent = roi_info.get("parent_doc")
381
+ roi = roi_info.get("roi")
382
+ if isinstance(parent, ImageDocument) and (getattr(parent, "image", None) is not None) and roi:
383
+ x, y, w, h = map(int, roi)
384
+
385
+ img = np.asarray(new_image)
386
+ if img.dtype != np.float32:
387
+ img = img.astype(np.float32, copy=False)
388
+
389
+ base = np.asarray(parent.image)
390
+ if img.shape[:2] != (h, w):
391
+ raise ValueError(f"Edited preview shape {img.shape[:2]} does not match ROI {(h, w)}")
392
+
393
+ # shape reconciliation
394
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
395
+ img = img[..., 0]
396
+ if base.ndim == 3 and img.ndim == 2:
397
+ img = np.repeat(img[..., None], base.shape[2], axis=2)
398
+
399
+ new_full = base.copy()
400
+ new_full[y:y+h, x:x+w] = img
401
+
402
+ # push onto the PARENT’s history
403
+ if metadata:
404
+ parent.metadata = _merge_meta(parent.metadata, metadata, step_name)
405
+ else:
406
+ parent.metadata.setdefault("step_name", step_name)
407
+
408
+ sm = get_swap_manager()
409
+ sid = sm.save_state(parent.image)
410
+ if sid:
411
+ parent._undo.append((sid, parent.metadata.copy(), step_name))
412
+
413
+ for old_sid, _, _ in parent._redo:
414
+ sm.delete_state(old_sid)
415
+ parent._redo.clear()
416
+
417
+ parent.image = new_full
418
+ parent.dirty = True
419
+ parent.changed.emit()
420
+
421
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
422
+ try:
423
+ if dm is not None and hasattr(dm, "imageRegionUpdated"):
424
+ dm.imageRegionUpdated.emit(parent, (x, y, w, h))
425
+ except Exception:
426
+ print(f"[DocManager] Failed to emit imageRegionUpdated for ROI.")
427
+ return # done
428
+
429
+ # ------ Normal (full-image) branch ------
430
+
431
+ # Copy-on-write
432
+ if self._cow_source is not None and self.image is not None:
433
+ self.image = self.image.copy()
434
+ self._cow_source = None
435
+
436
+ if self.image is not None:
437
+ # snapshot current image + metadata for undo
438
+ try:
439
+ curr = np.asarray(self.image, dtype=np.float32)
440
+ curr = ensure_contiguous(curr)
441
+
442
+ sm = get_swap_manager()
443
+ sid = sm.save_state(curr)
444
+
445
+ _debug_log_undo(
446
+ "ImageDocument.apply_edit.snapshot",
447
+ doc_id=id(self),
448
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
449
+ curr_shape=getattr(curr, "shape", None),
450
+ undo_len_before=len(self._undo),
451
+ redo_len_before=len(self._redo),
452
+ step_name=step_name,
453
+ swap_id=sid
454
+ )
455
+ if sid:
456
+ self._undo.append((sid, self.metadata.copy(), step_name))
457
+ except Exception as e:
458
+ print(f"[ImageDocument] apply_edit: failed to snapshot current image for undo: {e}")
459
+
460
+ # Clear redo stack and delete files
461
+ sm = get_swap_manager()
462
+ for old_sid, _, _ in self._redo:
463
+ sm.delete_state(old_sid)
464
+ self._redo.clear()
465
+
466
+ # --- header-safe metadata merge ---
467
+ if metadata:
468
+ self.metadata = _merge_meta(self.metadata, metadata, step_name)
469
+ else:
470
+ self.metadata.setdefault("step_name", step_name)
471
+
472
+ # normalize new image
473
+ img = np.asarray(new_image, dtype=np.float32)
474
+ if img.size == 0:
475
+ raise ValueError("apply_edit: new image is empty")
476
+
477
+ img = ensure_contiguous(img)
478
+
479
+ _debug_log_undo(
480
+ "ImageDocument.apply_edit.apply",
481
+ doc_id=id(self),
482
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
483
+ new_shape=getattr(img, "shape", None),
484
+ undo_len_after=len(self._undo),
485
+ redo_len_after=len(self._redo),
486
+ step_name=step_name,
487
+ )
488
+
489
+ self.image = img
490
+ self.dirty = True
491
+ self.changed.emit()
492
+
493
+ dm = getattr(self, "_doc_manager", None)
494
+ try:
495
+ if dm is not None and hasattr(dm, "imageRegionUpdated"):
496
+ dm.imageRegionUpdated.emit(self, None)
497
+ except Exception:
498
+ pass
499
+
500
+
501
+
502
+ def undo(self) -> str | None:
503
+ # Extra-safe: if stack is empty, bail early.
504
+ if not self._undo:
505
+ _debug_log_undo(
506
+ "ImageDocument.undo.empty_stack",
507
+ doc_id=id(self),
508
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
509
+ )
510
+ return None
511
+
512
+ _debug_log_undo(
513
+ "ImageDocument.undo.entry",
514
+ doc_id=id(self),
515
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
516
+ undo_len=len(self._undo),
517
+ redo_len=len(self._redo),
518
+ top_step=self._undo[-1][2] if self._undo else None,
519
+ )
520
+
521
+ # Pop with an extra guard in case something cleared _undo between
522
+ # the check above and this call (re-entrancy / threading).
523
+ try:
524
+ prev_sid, prev_meta, name = self._undo.pop()
525
+ except IndexError:
526
+ _debug_log_undo(
527
+ "ImageDocument.undo.pop_index_error",
528
+ doc_id=id(self),
529
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
530
+ undo_len=len(self._undo),
531
+ redo_len=len(self._redo),
532
+ )
533
+ return None
534
+
535
+ # Load previous image from swap
536
+ sm = get_swap_manager()
537
+ prev_img = sm.load_state(prev_sid)
538
+
539
+ # We can delete the swap file now that we have it in RAM
540
+ # (unless we want to keep it for some reason, but standard undo consumes the state)
541
+ sm.delete_state(prev_sid)
542
+
543
+ if prev_img is None:
544
+ print(f"[ImageDocument] undo: failed to load swap state {prev_sid}")
545
+ return None
546
+
547
+ # Normalize previous image before using it
548
+ try:
549
+ prev_arr = np.asarray(prev_img, dtype=np.float32)
550
+ if prev_arr.size == 0:
551
+ raise ValueError("undo: previous image is empty")
552
+ prev_arr = np.ascontiguousarray(prev_arr)
553
+ except Exception as e:
554
+ print(f"[ImageDocument] undo: invalid prev_img in stack ({type(prev_img)}): {e}")
555
+ # Put it back so we don't corrupt history further?
556
+ # Actually if load failed we are in trouble.
557
+ return None
558
+
559
+ _debug_log_undo(
560
+ "ImageDocument.undo.normalized_prev",
561
+ doc_id=id(self),
562
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
563
+ prev_shape=getattr(prev_arr, "shape", None),
564
+ prev_dtype=getattr(prev_arr, "dtype", None),
565
+ step_name=name,
566
+ meta_step=prev_meta.get("step_name", None) if isinstance(prev_meta, dict) else None,
567
+ )
568
+
569
+ # Snapshot current state for redo (best-effort)
570
+ curr_img = self.image
571
+ curr_meta = self.metadata
572
+ try:
573
+ if curr_img is not None:
574
+ curr_arr = np.asarray(curr_img, dtype=np.float32)
575
+ curr_arr = np.ascontiguousarray(curr_arr)
576
+
577
+ # Save to swap for Redo
578
+ sid = sm.save_state(curr_arr)
579
+ if sid:
580
+ self._redo.append((sid, dict(curr_meta), name))
581
+ else:
582
+ # Handle None image? Should not happen usually
583
+ pass
584
+ except Exception as e:
585
+ print(f"[ImageDocument] undo: failed to snapshot current image for redo: {e}")
586
+
587
+ _debug_log_undo(
588
+ "ImageDocument.undo.before_apply",
589
+ doc_id=id(self),
590
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
591
+ curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
592
+ curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
593
+ )
594
+
595
+ self.image = prev_arr
596
+ self.metadata = dict(prev_meta or {})
597
+ self.dirty = True
598
+ try:
599
+ self.changed.emit()
600
+ except Exception:
601
+ pass
602
+
603
+ _debug_log_undo(
604
+ "ImageDocument.undo.after_apply",
605
+ doc_id=id(self),
606
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
607
+ new_shape=getattr(self.image, "shape", None),
608
+ new_dtype=getattr(self.image, "dtype", None),
609
+ undo_len=len(self._undo),
610
+ redo_len=len(self._redo),
611
+ )
612
+ return name
613
+
614
+
615
+ def redo(self) -> str | None:
616
+ if not self._redo:
617
+ return None
618
+
619
+ _debug_log_undo(
620
+ "ImageDocument.redo.entry",
621
+ doc_id=id(self),
622
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
623
+ redo_len=len(self._redo),
624
+ undo_len=len(self._undo),
625
+ top_step=self._redo[-1][2] if self._redo else None,
626
+ )
627
+
628
+ nxt_sid, nxt_meta, name = self._redo.pop()
629
+
630
+ # Load next image from swap
631
+ sm = get_swap_manager()
632
+ nxt_img = sm.load_state(nxt_sid)
633
+ sm.delete_state(nxt_sid)
634
+
635
+ if nxt_img is None:
636
+ print(f"[ImageDocument] redo: failed to load swap state {nxt_sid}")
637
+ return None
638
+
639
+ # Normalize next image before using it
640
+ try:
641
+ nxt_arr = np.asarray(nxt_img, dtype=np.float32)
642
+ if nxt_arr.size == 0:
643
+ raise ValueError("redo: next image is empty")
644
+ nxt_arr = np.ascontiguousarray(nxt_arr)
645
+ except Exception as e:
646
+ print(f"[ImageDocument] redo: invalid nxt_img in stack ({type(nxt_img)}): {e}")
647
+ return None
648
+
649
+ _debug_log_undo(
650
+ "ImageDocument.redo.normalized_next",
651
+ doc_id=id(self),
652
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
653
+ nxt_shape=getattr(nxt_arr, "shape", None),
654
+ nxt_dtype=getattr(nxt_arr, "dtype", None),
655
+ step_name=name,
656
+ meta_step=nxt_meta.get("step_name", None) if isinstance(nxt_meta, dict) else None,
657
+ )
658
+ curr_img = self.image
659
+ curr_meta = self.metadata
660
+ try:
661
+ if curr_img is not None:
662
+ curr_arr = np.asarray(curr_img, dtype=np.float32)
663
+ curr_arr = np.ascontiguousarray(curr_arr)
664
+
665
+ # Save current to swap for Undo
666
+ sid = sm.save_state(curr_arr)
667
+ if sid:
668
+ self._undo.append((sid, dict(curr_meta), name))
669
+ else:
670
+ pass
671
+ except Exception as e:
672
+ print(f"[ImageDocument] redo: failed to snapshot current image for undo: {e}")
673
+ _debug_log_undo(
674
+ "ImageDocument.redo.before_apply",
675
+ doc_id=id(self),
676
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
677
+ curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
678
+ curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
679
+ )
680
+ self.image = nxt_arr
681
+ self.metadata = dict(nxt_meta or {})
682
+ self.dirty = True
683
+ try:
684
+ self.changed.emit()
685
+ except Exception:
686
+ pass
687
+
688
+ _debug_log_undo(
689
+ "ImageDocument.redo.after_apply",
690
+ doc_id=id(self),
691
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
692
+ new_shape=getattr(self.image, "shape", None),
693
+ new_dtype=getattr(self.image, "dtype", None),
694
+ undo_len=len(self._undo),
695
+ redo_len=len(self._redo),
696
+ )
697
+
698
+ return name
699
+
700
+
701
+ # existing methods unchanged below...
702
+ def set_image(self, img: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
703
+ """
704
+ Treat set_image as an editing operation that records history.
705
+ (History previews and “Restore from History” call this.)
706
+ """
707
+ self.apply_edit(img, metadata or {}, step_name=step_name)
708
+
709
+
710
+ # --- Add to ImageDocument (public history helpers) -------------------
711
+
712
+ def get_undo_stack(self):
713
+ """
714
+ Oldest → newest *before* current image.
715
+ Returns [(swap_id, meta, name), ...]
716
+ """
717
+ out = []
718
+ for sid, meta, name in self._undo:
719
+ out.append((sid, meta or {}, name or "Unnamed"))
720
+ return out
721
+
722
+ def display_name(self) -> str:
723
+ # Prefer an explicit display name if set
724
+ dn = self.metadata.get("display_name")
725
+ if dn:
726
+ return dn
727
+ p = self.metadata.get("file_path")
728
+ return os.path.basename(p) if p else "Untitled"
729
+
730
+
731
+ def _dm_json_sanitize(obj):
732
+ """Tiny, local JSON sanitizer: keeps size small & avoids numpy/astropy weirdness."""
733
+ import numpy as _np
734
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
735
+ return obj
736
+ if isinstance(obj, dict):
737
+ return {str(k): _dm_json_sanitize(v) for k, v in obj.items()}
738
+ if isinstance(obj, (list, tuple)):
739
+ return [_dm_json_sanitize(x) for x in obj]
740
+ # numpy array → small placeholder
741
+ try:
742
+ if isinstance(obj, _np.ndarray):
743
+ return {"__nd__": True, "shape": list(obj.shape), "dtype": str(obj.dtype)}
744
+ # numpy scalar
745
+ if hasattr(obj, "item"):
746
+ return obj.item()
747
+ except Exception:
748
+ pass
749
+ try:
750
+ return repr(obj)
751
+ except Exception:
752
+ return str(type(obj))
753
+
754
+ def _compute_cropped_wcs(parent_hdr_like: dict | "fits.Header",
755
+ x: int, y: int, w: int, h: int):
756
+ """
757
+ Returns a plain dict WCS header reflecting a pure pixel crop by (x,y,w,h).
758
+ Keeps CD/CDELT/PC/CRVAL as-is and shifts CRPIX by (+/-) the crop offset.
759
+ Also sets NAXIS1/2 to (w,h) and records custom CROPX/CROPY.
760
+ """
761
+ try:
762
+ from astropy.io.fits import Header # type: ignore
763
+ except Exception:
764
+ Header = None # type: ignore
765
+
766
+ # Normalize to a dict of key->value (no comments needed for the drag payload)
767
+ if Header is not None and isinstance(parent_hdr_like, Header):
768
+ base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
769
+ elif isinstance(parent_hdr_like, dict):
770
+ # If it’s an XISF-like dict, try to pull a FITSKeywords block first
771
+ fk = parent_hdr_like.get("FITSKeywords")
772
+ if isinstance(fk, dict) and fk:
773
+ base = {}
774
+ for k, arr in fk.items():
775
+ try:
776
+ base[k] = (arr or [{}])[0].get("value", None)
777
+ except Exception:
778
+ pass
779
+ else:
780
+ base = dict(parent_hdr_like)
781
+ else:
782
+ base = {}
783
+
784
+ # Shift CRPIX by the crop offset (ROI origin is (x,y) in full-image pixels)
785
+ crpix1 = base.get("CRPIX1")
786
+ crpix2 = base.get("CRPIX2")
787
+ if isinstance(crpix1, (int, float)) and isinstance(crpix2, (int, float)):
788
+ new_crpix1 = float(crpix1) - float(x)
789
+ new_crpix2 = float(crpix2) - float(y)
790
+ base["CRPIX1"] = new_crpix1
791
+ base["CRPIX2"] = new_crpix2
792
+ else:
793
+ new_crpix1 = crpix1
794
+ new_crpix2 = crpix2
795
+
796
+ # Update image size keys
797
+ base["NAXIS1"] = int(w)
798
+ base["NAXIS2"] = int(h)
799
+
800
+ # Optional helpful tags
801
+ base["CROPX"] = int(x)
802
+ base["CROPY"] = int(y)
803
+ base["SASKIND"] = "ROI-CROP"
804
+
805
+ # DEBUG: show how CRPIX changed for this crop
806
+ if _DEBUG_WCS:
807
+ print(f"[WCS DEBUG] _compute_cropped_wcs: roi=({x},{y},{w},{h})")
808
+ print(f" CRPIX1: {crpix1} -> {new_crpix1}")
809
+ print(f" CRPIX2: {crpix2} -> {new_crpix2}")
810
+ print("")
811
+
812
+ return base
813
+
814
+ import logging
815
+
816
+ log = logging.getLogger(__name__)
817
+
818
+ def _pick_header_for_save(meta: dict) -> fits.Header | None:
819
+ """
820
+ Choose the best header to write to disk.
821
+
822
+ Priority:
823
+ 1. 'wcs_header' – if you stash a solved header here
824
+ 2. 'fits_header' – common name after ASTAP / plate solve
825
+ 3. 'original_header' – whatever came from disk
826
+ 4. 'header' – older code paths
827
+ """
828
+ if not isinstance(meta, dict):
829
+ return None
830
+
831
+ for key in ("wcs_header", "fits_header", "original_header", "header"):
832
+ hdr = meta.get(key)
833
+ if isinstance(hdr, fits.Header):
834
+ log.debug("[_pick_header_for_save] using %s (%d cards)", key, len(hdr))
835
+ return hdr
836
+
837
+ log.debug("[_pick_header_for_save] no fits.Header found in metadata; "
838
+ "will let legacy_save_image fall back.")
839
+ return None
840
+
841
+ def _snapshot_header_for_metadata(meta: dict):
842
+ """
843
+ If meta contains a header under common keys, add a JSON-safe snapshot at
844
+ meta["__header_snapshot__"] so viewers/project IO never choke.
845
+ """
846
+ if not isinstance(meta, dict):
847
+ return
848
+ if "__header_snapshot__" in meta:
849
+ return
850
+
851
+ hdr = (meta.get("original_header")
852
+ or meta.get("fits_header")
853
+ or meta.get("header"))
854
+
855
+ if hdr is None:
856
+ return
857
+
858
+ snap = None
859
+
860
+ # Try astropy Header (without hard dependency)
861
+ try:
862
+ from astropy.io.fits import Header # type: ignore
863
+ except Exception:
864
+ Header = None # type: ignore
865
+
866
+ try:
867
+ if Header is not None and isinstance(hdr, Header):
868
+ cards = []
869
+ for k in hdr.keys():
870
+ try:
871
+ val = hdr[k]
872
+ except Exception:
873
+ val = ""
874
+ try:
875
+ cmt = hdr.comments[k] if hasattr(hdr, "comments") else ""
876
+ except Exception:
877
+ cmt = ""
878
+ cards.append([str(k), _dm_json_sanitize(val), str(cmt)])
879
+ snap = {"format": "fits-cards", "cards": cards}
880
+ elif isinstance(hdr, dict):
881
+ # Already a dict-like header (e.g., XISF style)
882
+ snap = {"format": "dict",
883
+ "items": {str(k): _dm_json_sanitize(v) for k, v in hdr.items()}}
884
+ else:
885
+ # Last resort string
886
+ snap = {"format": "repr", "text": repr(hdr)}
887
+ except Exception:
888
+ try:
889
+ snap = {"format": "repr", "text": str(hdr)}
890
+ except Exception:
891
+ snap = None
892
+
893
+ if snap:
894
+ meta["__header_snapshot__"] = snap
895
+
896
+ def _safe_str(x) -> str:
897
+ try:
898
+ return str(x)
899
+ except Exception:
900
+ try:
901
+ return repr(x)
902
+ except Exception:
903
+ return "<unrepr>"
904
+
905
+ def _fits_table_to_csv(hdu, out_csv_path: str, max_rows: int = 250000):
906
+ """
907
+ Convert a FITS (Bin)Table HDU to CSV. Returns the CSV path.
908
+ Limits to max_rows to avoid giant dumps.
909
+ """
910
+ try:
911
+ data = hdu.data
912
+ if data is None:
913
+ raise RuntimeError("No table data")
914
+
915
+ # Astropy table→numpy recarray is fine; iterate to strings
916
+ rec = np.asarray(data)
917
+ nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
918
+ if nrows == 0:
919
+ # write headers only
920
+ names = [str(n) for n in (getattr(data, "names", None) or [])]
921
+ with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
922
+ if names:
923
+ f.write(",".join(names) + "\n")
924
+ return out_csv_path
925
+
926
+ # Column names (fallback to numeric if missing)
927
+ names = list(getattr(data, "names", [])) or [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else len(rec.dtype.names or []))]
928
+
929
+ import csv
930
+ with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
931
+ w = csv.writer(f)
932
+ w.writerow([_safe_str(n) for n in names])
933
+
934
+ # Decide how to iterate rows depending on structured vs 2D numeric
935
+ if rec.dtype.names: # structured/record array
936
+ for ri in range(min(nrows, max_rows)):
937
+ row = rec[ri]
938
+ w.writerow([_safe_str(row[name]) for name in rec.dtype.names])
939
+ else:
940
+ # plain 2D numeric table
941
+ if rec.ndim == 1:
942
+ for ri in range(min(nrows, max_rows)):
943
+ w.writerow([_safe_str(rec[ri])])
944
+ else:
945
+ for ri in range(min(nrows, max_rows)):
946
+ w.writerow([_safe_str(x) for x in rec[ri]])
947
+
948
+ return out_csv_path
949
+ except Exception as e:
950
+ raise
951
+
952
+ def _fits_table_to_rows_headers(hdu, max_rows: int = 500000) -> tuple[list[list], list[str]]:
953
+ """
954
+ Convert a FITS (Bin)Table/Table HDU to (rows, headers).
955
+ Truncates to max_rows for safety.
956
+ """
957
+ data = hdu.data
958
+ if data is None:
959
+ return [], []
960
+ rec = np.asarray(data)
961
+ # Column names
962
+ names = list(getattr(data, "names", [])) or (
963
+ list(rec.dtype.names) if rec.dtype.names else [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else 1)]
964
+ )
965
+ rows = []
966
+ nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
967
+ nrows = min(nrows, max_rows)
968
+ if rec.dtype.names: # structured array
969
+ for ri in range(nrows):
970
+ row = rec[ri]
971
+ rows.append([_safe_str(row[name]) for name in rec.dtype.names])
972
+ else:
973
+ # numeric 2D/1D table
974
+ if rec.ndim == 1:
975
+ for ri in range(nrows):
976
+ rows.append([_safe_str(rec[ri])])
977
+ else:
978
+ for ri in range(nrows):
979
+ rows.append([_safe_str(x) for x in rec[ri]])
980
+ return rows, [str(n) for n in names]
981
+
982
+
983
+ _shown_raw_preview_paths: set[str] = set()
984
+ _raw_preview_boxes: list[QMessageBox] = [] # prevent GC while shown
985
+
986
+ def _show_raw_preview_warning_nonmodal(path: str):
987
+ parent = QApplication.activeWindow()
988
+ box = QMessageBox(parent)
989
+ box.setIcon(QMessageBox.Icon.Warning)
990
+ box.setWindowTitle("RAW preview loaded")
991
+ box.setText(
992
+ "Linear RAW decoding failed for:\n"
993
+ f"{path}\n\n"
994
+ "Showing the camera’s embedded JPEG preview instead "
995
+ "(8-bit, non-linear). Some processing tools may be limited."
996
+ )
997
+ box.setStandardButtons(QMessageBox.StandardButton.Ok)
998
+ box.setWindowModality(Qt.WindowModality.NonModal) # ← fix here
999
+ box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
1000
+
1001
+ _raw_preview_boxes.append(box)
1002
+ box.finished.connect(lambda _=None, b=box: _raw_preview_boxes.remove(b))
1003
+ box.show()
1004
+
1005
+ def maybe_warn_raw_preview(path: str, header):
1006
+ if not header or not bool(header.get("RAW_PREV", False)):
1007
+ return
1008
+ if path in _shown_raw_preview_paths:
1009
+ return
1010
+ _shown_raw_preview_paths.add(path)
1011
+ QTimer.singleShot(0, lambda p=path: _show_raw_preview_warning_nonmodal(p))
1012
+
1013
+ _np = np
1014
+
1015
+ class _RoiViewDocument(ImageDocument):
1016
+ def __init__(self, parent_doc: ImageDocument, roi: tuple[int,int,int,int], name_suffix: str = " (Preview)"):
1017
+ x, y, w, h = roi
1018
+ meta = dict(parent_doc.metadata or {})
1019
+ base = parent_doc.display_name()
1020
+ meta["display_name"] = f"{base}{name_suffix}"
1021
+ meta.setdefault("image_meta", {})
1022
+ meta["image_meta"] = dict(meta["image_meta"], readonly=True, view_kind="roi-preview")
1023
+
1024
+ super().__init__(_np.zeros((max(1,h), max(1,w), 3), dtype=_np.float32), meta, parent=parent_doc.parent())
1025
+
1026
+ self._parent_doc = parent_doc
1027
+ self._roi = ( x, y, w, h )
1028
+ self._roi_info = {"parent_doc": parent_doc, "roi": tuple(self._roi)}
1029
+ self.metadata["_roi_bounds"] = tuple(self._roi)
1030
+ imi = dict(self.metadata.get("image_meta") or {})
1031
+ imi.update({"roi": tuple(self._roi), "view_kind": "roi-preview"})
1032
+ self.metadata["image_meta"] = imi
1033
+
1034
+ # build and store an ROI-shifted WCS header snapshot to use if detached
1035
+ try:
1036
+ phdr = (parent_doc.metadata.get("original_header")
1037
+ or parent_doc.metadata.get("fits_header")
1038
+ or parent_doc.metadata.get("header"))
1039
+ rx, ry, rw, rh = self._roi
1040
+ roi_wcs = _compute_cropped_wcs(phdr, rx, ry, rw, rh)
1041
+ self.metadata["roi_wcs_header"] = roi_wcs # plain dict, drop-in safe
1042
+
1043
+ # 🔴 KEY FIX: for a standalone ROI doc, treat this cropped WCS
1044
+ # as the "original_header" so view-drops / duplicates inherit it.
1045
+ if phdr is not None:
1046
+ # optional: preserve the full parent header
1047
+ self.metadata.setdefault("parent_full_header", phdr)
1048
+ self.metadata["original_header"] = roi_wcs
1049
+ try:
1050
+ from .doc_manager import _snapshot_header_for_metadata # if you move it, adjust import
1051
+ except Exception:
1052
+ _snapshot_header_for_metadata = None
1053
+
1054
+ try:
1055
+ if _snapshot_header_for_metadata is not None:
1056
+ _snapshot_header_for_metadata(self.metadata)
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ # DEBUG: log parent vs ROI WCS
1061
+ if _DEBUG_WCS:
1062
+ base_name = parent_doc.display_name() if hasattr(parent_doc, "display_name") else "<parent>"
1063
+ print(f"[WCS DEBUG] _RoiViewDocument.__init__: parent='{base_name}' roi={self._roi}")
1064
+ _debug_log_wcs_context(" parent_header", phdr)
1065
+ _debug_log_wcs_context(" roi_header", self.metadata)
1066
+ except Exception as e:
1067
+ if _DEBUG_WCS:
1068
+ print(f"[WCS DEBUG] _RoiViewDocument.__init__ exception: {e}")
1069
+ pass
1070
+ self.metadata["image_meta"] = imi
1071
+ # NEW: transient preview overlay for this ROI (None means "show parent slice")
1072
+ self._preview_override: _np.ndarray | None = None
1073
+
1074
+ self._pundo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
1075
+ self._predo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
1076
+
1077
+ @property
1078
+ def image(self):
1079
+ p = self._parent_doc
1080
+ if p is None or getattr(p, "image", None) is None:
1081
+ return None
1082
+ x, y, w, h = self._roi
1083
+ # If a preview override exists, show it; else show the live parent slice
1084
+ return self._preview_override if self._preview_override is not None else p.image[y:y+h, x:x+w]
1085
+
1086
+ @image.setter
1087
+ def image(self, _val):
1088
+ # ignore: writes should use DocManager(update/commit) paths
1089
+ pass
1090
+
1091
+
1092
+ def commit_to_parent(self, new_image: _np.ndarray | None = None,
1093
+ metadata: dict | None = None, step_name: str = "Edit"):
1094
+ """
1095
+ Paste current preview (or provided new_image) back into the parent image
1096
+ with proper undo and region repaint.
1097
+ """
1098
+ parent = getattr(self, "_parent_doc", None)
1099
+ if parent is None or parent.image is None:
1100
+ return
1101
+
1102
+ x, y, w, h = self._roi
1103
+ # choose source
1104
+ src = new_image
1105
+ if src is None:
1106
+ src = self._preview_override if self._preview_override is not None else parent.image[y:y+h, x:x+w]
1107
+
1108
+ img = _np.asarray(src, dtype=_np.float32, copy=False)
1109
+ base = parent.image
1110
+
1111
+ # channel reconciliation
1112
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
1113
+ img = img[..., 0]
1114
+ if base.ndim == 3 and img.ndim == 2:
1115
+ img = _np.repeat(img[..., None], base.shape[2], axis=2)
1116
+ if img.shape[:2] != (h, w):
1117
+ raise ValueError(f"Commit shape {img.shape[:2]} does not match ROI {(h, w)}")
1118
+
1119
+ # push undo on parent and paste
1120
+ parent._undo.append((base.copy(), parent.metadata.copy(), step_name))
1121
+ parent._redo.clear()
1122
+ if metadata: parent.metadata.update(metadata)
1123
+ parent.metadata.setdefault("step_name", step_name)
1124
+
1125
+ new_full = base.copy()
1126
+ new_full[y:y+h, x:x+w] = img
1127
+ parent.image = new_full
1128
+ try: parent.changed.emit()
1129
+ except Exception as e:
1130
+ import logging
1131
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1132
+
1133
+ # notify region update + repaint
1134
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1135
+ if dm is not None:
1136
+ try: dm.imageRegionUpdated.emit(parent, (x, y, w, h))
1137
+ except Exception as e:
1138
+ import logging
1139
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1140
+
1141
+
1142
+ # --- helper to snapshot what's currently visible in the Preview
1143
+ def _current_preview_copy(self) -> _np.ndarray:
1144
+ img = self.image # property: returns override or parent slice
1145
+ if img is None:
1146
+ return _np.zeros((1, 1), dtype=_np.float32)
1147
+ arr = _np.asarray(img, dtype=_np.float32)
1148
+ return _np.ascontiguousarray(arr)
1149
+
1150
+ # === KEEP YOUR WORKING BODY; only 3 added lines are marked "NEW" ===
1151
+ def apply_edit(self, new_image, metadata=None, step_name="Edit"):
1152
+ x, y, w, h = self._roi
1153
+ img = np.asarray(new_image, dtype=np.float32, copy=False)
1154
+ base = self._parent_doc.image
1155
+
1156
+ _debug_log_undo(
1157
+ "_RoiViewDocument.apply_edit.entry",
1158
+ roi=(x, y, w, h),
1159
+ parent_id=id(self._parent_doc) if self._parent_doc is not None else None,
1160
+ roi_doc_id=id(self),
1161
+ new_shape=getattr(img, "shape", None),
1162
+ step_name=step_name,
1163
+ pundo_len=len(self._pundo),
1164
+ predo_len=len(self._predo),
1165
+ )
1166
+ if base is not None:
1167
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
1168
+ img = img[..., 0]
1169
+ if base.ndim == 3 and img.ndim == 2:
1170
+ img = np.repeat(img[..., None], base.shape[2], axis=2)
1171
+ if img.shape[:2] != (h, w):
1172
+ raise ValueError(f"Preview edit shape {img.shape[:2]} != ROI {(h, w)}")
1173
+
1174
+ img = np.ascontiguousarray(img)
1175
+
1176
+ # snapshot current visible preview for local undo
1177
+ self._pundo.append((self._current_preview_copy(), dict(self.metadata), step_name))
1178
+ self._predo.clear()
1179
+
1180
+ self._preview_override = img
1181
+ _debug_log_undo(
1182
+ "_RoiViewDocument.apply_edit.after",
1183
+ roi=(x, y, w, h),
1184
+ preview_shape=getattr(self._preview_override, "shape", None),
1185
+ pundo_len=len(self._pundo),
1186
+ predo_len=len(self._predo),
1187
+ step_name=step_name,
1188
+ )
1189
+
1190
+ if metadata:
1191
+ self.metadata.update(metadata)
1192
+ self.metadata.setdefault("step_name", step_name)
1193
+
1194
+ # 1) notify ROI listeners (e.g. the main window via _on_roi_changed)
1195
+ try:
1196
+ self.changed.emit()
1197
+ except Exception:
1198
+ pass
1199
+
1200
+ # 2) optionally: tell DocManager "ROI preview changed" using base doc + ROI
1201
+ dm = getattr(self, "_doc_manager", None)
1202
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1203
+ try:
1204
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1205
+ except Exception:
1206
+ pass
1207
+
1208
+
1209
+
1210
+ def _parent(self):
1211
+ return getattr(self, "_parent_doc", None)
1212
+
1213
+ def can_undo(self) -> bool:
1214
+ # Prefer local preview history if present
1215
+ if self._pundo:
1216
+ return True
1217
+ # Otherwise mirror parent’s history
1218
+ p = getattr(self, "_parent_doc", None)
1219
+ if p is not None and hasattr(p, "can_undo"):
1220
+ try:
1221
+ return bool(p.can_undo())
1222
+ except Exception:
1223
+ return False
1224
+ return False
1225
+
1226
+ def can_redo(self) -> bool:
1227
+ if self._predo:
1228
+ return True
1229
+ p = getattr(self, "_parent_doc", None)
1230
+ if p is not None and hasattr(p, "can_redo"):
1231
+ try:
1232
+ return bool(p.can_redo())
1233
+ except Exception:
1234
+ return False
1235
+ return False
1236
+
1237
+ def last_undo_name(self) -> str | None:
1238
+ if self._pundo:
1239
+ return self._pundo[-1][2]
1240
+ p = getattr(self, "_parent_doc", None)
1241
+ if p is not None and hasattr(p, "last_undo_name"):
1242
+ try:
1243
+ return p.last_undo_name()
1244
+ except Exception:
1245
+ return None
1246
+ return None
1247
+
1248
+ def last_redo_name(self) -> str | None:
1249
+ if self._predo:
1250
+ return self._predo[-1][2]
1251
+ p = getattr(self, "_parent_doc", None)
1252
+ if p is not None and hasattr(p, "last_redo_name"):
1253
+ try:
1254
+ return p.last_redo_name()
1255
+ except Exception:
1256
+ return None
1257
+ return None
1258
+
1259
+ def undo(self) -> str | None:
1260
+ # --- Case 1: ROI-local preview history ---
1261
+ if self._pundo:
1262
+ _debug_log_undo(
1263
+ "_RoiViewDocument.undo.local.entry",
1264
+ roi=self._roi,
1265
+ roi_doc_id=id(self),
1266
+ pundo_len=len(self._pundo),
1267
+ predo_len=len(self._predo),
1268
+ )
1269
+ # move current → redo; pop undo → current
1270
+ curr = self._current_preview_copy()
1271
+ self._predo.append((curr, dict(self.metadata), self._pundo[-1][2]))
1272
+
1273
+ prev_img, prev_meta, name = self._pundo.pop()
1274
+ self._preview_override = prev_img
1275
+ self.metadata = dict(prev_meta)
1276
+ _debug_log_undo(
1277
+ "_RoiViewDocument.undo.local.apply",
1278
+ roi=self._roi,
1279
+ new_preview_shape=getattr(prev_img, "shape", None),
1280
+ pundo_len=len(self._pundo),
1281
+ predo_len=len(self._predo),
1282
+ name=name,
1283
+ )
1284
+ try:
1285
+ self.changed.emit()
1286
+ except Exception:
1287
+ pass
1288
+
1289
+ dm = getattr(self, "_doc_manager", None)
1290
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1291
+ try:
1292
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1293
+ except Exception:
1294
+ pass
1295
+ return name
1296
+
1297
+ # --- Case 2: no ROI-local history → delegate to parent ---
1298
+ parent = getattr(self, "_parent_doc", None)
1299
+ if parent is None or not hasattr(parent, "undo"):
1300
+ return None
1301
+ _debug_log_undo(
1302
+ "_RoiViewDocument.undo.parent.entry",
1303
+ roi=self._roi,
1304
+ roi_doc_id=id(self),
1305
+ parent_id=id(parent),
1306
+ parent_undo_len=len(getattr(parent, "_undo", [])),
1307
+ parent_redo_len=len(getattr(parent, "_redo", [])),
1308
+ )
1309
+
1310
+ name = parent.undo()
1311
+
1312
+ # After parent changes, clear override so we show the new parent slice
1313
+ self._preview_override = None
1314
+
1315
+ try:
1316
+ self.changed.emit()
1317
+ except Exception:
1318
+ pass
1319
+
1320
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1321
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1322
+ try:
1323
+ dm.previewRepaintRequested.emit(parent, self._roi)
1324
+ except Exception:
1325
+ pass
1326
+ return name
1327
+
1328
+ def redo(self) -> str | None:
1329
+ # --- Case 1: ROI-local preview history ---
1330
+ if self._predo:
1331
+ # move current → undo; pop redo → current
1332
+ curr = self._current_preview_copy()
1333
+ self._pundo.append((curr, dict(self.metadata), self._predo[-1][2]))
1334
+
1335
+ nxt_img, nxt_meta, name = self._predo.pop()
1336
+ self._preview_override = nxt_img
1337
+ self.metadata = dict(nxt_meta)
1338
+
1339
+ try:
1340
+ self.changed.emit()
1341
+ except Exception:
1342
+ pass
1343
+
1344
+ dm = getattr(self, "_doc_manager", None)
1345
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1346
+ try:
1347
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1348
+ except Exception:
1349
+ pass
1350
+ return name
1351
+
1352
+ # --- Case 2: delegate to parent’s redo ---
1353
+ parent = getattr(self, "_parent_doc", None)
1354
+ if parent is None or not hasattr(parent, "redo"):
1355
+ return None
1356
+
1357
+ name = parent.redo()
1358
+
1359
+ # Parent changed → reset override and repaint
1360
+ self._preview_override = None
1361
+
1362
+ try:
1363
+ self.changed.emit()
1364
+ except Exception:
1365
+ pass
1366
+
1367
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1368
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1369
+ try:
1370
+ dm.previewRepaintRequested.emit(parent, self._roi)
1371
+ except Exception:
1372
+ pass
1373
+ return name
1374
+
1375
+
1376
+
1377
+ class LiveViewDocument(QObject):
1378
+ """
1379
+ Drop-in proxy that mirrors an ImageDocument API but always resolves
1380
+ via DocManager + view to the ROI-aware document (if a Preview tab is active).
1381
+ Reads: delegate to current resolved doc.
1382
+ Writes: use DocManager.update_active_document(...) so ROI is pasted back.
1383
+ """
1384
+ changed = pyqtSignal()
1385
+
1386
+ def __init__(self, doc_manager: "DocManager", view, base_doc: "ImageDocument"):
1387
+ super().__init__(parent=base_doc.parent())
1388
+ self._dm = doc_manager
1389
+ self._view = view # ImageSubWindow widget
1390
+ self._base = base_doc # true ImageDocument
1391
+
1392
+ # Bridge base document change signals (ROI wrappers rarely emit)
1393
+ try:
1394
+ base_doc.changed.connect(self.changed.emit)
1395
+ except Exception:
1396
+ pass
1397
+
1398
+ # ---- core resolver ----
1399
+ def _current(self):
1400
+ try:
1401
+ d = self._dm.get_document_for_view(self._view)
1402
+ return d or self._base
1403
+ except Exception:
1404
+ return self._base
1405
+
1406
+ # ---- common API surface (reads) ----
1407
+ @property
1408
+ def image(self):
1409
+ d = self._current()
1410
+ return getattr(d, "image", None)
1411
+
1412
+ @property
1413
+ def metadata(self):
1414
+ d = self._current()
1415
+ return getattr(d, "metadata", {}) or {}
1416
+
1417
+ def display_name(self):
1418
+ d = self._current()
1419
+ if hasattr(d, "display_name"):
1420
+ try:
1421
+ return d.display_name()
1422
+ except Exception:
1423
+ pass
1424
+ return self._base.display_name() if hasattr(self._base, "display_name") else "Untitled"
1425
+
1426
+ # Mask access stays consistent with whichever doc is current
1427
+ def get_active_mask(self):
1428
+ d = self._current()
1429
+ if hasattr(d, "get_active_mask"):
1430
+ try:
1431
+ return d.get_active_mask()
1432
+ except Exception:
1433
+ return None
1434
+ return None
1435
+
1436
+ @property
1437
+ def masks(self):
1438
+ d = self._current()
1439
+ return getattr(d, "masks", {})
1440
+
1441
+ @property
1442
+ def active_mask_id(self):
1443
+ d = self._current()
1444
+ return getattr(d, "active_mask_id", None)
1445
+
1446
+ # ---- writes route through DocManager so ROI is honored ----
1447
+ def apply_edit(self, new_image, metadata=None, step_name="Edit"):
1448
+ #print("[LiveViewDocument] apply_edit called, routing via DocManager")
1449
+ self._dm.update_active_document(new_image, dict(metadata or {}), step_name)
1450
+
1451
+ # ---- history helpers (optional pass-throughs) ----
1452
+ def can_undo(self):
1453
+ d = self._current()
1454
+ return bool(getattr(d, "can_undo", lambda: False)())
1455
+
1456
+ def can_redo(self):
1457
+ d = self._current()
1458
+ return bool(getattr(d, "can_redo", lambda: False)())
1459
+
1460
+ def last_undo_name(self):
1461
+ d = self._current()
1462
+ return getattr(d, "last_undo_name", lambda: None)()
1463
+
1464
+ def last_redo_name(self):
1465
+ d = self._current()
1466
+ return getattr(d, "last_redo_name", lambda: None)()
1467
+
1468
+ def undo(self):
1469
+ d = self._current()
1470
+ _debug_log_undo(
1471
+ "LiveViewDocument.undo.call",
1472
+ live_id=id(self),
1473
+ resolved_type=type(d).__name__ if d is not None else None,
1474
+ resolved_id=id(d) if d is not None else None,
1475
+ is_roi=isinstance(d, _RoiViewDocument),
1476
+ has_undo=getattr(d, "can_undo", lambda: False)(),
1477
+ )
1478
+ return getattr(d, "undo", lambda: None)()
1479
+
1480
+ def redo(self):
1481
+ d = self._current()
1482
+ _debug_log_undo(
1483
+ "LiveViewDocument.redo.call",
1484
+ live_id=id(self),
1485
+ resolved_type=type(d).__name__ if d is not None else None,
1486
+ resolved_id=id(d) if d is not None else None,
1487
+ is_roi=isinstance(d, _RoiViewDocument),
1488
+ has_redo=getattr(d, "can_redo", lambda: False)(),
1489
+ )
1490
+ return getattr(d, "redo", lambda: None)()
1491
+
1492
+
1493
+ # ---- generic fallback so existing attributes keep working ----
1494
+ def __getattr__(self, name):
1495
+ # Prefer the current resolved doc, then base_doc
1496
+ d = object.__getattribute__(self, "_current")()
1497
+ if hasattr(d, name):
1498
+ return getattr(d, name)
1499
+ return getattr(self._base, name)
1500
+
1501
+ def _xisf_meta_to_fits_header(m: dict) -> fits.Header | None:
1502
+ """
1503
+ Best-effort: pull common WCS keys out of XISF FITSKeywords into a fits.Header.
1504
+ Returns None if nothing usable found.
1505
+ """
1506
+ fk = m.get("FITSKeywords", {}) if isinstance(m, dict) else {}
1507
+ if not fk:
1508
+ return None
1509
+
1510
+ want = (
1511
+ "WCSAXES", "CTYPE1", "CTYPE2", "CRPIX1", "CRPIX2",
1512
+ "CRVAL1", "CRVAL2", "CD1_1", "CD1_2", "CD2_1", "CD2_2",
1513
+ "CDELT1", "CDELT2", "PC1_1", "PC1_2", "PC2_1", "PC2_2",
1514
+ "A_ORDER", "B_ORDER"
1515
+ )
1516
+
1517
+ hdr = fits.Header()
1518
+ found = False
1519
+ for k in want:
1520
+ vlist = fk.get(k)
1521
+ if vlist and isinstance(vlist, list) and vlist[0].get("value") is not None:
1522
+ hdr[k] = vlist[0]["value"]
1523
+ found = True
1524
+
1525
+ # also pull SIP coeffs if present
1526
+ for k, vlist in fk.items():
1527
+ if k.startswith(("A_", "B_", "AP_", "BP_")) and vlist and vlist[0].get("value") is not None:
1528
+ try:
1529
+ hdr[k] = float(vlist[0]["value"])
1530
+ found = True
1531
+ except Exception:
1532
+ pass
1533
+
1534
+ return hdr if found else None
1535
+
1536
+ DEBUG_SAVE_DOCUMENT = False
1537
+
1538
+ def debug_dump_metadata_print(meta: dict, context: str = ""):
1539
+ if DEBUG_SAVE_DOCUMENT:
1540
+ print(f"\n===== METADATA DUMP ({context}) =====")
1541
+ if not isinstance(meta, dict):
1542
+ print(" (not a dict) ->", type(meta))
1543
+ print("====================================")
1544
+ return
1545
+
1546
+ keys = sorted(str(k) for k in meta.keys())
1547
+ print(" keys:", ", ".join(keys))
1548
+
1549
+ for key in keys:
1550
+ val = meta[key]
1551
+ if isinstance(val, fits.Header):
1552
+ print(f" {key}: fits.Header with {len(val.cards)} cards")
1553
+ else:
1554
+ print(f" {key}: {val!r} ({type(val).__name__})")
1555
+
1556
+ print("===== END METADATA DUMP ({}) =====".format(context))
1557
+
1558
+ class DocManager(QObject):
1559
+ documentAdded = pyqtSignal(object) # ImageDocument
1560
+ documentRemoved = pyqtSignal(object) # ImageDocument
1561
+ imageRegionUpdated = pyqtSignal(object, object) # (doc, roi_tuple_or_None)
1562
+ previewRepaintRequested = pyqtSignal(object, object)
1563
+
1564
+ activeBaseChanged = pyqtSignal(object) # emits ImageDocument | None
1565
+
1566
+ def __init__(self, image_manager=None, parent=None):
1567
+ super().__init__(parent)
1568
+ self.image_manager = image_manager
1569
+ self._roi_doc_cache = {}
1570
+ self._docs: list[ImageDocument] = []
1571
+ self._active_doc: ImageDocument | None = None
1572
+ self._mdi: "QMdiArea | None" = None # type: ignore
1573
+ def _on_region_updated(doc, roi):
1574
+ vw = self._active_view_widget()
1575
+ if vw is not None:
1576
+ try:
1577
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1578
+ vw.refresh_from_docman(doc=doc, roi=roi)
1579
+ else:
1580
+ vw._render()
1581
+ except Exception:
1582
+ pass
1583
+
1584
+ self.imageRegionUpdated.connect(_on_region_updated)
1585
+ self._by_uid = {}
1586
+ self._focused_base_doc: ImageDocument | None = None # <— NEW
1587
+
1588
+ def _do_preview_repaint(doc, roi):
1589
+ vw = self._active_view_widget()
1590
+ if vw is not None:
1591
+ try:
1592
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1593
+ vw.refresh_from_docman(doc=doc, roi=roi)
1594
+ else:
1595
+ vw._render()
1596
+ except Exception:
1597
+ pass
1598
+ self.previewRepaintRequested.connect(_do_preview_repaint)
1599
+
1600
+ def get_document_for_view(self, view):
1601
+ """
1602
+ Given an ImageSubWindow widget, return either:
1603
+ - the full base ImageDocument
1604
+ - or a cached ROI-wrapper doc if a Preview/ROI tab is active
1605
+ Works with both old (has_active_preview/current_preview_roi) and
1606
+ new (_active_roi_tuple) view APIs. Falls back to view.document.
1607
+ """
1608
+ # 1) Resolve a base document from the view
1609
+ base = (
1610
+ getattr(view, "base_document", None)
1611
+ or getattr(view, "_base_document", None)
1612
+ or getattr(view, "document", None)
1613
+ )
1614
+ if base is None:
1615
+ return None
1616
+
1617
+ # 2) Try to discover an ROI (support both APIs)
1618
+ roi = None
1619
+ try:
1620
+ if hasattr(view, "has_active_preview") and callable(view.has_active_preview):
1621
+ if view.has_active_preview():
1622
+ # preferred old API
1623
+ try:
1624
+ roi = view.current_preview_roi() # (x,y,w,h)
1625
+ except Exception:
1626
+ roi = None
1627
+ except Exception:
1628
+ pass
1629
+
1630
+ if roi is None:
1631
+ # new API candidate
1632
+ for attr in ("_active_roi_tuple", "current_roi_tuple", "selected_roi", "roi"):
1633
+ try:
1634
+ fn = getattr(view, attr, None)
1635
+ if callable(fn):
1636
+ r = fn()
1637
+ if r and len(r) == 4:
1638
+ roi = r
1639
+ break
1640
+ except Exception:
1641
+ pass
1642
+
1643
+ # 3) If no ROI, return the base doc
1644
+ if not roi:
1645
+ return base
1646
+
1647
+ # 4) Cache and return a lightweight ROI view doc
1648
+ try:
1649
+ x, y, w, h = map(int, roi)
1650
+ key = (id(base), id(view), (x, y, w, h))
1651
+ roi_doc = self._roi_doc_cache.get(key)
1652
+ if roi_doc is None:
1653
+ roi_doc = self._build_roi_document(base, (x, y, w, h))
1654
+ self._roi_doc_cache[key] = roi_doc
1655
+ return roi_doc
1656
+ except Exception:
1657
+ # If anything about ROI construction fails, fall back
1658
+ return base
1659
+
1660
+ def _invalidate_roi_cache(self, parent_doc, roi_tuple):
1661
+ """Drop cached ROI docs that overlap an updated region of parent_doc."""
1662
+ if not roi_tuple:
1663
+ # full-image change -> drop all for this parent
1664
+ dead = [k for k in self._roi_doc_cache.keys() if k[0] == id(parent_doc)]
1665
+ else:
1666
+ px, py, pw, ph = roi_tuple
1667
+ def _overlaps(a, b):
1668
+ ax, ay, aw, ah = a; bx, by, bw, bh = b
1669
+ return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
1670
+ dead = []
1671
+ for (parent_id, _view_id, aroi), _doc in list(self._roi_doc_cache.items()):
1672
+ if parent_id != id(parent_doc):
1673
+ continue
1674
+ if _overlaps(aroi, (px, py, pw, ph)):
1675
+ dead.append((parent_id, _view_id, aroi))
1676
+ for k in dead:
1677
+ self._roi_doc_cache.pop(k, None)
1678
+
1679
+ def get_document_by_uid(self, uid: str):
1680
+ return self._by_uid.get(uid)
1681
+
1682
+
1683
+ def _register_doc(self, doc):
1684
+ import weakref
1685
+ # Only ImageDocument needs the backref; tables can ignore it.
1686
+ if hasattr(doc, "image") or hasattr(doc, "apply_edit"):
1687
+ try:
1688
+ doc._doc_manager = weakref.proxy(self) # avoid cycles
1689
+ except Exception:
1690
+ doc._doc_manager = self # fallback
1691
+ self._docs.append(doc)
1692
+ if hasattr(doc, "uid"):
1693
+ self._by_uid[doc.uid] = doc
1694
+ self.documentAdded.emit(doc)
1695
+
1696
+ def _build_roi_document(self, base_doc, roi):
1697
+ #print("[DocManager] Building ROI view document")
1698
+ doc = _RoiViewDocument(base_doc, roi, name_suffix=" (Preview)")
1699
+ try:
1700
+ import weakref
1701
+ doc._doc_manager = weakref.proxy(self)
1702
+ except Exception:
1703
+ doc._doc_manager = self
1704
+
1705
+ # Repaint the active view on ROI preview changes, but DO NOT invalidate cache.
1706
+ try:
1707
+ #print("[DocManager] Connecting ROI view document change signal")
1708
+ import weakref
1709
+ dm_ref = weakref.ref(self)
1710
+ roi_tuple = tuple(map(int, roi))
1711
+
1712
+ def _on_roi_changed():
1713
+ dm = dm_ref()
1714
+ if dm is None:
1715
+ return
1716
+ vw = dm._active_view_widget()
1717
+ if vw is not None:
1718
+ try:
1719
+ # IMPORTANT: use the *parent* doc here, not the ROI wrapper
1720
+ base = getattr(doc, "_parent_doc", None) or doc
1721
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1722
+ vw.refresh_from_docman(doc=base, roi=roi_tuple)
1723
+ else:
1724
+ vw._render()
1725
+ except Exception:
1726
+ pass
1727
+
1728
+ doc.changed.connect(_on_roi_changed)
1729
+
1730
+ #print("[DocManager] ROI view document change signal connected")
1731
+ except Exception:
1732
+ print("[DocManager] Failed to connect ROI view document change signal")
1733
+ pass
1734
+
1735
+ return doc
1736
+
1737
+ def commit_active_preview_to_parent(self, metadata: dict | None = None, step_name: str = "Edit"):
1738
+ doc = self.get_active_document()
1739
+ if isinstance(doc, _RoiViewDocument):
1740
+ doc.commit_to_parent(None, metadata=metadata or {}, step_name=step_name)
1741
+ # after commit, force an immediate view repaint
1742
+ vw = self._active_view_widget()
1743
+ if vw is not None:
1744
+ try:
1745
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1746
+ vw.refresh_from_docman(doc=doc._parent_doc, roi=None)
1747
+ else:
1748
+ vw._render()
1749
+ except Exception:
1750
+ pass
1751
+
1752
+ def wrap_document_for_view(self, view, base_doc: ImageDocument) -> LiveViewDocument:
1753
+ """Return a live, ROI-aware proxy for this view."""
1754
+ return LiveViewDocument(self, view, base_doc)
1755
+
1756
+ def open_path(self, path: str):
1757
+ ext = os.path.splitext(path)[1].lower().lstrip('.')
1758
+ norm_ext = _normalize_ext(ext)
1759
+
1760
+ lower_path = path.lower()
1761
+ is_fits = lower_path.endswith((".fit", ".fits", ".fit.gz", ".fits.gz", ".fz"))
1762
+ is_xisf = (norm_ext == "xisf")
1763
+
1764
+ primary_doc = None
1765
+ created_any = False
1766
+
1767
+ # ---------- 1) Try the universal loader first (ALL formats) ----------
1768
+ img = header = bit_depth = is_mono = None
1769
+ meta = None
1770
+ try:
1771
+ # NEW: prefer metadata-aware return
1772
+ out = legacy_load_image(path, return_metadata=True)
1773
+ if out and len(out) == 5:
1774
+ img, header, bit_depth, is_mono, meta = out
1775
+ else:
1776
+ img, header, bit_depth, is_mono = out
1777
+ except TypeError:
1778
+ # legacy_load_image older signature → fall back
1779
+ try:
1780
+ img, header, bit_depth, is_mono = legacy_load_image(path)
1781
+ except Exception as e:
1782
+ print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
1783
+ except Exception as e:
1784
+ print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
1785
+
1786
+ maybe_warn_raw_preview(path, header)
1787
+
1788
+ if img is not None:
1789
+ if meta is None:
1790
+ meta = {
1791
+ "file_path": path,
1792
+ "original_header": header,
1793
+ "bit_depth": bit_depth,
1794
+ "is_mono": is_mono,
1795
+ "original_format": norm_ext,
1796
+ }
1797
+
1798
+ # NEW: attach WCS even for old loader
1799
+ meta = attach_wcs_to_metadata(meta, header)
1800
+
1801
+ _snapshot_header_for_metadata(meta)
1802
+
1803
+ img = _normalize_image_01(img)
1804
+ primary_doc = ImageDocument(img, meta)
1805
+ self._register_doc(primary_doc)
1806
+ created_any = True
1807
+
1808
+
1809
+
1810
+ # ---------- 2) FITS: enumerate HDUs (tables + extra images + ICC) ----------
1811
+ if is_fits:
1812
+ try:
1813
+ with fits.open(path, memmap=True) as hdul:
1814
+ base = os.path.basename(path)
1815
+
1816
+
1817
+ for i, hdu in enumerate(hdul):
1818
+ name_up = (getattr(hdu, "name", "PRIMARY") or "PRIMARY").upper()
1819
+ if primary_doc is not None and (i == 0 or name_up == "PRIMARY"):
1820
+
1821
+ continue
1822
+
1823
+ ext_hdr = hdu.header
1824
+ try:
1825
+ en = str(ext_hdr.get("EXTNAME", "")).strip()
1826
+ ev = ext_hdr.get("EXTVER", None)
1827
+ extname = f"{en}[{int(ev)}]" if (en and isinstance(ev, (int, np.integer))) else (en or "")
1828
+ except Exception:
1829
+ extname = ""
1830
+
1831
+ # --- Tables → TableDocument ---
1832
+ if isinstance(hdu, (fits.BinTableHDU, fits.TableHDU)):
1833
+ key_str = extname or f"HDU{i}"
1834
+ nice = key_str
1835
+ #print(f"[DocManager] HDU {i}: {type(hdu).__name__} '{nice}' → Table")
1836
+
1837
+ # Optional CSV export
1838
+ csv_name = f"{os.path.splitext(path)[0]}_{key_str}.csv".replace(" ", "_")
1839
+ try:
1840
+ _ = _fits_table_to_csv(hdu, csv_name)
1841
+ except Exception as e_csv:
1842
+ print(f"[DocManager] Table CSV export failed ({nice}): {e_csv}")
1843
+ csv_name = None
1844
+
1845
+ # Build in-app table
1846
+ try:
1847
+ rows, headers = _fits_table_to_rows_headers(hdu, max_rows=500000)
1848
+ tmeta = {
1849
+ "file_path": f"{path}::{key_str}",
1850
+ "original_header": ext_hdr,
1851
+ "original_format": "fits",
1852
+ "display_name": f"{base} {key_str} (Table)",
1853
+ "doc_type": "table",
1854
+ "table_csv": csv_name if (csv_name and os.path.exists(csv_name)) else None,
1855
+ }
1856
+ _snapshot_header_for_metadata(tmeta)
1857
+ tdoc = TableDocument(rows, headers, tmeta, parent=self.parent())
1858
+ self._register_doc(tdoc)
1859
+ try: tdoc.changed.emit()
1860
+ except Exception as e:
1861
+ import logging
1862
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1863
+ created_any = True
1864
+ #print(f"[DocManager] Added TableDocument: rows={len(rows)} cols={len(headers)} title='{tdoc.display_name()}'")
1865
+ except Exception as e_tab:
1866
+ print(f"[DocManager] Table HDU {nice} → in-app view failed: {e_tab}")
1867
+ continue # IMPORTANT: don’t treat a table as an image
1868
+
1869
+ # --- Not a table: ICC or image ---
1870
+ if hdu.data is None:
1871
+ #print(f"[DocManager] HDU {i} '{extname or f'HDU{i}'}' has no data — noted as aux")
1872
+ continue
1873
+
1874
+ arr = np.asanyarray(hdu.data)
1875
+ en_up = (extname or "").upper()
1876
+ is_probable_icc = ("ICC" in en_up or "PROFILE" in en_up)
1877
+
1878
+ # ICC ONLY if name suggests ICC/profile AND data is 1-D uint8
1879
+ if arr.ndim == 1 and arr.dtype == np.uint8 and is_probable_icc:
1880
+ try:
1881
+ icc_path = f"{os.path.splitext(path)[0]}_{extname or f'HDU{i}'}_.icc".replace(" ", "_")
1882
+ with open(icc_path, "wb") as f:
1883
+ f.write(arr.tobytes())
1884
+ #print(f"[DocManager] Extracted ICC profile → {icc_path}")
1885
+ created_any = True
1886
+ continue
1887
+ except Exception as e_icc:
1888
+ print(f"[DocManager] ICC export failed: {e_icc} — will try as image")
1889
+
1890
+ # Otherwise: treat as image doc
1891
+ try:
1892
+ if arr.dtype.kind in "ui":
1893
+ a = arr.astype(np.float32, copy=False) # NO normalization
1894
+ # optional: if you want to record original scale:
1895
+ ext_depth = f"{arr.dtype.itemsize*8}-bit {'unsigned' if arr.dtype.kind=='u' else 'signed'}"
1896
+ else:
1897
+ a = arr.astype(np.float32, copy=False) # floats preserved
1898
+ ext_depth = "32-bit floating point" if arr.dtype == np.float32 else "64-bit floating point"
1899
+
1900
+ ext_mono = bool(a.ndim == 2 or (a.ndim == 3 and a.shape[2] == 1))
1901
+ key_str = extname or f"HDU {i}"
1902
+ disp = f"{base} {key_str}"
1903
+
1904
+ aux_meta = {
1905
+ "file_path": f"{path}::{key_str}",
1906
+ "original_header": ext_hdr,
1907
+ "bit_depth": ext_depth,
1908
+ "is_mono": bool(ext_mono),
1909
+ "original_format": "fits",
1910
+ "image_meta": {"derived_from": path, "layer": key_str, "readonly": True},
1911
+ "display_name": disp,
1912
+ }
1913
+
1914
+ # NEW: attach WCS from this HDU header
1915
+ aux_meta = attach_wcs_to_metadata(aux_meta, ext_hdr)
1916
+
1917
+ _snapshot_header_for_metadata(aux_meta)
1918
+ a = _normalize_image_01(a)
1919
+ aux_doc = ImageDocument(a, aux_meta)
1920
+
1921
+ self._register_doc(aux_doc)
1922
+ try: aux_doc.changed.emit()
1923
+ except Exception as e:
1924
+ import logging
1925
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1926
+ created_any = True
1927
+
1928
+ except Exception as e_img:
1929
+ print(f"[DocManager] FITS HDU {i} image build failed: {e_img}")
1930
+ except Exception as _e:
1931
+ print(f"[DocManager] FITS HDU enumeration failed: {_e}")
1932
+
1933
+ # ---------- 3) XISF: create primary if needed, then enumerate extras ----------
1934
+ if is_xisf:
1935
+ try:
1936
+ # helpers
1937
+ def _bit_depth_from_dtype(dt: np.dtype) -> str:
1938
+ dt = np.dtype(dt)
1939
+ if dt == np.float32: return "32-bit floating point"
1940
+ if dt == np.float64: return "64-bit floating point"
1941
+ if dt == np.uint8: return "8-bit"
1942
+ if dt == np.uint16: return "16-bit"
1943
+ if dt == np.uint32: return "32-bit unsigned"
1944
+ return "32-bit floating point"
1945
+
1946
+ def _to_float32_01(arr: np.ndarray) -> np.ndarray:
1947
+ a = np.asarray(arr)
1948
+ if a.dtype == np.float32:
1949
+ return a
1950
+ if a.dtype.kind in "iu":
1951
+ return (a.astype(np.float32) / np.iinfo(a.dtype).max).clip(0.0, 1.0)
1952
+ return a.astype(np.float32, copy=False)
1953
+
1954
+ def _to_float32_preserve(arr: np.ndarray) -> np.ndarray:
1955
+ a = np.asarray(arr)
1956
+ return a if a.dtype == np.float32 else a.astype(np.float32, copy=False)
1957
+
1958
+ xisf = XISFReader(path)
1959
+ metas = xisf.get_images_metadata() or []
1960
+ base = os.path.basename(path)
1961
+
1962
+ # If legacy did NOT create a primary, build image #0 now
1963
+ if primary_doc is None and len(metas) >= 1:
1964
+ try:
1965
+ arr0 = xisf.read_image(0, data_format="channels_last")
1966
+ arr0_f32 = _to_float32_preserve(arr0)
1967
+ arr0_f32 = _normalize_image_01(arr0_f32)
1968
+ bd0 = _bit_depth_from_dtype(metas[0].get("dtype", arr0.dtype))
1969
+ is_mono0 = (arr0_f32.ndim == 2) or (arr0_f32.ndim == 3 and arr0_f32.shape[2] == 1)
1970
+
1971
+ # Friendly label for #0
1972
+ label0 = metas[0].get("id") or "Image[0]"
1973
+ md0 = {
1974
+ "file_path": f"{path}::XISF[0]",
1975
+ "original_header": metas[0], # will be sanitized
1976
+ "bit_depth": bd0,
1977
+ "is_mono": is_mono0,
1978
+ "original_format": "xisf",
1979
+ "image_meta": {"derived_from": path, "layer_index": 0, "readonly": True},
1980
+ "display_name": f"{base} {label0}",
1981
+ }
1982
+ # NEW: attach WCS if possible
1983
+ hdr0 = _xisf_meta_to_fits_header(metas[0])
1984
+ if hdr0 is not None:
1985
+ md0 = attach_wcs_to_metadata(md0, hdr0)
1986
+
1987
+ _snapshot_header_for_metadata(md0)
1988
+ primary_doc = ImageDocument(arr0_f32, md0)
1989
+ self._register_doc(primary_doc)
1990
+ try: primary_doc.changed.emit()
1991
+ except Exception as e:
1992
+ import logging
1993
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1994
+ created_any = True
1995
+
1996
+ except Exception as e0:
1997
+ print(f"[DocManager] XISF primary (index 0) open failed: {e0}")
1998
+
1999
+ # Add images 1..N-1 as siblings (even if primary came from legacy)
2000
+ for i in range(1, len(metas)):
2001
+ try:
2002
+ m = metas[i]
2003
+ arr = xisf.read_image(i, data_format="channels_last")
2004
+ arr_f32 = _to_float32_preserve(arr)
2005
+ arr_f32 = _normalize_image_01(arr_f32)
2006
+
2007
+ bd = _bit_depth_from_dtype(m.get("dtype", arr.dtype))
2008
+ is_mono_i = (arr_f32.ndim == 2) or (arr_f32.ndim == 3 and arr_f32.shape[2] == 1)
2009
+
2010
+ # Friendly label: prefer id, else EXTNAME/EXTVER in FITSKeywords, else index
2011
+ label = m.get("id") or None
2012
+ if not label:
2013
+ try:
2014
+ fk = m.get("FITSKeywords", {})
2015
+ en = (fk.get("EXTNAME") or [{}])[0].get("value", "")
2016
+ ev = (fk.get("EXTVER") or [{}])[0].get("value", "")
2017
+ if en:
2018
+ label = f"{en}[{ev}]" if ev else en
2019
+ except Exception:
2020
+ pass
2021
+ if not label:
2022
+ label = f"Image[{i}]"
2023
+
2024
+ md = {
2025
+ "file_path": f"{path}::XISF[{i}]",
2026
+ "original_header": m, # snapshot; sanitized below
2027
+ "bit_depth": bd,
2028
+ "is_mono": is_mono_i,
2029
+ "original_format": "xisf",
2030
+ "image_meta": {"derived_from": path, "layer_index": i, "readonly": True},
2031
+ "display_name": f"{base} {label}",
2032
+ }
2033
+ hdri = _xisf_meta_to_fits_header(m)
2034
+ if hdri is not None:
2035
+ md = attach_wcs_to_metadata(md, hdri)
2036
+
2037
+ _snapshot_header_for_metadata(md)
2038
+ sib = ImageDocument(arr_f32, md)
2039
+ self._register_doc(sib)
2040
+ try: sib.changed.emit()
2041
+ except Exception as e:
2042
+ import logging
2043
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
2044
+ created_any = True
2045
+
2046
+ except Exception as _e:
2047
+ print(f"[DocManager] XISF image {i} skipped: {_e}")
2048
+ except Exception as _e:
2049
+ print(f"[DocManager] XISF open/enumeration failed: {_e}")
2050
+
2051
+ # ---------- 4) Return sensible doc or raise ----------
2052
+ if primary_doc is not None:
2053
+ return primary_doc
2054
+ if created_any:
2055
+ return self._docs[-1] # e.g., a table-only FITS or extra XISF image
2056
+
2057
+ raise IOError(f"Could not load: {path}")
2058
+
2059
+ # --- Subwindow / ROI awareness -------------------------------------
2060
+ def _active_subwindow(self):
2061
+ """Return the active QMdiSubWindow (if any)."""
2062
+ if self._mdi is None:
2063
+ return None
2064
+ try:
2065
+ return self._mdi.activeSubWindow()
2066
+ except Exception:
2067
+ return None
2068
+
2069
+ def _active_view_widget(self):
2070
+ """Return the active view widget (ImageSubWindow or TableSubWindow)."""
2071
+ sw = self._active_subwindow()
2072
+ if not sw:
2073
+ return None
2074
+ try:
2075
+ return sw.widget()
2076
+ except Exception:
2077
+ return None
2078
+
2079
+ def _active_preview_roi(self):
2080
+ """
2081
+ Returns (x,y,w,h) if the active view is an ImageSubWindow with a selected Preview tab.
2082
+ Else returns None.
2083
+ """
2084
+ #print("[DocManager] Checking for active preview ROI")
2085
+ vw = self._active_view_widget()
2086
+ if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
2087
+ try:
2088
+ return vw.current_preview_roi()
2089
+ except Exception:
2090
+ return None
2091
+ return None
2092
+
2093
+ def get_active_image(self, prefer_preview: bool = True):
2094
+ """
2095
+ Unified read: returns the ndarray a tool should operate on.
2096
+ If a Preview tab is active and prefer_preview=True, return that crop.
2097
+ Otherwise return the full document image.
2098
+ """
2099
+ doc = self.get_active_document()
2100
+ if doc is None or doc.image is None:
2101
+ return None
2102
+ roi = self._active_preview_roi() if prefer_preview else None
2103
+ if roi is None:
2104
+ return doc.image
2105
+ x, y, w, h = roi
2106
+ return doc.image[y:y+h, x:x+w]
2107
+
2108
+
2109
+ # --- Slot -> Document ---
2110
+ def open_from_slot(self, slot_idx: int | None = None) -> "ImageDocument | None":
2111
+ if not self.image_manager:
2112
+ return None
2113
+
2114
+ if slot_idx is None:
2115
+ slot_idx = getattr(self.image_manager, "current_slot", 0)
2116
+
2117
+ img = self.image_manager.get_image_for_slot(slot_idx)
2118
+ if img is None:
2119
+ return None
2120
+
2121
+ meta = {}
2122
+ try:
2123
+ meta = dict(self.image_manager._metadata.get(slot_idx, {}))
2124
+ except Exception:
2125
+ pass
2126
+
2127
+ meta.setdefault("file_path", f"Slot {slot_idx}")
2128
+ meta.setdefault("bit_depth", "32-bit floating point")
2129
+ meta.setdefault("is_mono", (img.ndim == 2))
2130
+ meta.setdefault("original_header", meta.get("original_header")) # whatever SASv2 had
2131
+ meta.setdefault("original_format", "fits")
2132
+
2133
+ _snapshot_header_for_metadata(meta)
2134
+
2135
+ doc = ImageDocument(img, meta)
2136
+ self._register_doc(doc)
2137
+ return doc
2138
+
2139
+ # --- Save ---
2140
+ def _infer_bit_depth_for_format(self, img: np.ndarray, ext: str, current_bit_depth: str | None) -> str:
2141
+ # Previous heuristic fallback (used only if no override provided).
2142
+ if ext in ("png", "jpg"):
2143
+ return "8-bit"
2144
+ if ext in ("fits", "fit"):
2145
+ return "32-bit floating point"
2146
+ if ext == "tif":
2147
+ if current_bit_depth in _ALLOWED_DEPTHS["tif"]:
2148
+ return current_bit_depth
2149
+ return "16-bit" if np.issubdtype(img.dtype, np.floating) else "8-bit"
2150
+ if ext == "xisf":
2151
+ return current_bit_depth if current_bit_depth in _ALLOWED_DEPTHS["xisf"] else "32-bit floating point"
2152
+ return "32-bit floating point"
2153
+
2154
+ def save_document(
2155
+ self,
2156
+ doc: "ImageDocument",
2157
+ path: str,
2158
+ bit_depth: str | None = None,
2159
+ *,
2160
+ bit_depth_override: str | None = None,
2161
+ ):
2162
+ """
2163
+ Save the given ImageDocument to 'path'.
2164
+
2165
+ bit_depth_override:
2166
+ New-style explicit choice from a dialog.
2167
+
2168
+ bit_depth:
2169
+ Legacy positional argument; still honored if override is None.
2170
+ """
2171
+ ext = _normalize_ext(os.path.splitext(path)[1])
2172
+ img = doc.image
2173
+ meta = doc.metadata or {}
2174
+
2175
+ # ── MASSIVE DEBUG: show everything we know coming in ───────────────
2176
+ debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
2177
+
2178
+ # --- Decide final bit depth ---------------------------------------
2179
+ requested = bit_depth_override or bit_depth or meta.get("bit_depth")
2180
+
2181
+ if requested:
2182
+ allowed = _ALLOWED_DEPTHS.get(ext, set())
2183
+ if allowed and requested not in allowed:
2184
+ print(f"[save_document] Requested bit depth {requested!r} "
2185
+ f"not in allowed {allowed}, falling back to first.")
2186
+ final_bit_depth = next(iter(allowed))
2187
+ else:
2188
+ final_bit_depth = requested
2189
+ else:
2190
+ final_bit_depth = self._infer_bit_depth_for_format(
2191
+ img, ext, meta.get("bit_depth")
2192
+ )
2193
+
2194
+
2195
+
2196
+ # --- Clip if needed for integer encodes ---------------------------
2197
+ needs_clip = (
2198
+ ext in ("png", "jpg", "jpeg", "tif", "tiff")
2199
+ and final_bit_depth in ("8-bit", "16-bit", "32-bit unsigned")
2200
+ )
2201
+ if needs_clip:
2202
+ print("[save_document] Clipping image to [0,1] for integer encode.")
2203
+ img_to_save = np.clip(img, 0.0, 1.0) if needs_clip else img
2204
+
2205
+ # --- PICK THE HEADER EXPLICITLY -----------------------------------
2206
+ # Priority:
2207
+ # 1) wcs_header
2208
+ # 2) fits_header
2209
+ # 3) original_header
2210
+ # 4) header
2211
+ effective_header = None
2212
+ for key in ("original_header", "fits_header", "wcs_header", "header"):
2213
+ val = meta.get(key)
2214
+ if isinstance(val, fits.Header):
2215
+ effective_header = val
2216
+
2217
+ break
2218
+
2219
+ #if effective_header is None:
2220
+ # print("[save_document] WARNING: No fits.Header in metadata, "
2221
+ # "legacy_save_image will pick a default header.")
2222
+ #else:
2223
+ # # Print first few cards so we can confirm we have the SIP stuff
2224
+ # print("[save_document] effective_header preview (first 25 cards):")
2225
+ # for i, card in enumerate(effective_header.cards):
2226
+ # if i >= 25:
2227
+ # print(" ... (truncated)")
2228
+ # break
2229
+ # print(f" {card.keyword:8s} = {card.value!r}")
2230
+
2231
+ # ── Call the legacy saver ─────────────────────────────────────────
2232
+
2233
+
2234
+ legacy_save_image(
2235
+ img_array=img_to_save,
2236
+ filename=path,
2237
+ original_format=ext,
2238
+ bit_depth=final_bit_depth,
2239
+ original_header=effective_header,
2240
+ is_mono=meta.get("mono", img.ndim == 2),
2241
+ image_meta=meta.get("image_meta"),
2242
+ file_meta=meta.get("file_meta"),
2243
+ wcs_header=meta.get("wcs_header"),
2244
+ )
2245
+
2246
+ # ── Update metadata in memory to match what we just wrote ─────────
2247
+ meta["file_path"] = path
2248
+ meta["original_format"] = ext
2249
+ meta["bit_depth"] = final_bit_depth
2250
+
2251
+ if isinstance(effective_header, fits.Header):
2252
+ meta["original_header"] = effective_header
2253
+
2254
+ # If you have this helper, keep it; if not, you can skip it
2255
+ try:
2256
+ _snapshot_header_for_metadata(meta)
2257
+ except Exception as e:
2258
+ print("[save_document] _snapshot_header_for_metadata error:", e)
2259
+
2260
+ doc.metadata = meta
2261
+
2262
+ # reset dirty flag
2263
+ if hasattr(doc, "dirty"):
2264
+ doc.dirty = False
2265
+
2266
+ if hasattr(doc, "changed"):
2267
+ doc.changed.emit()
2268
+
2269
+ def duplicate_document(self, source_doc: ImageDocument, new_name: str | None = None) -> ImageDocument:
2270
+ # DEBUG: log the source doc WCS before we touch anything
2271
+ if _DEBUG_WCS:
2272
+ try:
2273
+ name = source_doc.display_name() if hasattr(source_doc, "display_name") else "<src>"
2274
+ except Exception:
2275
+ name = "<src>"
2276
+
2277
+ _debug_log_wcs_context(" source.metadata", getattr(source_doc, "metadata", {}))
2278
+
2279
+ # COPY-ON-WRITE: Share the source image instead of copying immediately.
2280
+ # The duplicate's apply_edit will copy when it first modifies the image.
2281
+ # This saves memory when duplicates are created but not modified.
2282
+ img_ref = source_doc.image # Shared reference, no copy
2283
+
2284
+ meta = dict(source_doc.metadata or {})
2285
+ base = source_doc.display_name()
2286
+ dup_title = (new_name or f"{base}_duplicate")
2287
+ # 🚫 strip any lingering emojis / link markers
2288
+ dup_title = dup_title.replace("🔗", "").strip()
2289
+ meta["display_name"] = dup_title
2290
+
2291
+ # Remove anything that makes the view look "linked/preview"
2292
+ imi = dict(meta.get("image_meta") or {})
2293
+ for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
2294
+ imi.pop(k, None)
2295
+ meta["image_meta"] = imi
2296
+ for k in list(meta.keys()):
2297
+ if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
2298
+ meta.pop(k, None)
2299
+
2300
+ # NOTE: we intentionally DO NOT remove "roi_wcs_header" or "original_header"
2301
+ # so that a ROI doc keeps its cropped WCS in the duplicate.
2302
+
2303
+ # Safe bit depth / mono flags
2304
+ meta.setdefault("original_format", meta.get("original_format", "fits"))
2305
+ if isinstance(img_ref, np.ndarray):
2306
+ meta["is_mono"] = (img_ref.ndim == 2 or (img_ref.ndim == 3 and img_ref.shape[2] == 1))
2307
+
2308
+ _snapshot_header_for_metadata(meta)
2309
+
2310
+ dup = ImageDocument(img_ref, meta, parent=self.parent())
2311
+ # Mark this duplicate as sharing image data with source
2312
+ dup._cow_source = source_doc
2313
+ self._register_doc(dup)
2314
+
2315
+ # DEBUG: log the duplicate doc WCS
2316
+ if _DEBUG_WCS:
2317
+ try:
2318
+ dname = dup.display_name()
2319
+ except Exception:
2320
+ dname = "<dup>"
2321
+
2322
+ _debug_log_wcs_context(" duplicate.metadata", dup.metadata)
2323
+
2324
+ return dup
2325
+
2326
+ #def open_array(self, arr, metadata: dict | None = None, title: str | None = None) -> ImageDocument:
2327
+ # import numpy as np
2328
+ ## if arr is None:
2329
+ # raise ValueError("open_array: arr is None")
2330
+ # img = np.asarray(arr)
2331
+ # if img.dtype != np.float32:
2332
+ # img = img.astype(np.float32, copy=False)
2333
+
2334
+ # meta = dict(metadata or {})
2335
+ # meta.setdefault("bit_depth", "32-bit floating point")
2336
+ # meta.setdefault("is_mono", img.ndim == 2)
2337
+ # meta.setdefault("original_header", meta.get("original_header"))
2338
+ # meta.setdefault("original_format", meta.get("original_format", "fits"))
2339
+ # if title:
2340
+ # meta.setdefault("display_name", title)
2341
+
2342
+ # doc = ImageDocument(img, meta, parent=self.parent())
2343
+ # self._docs.append(doc)
2344
+ # self.documentAdded.emit(doc)
2345
+ # return doc
2346
+
2347
+ # convenient aliases used by your tool code
2348
+ def open_array(self, img: np.ndarray, metadata: dict | None = None, title: str | None = None) -> "ImageDocument":
2349
+ meta = dict(metadata or {})
2350
+ if title:
2351
+ meta["display_name"] = title
2352
+ # normalize a few expected fields if missing
2353
+ try:
2354
+ if "is_mono" not in meta and isinstance(img, np.ndarray):
2355
+ meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
2356
+ except Exception:
2357
+ pass
2358
+ meta.setdefault("bit_depth", meta.get("bit_depth", "32-bit floating point"))
2359
+
2360
+ _snapshot_header_for_metadata(meta)
2361
+
2362
+ doc = ImageDocument(img, meta, parent=self.parent())
2363
+ self._register_doc(doc)
2364
+ return doc
2365
+
2366
+ # (optional alias for old code)
2367
+ open_numpy = open_array
2368
+
2369
+
2370
+ def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
2371
+ return self.open_array(image, metadata=metadata, title=name)
2372
+
2373
+ def close_document(self, doc):
2374
+ if doc in self._docs:
2375
+ self._docs.remove(doc)
2376
+ try:
2377
+ if hasattr(doc, "uid"):
2378
+ self._by_uid.pop(doc.uid, None)
2379
+ except Exception:
2380
+ pass
2381
+
2382
+ # Cleanup swap files
2383
+ if hasattr(doc, "close"):
2384
+ try:
2385
+ doc.close()
2386
+ except Exception as e:
2387
+ print(f"[DocManager] Failed to close document {doc}: {e}")
2388
+
2389
+ self.documentRemoved.emit(doc)
2390
+
2391
+ # --- Active-document helpers (NEW) ---------------------------------
2392
+ def all_documents(self):
2393
+ return list(self._docs)
2394
+
2395
+ def _find_main_window(self):
2396
+ from PyQt6.QtWidgets import QMainWindow, QApplication
2397
+ w = self.parent()
2398
+ while w is not None and not isinstance(w, QMainWindow):
2399
+ w = w.parent()
2400
+ if w:
2401
+ return w
2402
+ for tlw in QApplication.topLevelWidgets():
2403
+ if isinstance(tlw, QMainWindow):
2404
+ return tlw
2405
+ return None
2406
+
2407
+ def set_active_document(self, doc: ImageDocument | None):
2408
+ if doc is not None and doc not in self._docs:
2409
+ return
2410
+ # ensure backref for legacy docs
2411
+ if doc is not None and not hasattr(doc, "_doc_manager"):
2412
+ try:
2413
+ import weakref
2414
+ doc._doc_manager = weakref.proxy(self)
2415
+ except Exception:
2416
+ doc._doc_manager = self
2417
+ self._active_doc = doc
2418
+
2419
+ def set_mdi_area(self, mdi):
2420
+ """Call this once from MainWindow after MDI is created."""
2421
+ self._mdi = mdi
2422
+ try:
2423
+ mdi.subWindowActivated.connect(self._on_subwindow_activated)
2424
+ except Exception:
2425
+ pass
2426
+
2427
+ def _base_from_subwindow(self, sw):
2428
+ """Best-effort: unwrap to the base ImageDocument bound to a subwindow."""
2429
+ if sw is None:
2430
+ return None
2431
+ try:
2432
+ w = sw.widget()
2433
+ base = (getattr(w, "base_document", None)
2434
+ or getattr(w, "_base_document", None)
2435
+ or getattr(w, "document", None))
2436
+ # unwrap ROI wrappers if any
2437
+ p = getattr(base, "_parent_doc", None)
2438
+ return p if isinstance(p, ImageDocument) else base
2439
+ except Exception:
2440
+ return None
2441
+
2442
+ def _on_subwindow_activated(self, sw):
2443
+ # existing logic (keep it)
2444
+ doc = None
2445
+ try:
2446
+ if sw is not None:
2447
+ w = sw.widget()
2448
+ doc = getattr(w, "document", None) or getattr(sw, "document", None)
2449
+ except Exception:
2450
+ doc = None
2451
+ self.set_active_document(doc)
2452
+
2453
+ # NEW: compute focused *base* doc and emit change only when different
2454
+ new_base = self._base_from_subwindow(sw)
2455
+ if new_base is not self._focused_base_doc:
2456
+ self._focused_base_doc = new_base
2457
+ try:
2458
+ self.activeBaseChanged.emit(new_base)
2459
+ except Exception:
2460
+ pass
2461
+
2462
+ def get_focused_base_document(self):
2463
+ """
2464
+ Returns the last *activated* subwindow's base ImageDocument (sticky),
2465
+ ignoring hover/preview wrappers.
2466
+ """
2467
+ return self._focused_base_doc
2468
+
2469
+ def get_active_document(self):
2470
+ """
2471
+ Return the active document-like object.
2472
+ If a Preview tab is selected on the active ImageSubWindow, return a cached
2473
+ _RoiViewDocument so tools and the Preview tab share the same instance.
2474
+ Otherwise return the real ImageDocument.
2475
+ """
2476
+ # Prefer cached (if set and still valid)
2477
+ if self._active_doc is not None and self._active_doc in self._docs:
2478
+ base_doc = self._active_doc
2479
+ else:
2480
+ base_doc = None
2481
+ try:
2482
+ if self._mdi is not None:
2483
+ sw = self._mdi.activeSubWindow()
2484
+ if sw is not None:
2485
+ w = sw.widget()
2486
+ base_doc = getattr(w, "document", None) or getattr(sw, "document", None)
2487
+ if base_doc is not None:
2488
+ self._active_doc = base_doc
2489
+ except Exception:
2490
+ pass
2491
+ if base_doc is None:
2492
+ base_doc = self._docs[-1] if self._docs else None
2493
+
2494
+ # Non-image docs just pass through
2495
+ if base_doc is None or not isinstance(base_doc, ImageDocument) or base_doc.image is None:
2496
+ return base_doc
2497
+
2498
+ # ✅ ROI-aware, CACHED preview doc
2499
+ vw = self._active_view_widget()
2500
+ if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
2501
+ try:
2502
+ roi_doc = self.get_document_for_view(vw) # <-- uses _roi_doc_cache
2503
+ if isinstance(roi_doc, _RoiViewDocument):
2504
+ try:
2505
+ name_suffix = f" (Preview {vw.current_preview_name() or ''})"
2506
+ roi_doc.metadata["display_name"] = f"{base_doc.display_name()}{name_suffix}"
2507
+ except Exception:
2508
+ pass
2509
+ return roi_doc
2510
+ except Exception:
2511
+ return base_doc
2512
+
2513
+ return base_doc
2514
+
2515
+
2516
+
2517
+ def update_active_document(self, updated_image, metadata=None, step_name: str = "Edit"):
2518
+
2519
+ view_doc = self.get_active_document()
2520
+ if view_doc is None:
2521
+ raise RuntimeError("No active document")
2522
+
2523
+ old_img = getattr(view_doc, "image", None)
2524
+ old_shape = getattr(old_img, "shape", None)
2525
+
2526
+ img = np.asarray(updated_image)
2527
+ if img.dtype != np.float32:
2528
+ img = img.astype(np.float32, copy=False)
2529
+
2530
+ _debug_log_undo(
2531
+ "DocManager.update_active_document.entry",
2532
+ step_name=step_name,
2533
+ view_doc_type=type(view_doc).__name__,
2534
+ view_doc_id=id(view_doc),
2535
+ is_roi=isinstance(view_doc, _RoiViewDocument),
2536
+ old_shape=old_shape,
2537
+ new_shape=getattr(img, "shape", None),
2538
+ )
2539
+
2540
+ # --- Extract operation parameters (if any) from metadata --------
2541
+ md = dict(metadata or {})
2542
+ op_params = md.pop("__op_params__", None)
2543
+
2544
+ # If this is an ROI view doc, keep track of where this happened
2545
+ roi_tuple = None
2546
+ source_kind = "full"
2547
+ if isinstance(view_doc, _RoiViewDocument):
2548
+ roi_tuple = getattr(view_doc, "_roi", None)
2549
+ source_kind = "roi"
2550
+
2551
+ # --- ROI preview branch: only update preview, no parent paste ----
2552
+ if isinstance(view_doc, _RoiViewDocument):
2553
+ # Update ONLY the preview; view repaint is driven by signals
2554
+ view_doc.apply_edit(img, md, step_name)
2555
+
2556
+ # Record operation on the ROI doc itself
2557
+ if hasattr(view_doc, "record_operation"):
2558
+ try:
2559
+ view_doc.record_operation(
2560
+ step_name=step_name,
2561
+ params=op_params,
2562
+ roi=roi_tuple,
2563
+ source=source_kind,
2564
+ )
2565
+ except Exception:
2566
+ pass
2567
+ _debug_log_undo(
2568
+ "DocManager.update_active_document.roi_after",
2569
+ step_name=step_name,
2570
+ view_doc_id=id(view_doc),
2571
+ roi=getattr(view_doc, "_roi", None),
2572
+ pundo_len=len(getattr(view_doc, "_pundo", [])),
2573
+ predo_len=len(getattr(view_doc, "_predo", [])),
2574
+ )
2575
+ return
2576
+
2577
+ # --- Full image branch ------------------------------------------
2578
+ if isinstance(view_doc, ImageDocument):
2579
+ view_doc.apply_edit(img, md, step_name)
2580
+ try:
2581
+ self.imageRegionUpdated.emit(view_doc, None)
2582
+ except Exception:
2583
+ pass
2584
+
2585
+ _debug_log_undo(
2586
+ "DocManager.update_active_document.full_after",
2587
+ step_name=step_name,
2588
+ view_doc_id=id(view_doc),
2589
+ undo_len=len(getattr(view_doc, "_undo", [])),
2590
+ redo_len=len(getattr(view_doc, "_redo", [])),
2591
+ final_shape=getattr(view_doc.image, "shape", None),
2592
+ )
2593
+ # Record operation on the full document
2594
+ if hasattr(view_doc, "record_operation"):
2595
+ try:
2596
+ view_doc.record_operation(
2597
+ step_name=step_name,
2598
+ params=op_params,
2599
+ roi=None,
2600
+ source=source_kind,
2601
+ )
2602
+
2603
+ except Exception:
2604
+ pass
2605
+ else:
2606
+ raise RuntimeError("Active document is not an image")
2607
+
2608
+ def get_active_operation_log(self) -> list[dict]:
2609
+ """
2610
+ Return the operation log for the *currently active* document-like
2611
+ (full image or ROI-preview). Empty list if none.
2612
+ """
2613
+ doc = self.get_active_document()
2614
+ if doc is None:
2615
+ return []
2616
+ get_log = getattr(doc, "get_operation_log", None)
2617
+ if callable(get_log):
2618
+ try:
2619
+ return get_log()
2620
+ except Exception:
2621
+ return []
2622
+ return []
2623
+
2624
+
2625
+
2626
+ # Back-compat/aliases so tools can call any of these:
2627
+ def update_image(self, updated_image, metadata=None, step_name: str = "Edit"):
2628
+ self.update_active_document(updated_image, metadata, step_name)
2629
+
2630
+ def set_image(self, img, metadata=None, step_name: str = "Edit"):
2631
+ self.update_active_document(img, metadata, step_name)
2632
+
2633
+ def apply_edit_to_active(self, img, step_name: str = "Edit", metadata=None):
2634
+ self.update_active_document(img, metadata, step_name)