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,1179 @@
1
+ #pro.isophote.py
2
+ from __future__ import annotations
3
+
4
+ # --- Stdlib ---
5
+ import time
6
+ import inspect
7
+ from types import SimpleNamespace
8
+ from typing import Optional
9
+
10
+ # --- Third-party ---
11
+ import numpy as np
12
+ from astropy.io import fits
13
+
14
+ # photutils is optional; we degrade gracefully if missing
15
+ try:
16
+ from photutils.isophote import Ellipse, EllipseGeometry, build_ellipse_model
17
+ except Exception: # pragma: no cover
18
+ Ellipse = None
19
+ EllipseGeometry = None
20
+ build_ellipse_model = None
21
+
22
+ # --- Qt (PyQt6) ---
23
+ from PyQt6.QtCore import (
24
+ pyqtSignal, QObject, Qt, QSize, QEvent, QThread, QPointF, QRectF
25
+ )
26
+ from PyQt6.QtGui import (
27
+ QIcon, QPixmap, QImage, QPen, QBrush, QPainterPath, QCursor, QFontMetrics, QAction, QTransform
28
+ )
29
+ from PyQt6.QtWidgets import (
30
+ QApplication, QWidget, QDialog, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
31
+ QGraphicsEllipseItem, QGraphicsPathItem, QFormLayout, QHBoxLayout, QVBoxLayout,
32
+ QLabel, QSlider, QPushButton, QCheckBox, QDoubleSpinBox, QSizePolicy, QSplitter,
33
+ QToolButton, QMenu, QMessageBox, QStyle, QProgressDialog, QGraphicsItem, QFileDialog
34
+ )
35
+
36
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
37
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
38
+
39
+
40
+ # ===========================
41
+ # UI Helpers / Components
42
+ # ===========================
43
+
44
+ class _SyncedView(QGraphicsView):
45
+ """Zoom/pan-enabled view that mirrors BOTH transform and scrollbars to a peer.
46
+ Shift+LeftClick emits image coords for 'pick center'."""
47
+ viewChanged = pyqtSignal(QTransform, int, int)
48
+ mousePosClicked = pyqtSignal(float, float)
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+ self.setRenderHints(self.renderHints())
53
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
54
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
55
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
56
+ self._sync_block = False
57
+ self._img_item: Optional[QGraphicsPixmapItem] = None
58
+ self._img_shape = None
59
+ self.horizontalScrollBar().valueChanged.connect(self._emit_view_changed)
60
+ self.verticalScrollBar().valueChanged.connect(self._emit_view_changed)
61
+
62
+ def setSceneImage(self, qpix: QPixmap, img_shape):
63
+ scene = QGraphicsScene(self)
64
+ self._img_item = QGraphicsPixmapItem(qpix)
65
+ scene.addItem(self._img_item)
66
+ self.setScene(scene)
67
+ self._img_shape = img_shape
68
+ self.fitInView(self._img_item, Qt.AspectRatioMode.KeepAspectRatio)
69
+ self._emit_view_changed()
70
+
71
+ def wheelEvent(self, e):
72
+ factor = 1.25 if e.angleDelta().y() > 0 else 0.8
73
+ self.scale(factor, factor)
74
+ self._emit_view_changed()
75
+
76
+ def resizeEvent(self, e):
77
+ super().resizeEvent(e)
78
+ self._emit_view_changed()
79
+
80
+ def _emit_view_changed(self, *_):
81
+ if self._sync_block:
82
+ return
83
+ self.viewChanged.emit(
84
+ self.transform(),
85
+ self.horizontalScrollBar().value(),
86
+ self.verticalScrollBar().value()
87
+ )
88
+
89
+ def setPeerView(self, tr: QTransform, hval: int, vval: int):
90
+ if self._sync_block:
91
+ return
92
+ self._sync_block = True
93
+ try:
94
+ self.setTransform(tr)
95
+ self.horizontalScrollBar().setValue(hval)
96
+ self.verticalScrollBar().setValue(vval)
97
+ finally:
98
+ self._sync_block = False
99
+
100
+ def mousePressEvent(self, ev):
101
+ if (ev.button() == Qt.MouseButton.LeftButton and
102
+ (ev.modifiers() & Qt.KeyboardModifier.ShiftModifier) and
103
+ self._img_item is not None):
104
+ p = self.mapToScene(ev.position().toPoint())
105
+ self.mousePosClicked.emit(p.x(), p.y())
106
+ ev.accept()
107
+ return
108
+ super().mousePressEvent(ev)
109
+
110
+
111
+ class FloatSlider(QWidget):
112
+ """Labeled horizontal slider that emits/accepts float values, with fixed-width value label."""
113
+ valueChanged = pyqtSignal(float)
114
+
115
+ def __init__(self, minimum: float, maximum: float, value: float,
116
+ decimals: int = 2, unit: str = "", tick: Optional[float] = None,
117
+ parent: Optional[QWidget] = None):
118
+ super().__init__(parent)
119
+ self._scale = 10 ** decimals
120
+ self._decimals = decimals
121
+ self._unit = unit
122
+
123
+ self._slider = QSlider(Qt.Orientation.Horizontal, self)
124
+ self._label = QLabel(self)
125
+
126
+ self._slider.setRange(int(round(minimum * self._scale)),
127
+ int(round(maximum * self._scale)))
128
+ if tick:
129
+ self._slider.setSingleStep(int(max(1, round(tick * self._scale))))
130
+
131
+ fm = QFontMetrics(self._label.font())
132
+ max_abs = max(abs(minimum), abs(maximum))
133
+ sample_text = f"-{max_abs:.{self._decimals}f}{self._unit}"
134
+ self._label.setMinimumWidth(fm.horizontalAdvance(sample_text) + 8)
135
+ self._label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
136
+ self._label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
137
+
138
+ lay = QHBoxLayout(self); lay.setContentsMargins(0, 0, 0, 0)
139
+ lay.addWidget(self._slider, 1); lay.addWidget(self._label, 0)
140
+
141
+ self._slider.valueChanged.connect(self._on_slider)
142
+ self.setValue(value)
143
+
144
+ def _on_slider(self, iv):
145
+ val = iv / self._scale
146
+ self._label.setText(f"{val:.{self._decimals}f}{self._unit}")
147
+ self.valueChanged.emit(val)
148
+
149
+ def value(self) -> float:
150
+ return self._slider.value() / self._scale
151
+
152
+ def setValue(self, v: float):
153
+ self._slider.blockSignals(True)
154
+ self._slider.setValue(int(round(v * self._scale)))
155
+ self._slider.blockSignals(False)
156
+ self._label.setText(f"{self.value():.{self._decimals}f}{self._unit}")
157
+
158
+
159
+ class DraggableEllipse(QGraphicsEllipseItem):
160
+ """Seed ellipse: movable only while holding Ctrl."""
161
+ def __init__(self, rect: QRectF, on_center_moved=None):
162
+ super().__init__(rect)
163
+ self.setFlags(
164
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges |
165
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
166
+ )
167
+ self.setAcceptHoverEvents(True)
168
+ self.setZValue(10)
169
+ pen = QPen(Qt.GlobalColor.cyan); pen.setWidthF(1.5)
170
+ self.setPen(pen)
171
+ self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
172
+ self._drag_active = False
173
+ self._drag_offset = QPointF(0, 0)
174
+ self._on_center_moved = on_center_moved
175
+
176
+ def center_scene(self) -> QPointF:
177
+ return self.mapToScene(self.rect().center())
178
+
179
+ def set_center_scene(self, p: QPointF):
180
+ d = p - self.center_scene()
181
+ self.moveBy(d.x(), d.y())
182
+
183
+ def hoverMoveEvent(self, ev):
184
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
185
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
186
+ else:
187
+ self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
188
+ super().hoverMoveEvent(ev)
189
+
190
+ def mousePressEvent(self, ev):
191
+ if (ev.button() == Qt.MouseButton.LeftButton and
192
+ (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
193
+ self._drag_active = True
194
+ self._drag_offset = self.mapToScene(ev.pos()) - self.center_scene()
195
+ ev.accept()
196
+ else:
197
+ self._drag_active = False
198
+ ev.ignore()
199
+
200
+ def mouseMoveEvent(self, ev):
201
+ if self._drag_active:
202
+ new_center = self.mapToScene(ev.pos()) - self._drag_offset
203
+ self.set_center_scene(new_center)
204
+ ev.accept()
205
+ else:
206
+ ev.ignore()
207
+
208
+ def mouseReleaseEvent(self, ev):
209
+ if self._drag_active:
210
+ self._drag_active = False
211
+ if self._on_center_moved:
212
+ c = self.center_scene()
213
+ self._on_center_moved(c.x(), c.y())
214
+ ev.accept()
215
+ else:
216
+ ev.ignore()
217
+
218
+
219
+ # ===========================
220
+ # Fitting Worker (threaded)
221
+ # ===========================
222
+
223
+ class _FitWorker(QObject):
224
+ finished = pyqtSignal(object, object, object) # model, resid, isolist_or_scaled
225
+ error = pyqtSignal(str)
226
+ progress = pyqtSignal(int, str)
227
+
228
+ def __init__(self, img, params, parent=None):
229
+ super().__init__(parent)
230
+ self.img = img
231
+ self.p = params
232
+
233
+ @staticmethod
234
+ def _downsample_mean(img, ds):
235
+ ds = int(max(1, ds))
236
+ H, W = img.shape
237
+ if ds == 1:
238
+ return img, (H, W)
239
+ Hc, Wc = (H // ds) * ds, (W // ds) * ds
240
+ if Hc == 0 or Wc == 0:
241
+ return img, (H, W)
242
+ crop = img[:Hc, :Wc]
243
+ small = crop.reshape(Hc // ds, ds, Wc // ds, ds).mean(axis=(1, 3))
244
+ return small.astype(img.dtype, copy=False), (H, W)
245
+
246
+ @staticmethod
247
+ def _upsample_nn(arr, ds, out_shape, pad_mode="edge"):
248
+ ds = int(max(1, ds))
249
+ if ds == 1:
250
+ up = arr
251
+ else:
252
+ up = arr.repeat(ds, axis=0).repeat(ds, axis=1)
253
+ H, W = out_shape
254
+ uh, uw = up.shape
255
+ if uh < H or uw < W:
256
+ up = np.pad(up, ((0, max(0, H - uh)), (0, max(0, W - uw))), mode=pad_mode)
257
+ return up[:H, :W].astype(arr.dtype, copy=False)
258
+
259
+ def run(self):
260
+ try:
261
+ if Ellipse is None or EllipseGeometry is None or build_ellipse_model is None:
262
+ raise RuntimeError("photutils.isophote not available")
263
+
264
+ self.progress.emit(5, "Preparing…")
265
+ ds = int(max(1, self.p.get("downsample", 1)))
266
+
267
+ # --- geometry at full-res ---
268
+ pa_rad = np.deg2rad(self.p["pa_deg"])
269
+ try:
270
+ geom_full = EllipseGeometry(x0=self.p["cx"], y0=self.p["cy"],
271
+ sma=self.p["sma0"], eps=self.p["eps"], pa=pa_rad)
272
+ except TypeError:
273
+ geom_full = EllipseGeometry(x0=self.p["cx"], y0=self.p["cy"],
274
+ sma=self.p["sma0"], eps=self.p["eps"],
275
+ position_angle=pa_rad)
276
+
277
+ # --- build (possibly) downsampled image & geometry ---
278
+ img_for_fit = self.img
279
+ geom_for_fit = geom_full
280
+ minsma = self.p["minsma"]; maxsma = self.p["maxsma"]; step = self.p["step"]
281
+ if ds > 1:
282
+ small, full_shape = self._downsample_mean(self.img, ds)
283
+ img_for_fit = small
284
+ geom_for_fit = EllipseGeometry(
285
+ x0=geom_full.x0/ds, y0=geom_full.y0/ds,
286
+ sma=geom_full.sma/ds, eps=geom_full.eps, pa=geom_full.pa
287
+ )
288
+ minsma = minsma/ds; maxsma = maxsma/ds; step = max(step/ds, 0.5)
289
+
290
+ self.progress.emit(20, "Building mask…")
291
+ h, w = img_for_fit.shape
292
+ if self.p["use_wedge"]:
293
+ yy, xx = np.mgrid[0:h, 0:w]
294
+ cx_fit, cy_fit = geom_for_fit.x0, geom_for_fit.y0
295
+ ang = np.arctan2(yy - cy_fit, xx - cx_fit)
296
+ pa = np.deg2rad(self.p["wedge_pa"])
297
+ half = np.deg2rad(self.p["wedge_width"] / 2.0)
298
+ d = np.arctan2(np.sin(ang - pa), np.cos(ang - pa))
299
+ wedge_mask = (np.abs(d) <= half)
300
+ else:
301
+ wedge_mask = np.zeros((h, w), dtype=bool)
302
+
303
+ img_ma = np.ma.masked_array(img_for_fit, mask=wedge_mask)
304
+ ell = Ellipse(img_ma, geometry=geom_for_fit)
305
+
306
+ # --- fit kwargs (version-safe) ---
307
+ fit_kwargs = dict(
308
+ sma0=geom_for_fit.sma, minsma=minsma, maxsma=maxsma,
309
+ step=step, sclip=self.p["sclip"], nclip=int(self.p["nclip"]),
310
+ fix_center=self.p["fix_center"], fix_pa=self.p["fix_pa"], fix_eps=self.p["fix_eps"],
311
+ )
312
+ sig = inspect.signature(ell.fit_image).parameters
313
+ mode_key = "integrmode" if "integrmode" in sig else ("integr_mode" if "integr_mode" in sig else None)
314
+ if mode_key:
315
+ fit_kwargs[mode_key] = "bilinear"
316
+
317
+ self.progress.emit(40, "Fitting isophotes…")
318
+ isolist = ell.fit_image(**fit_kwargs)
319
+ if hasattr(isolist, "__len__") and len(isolist) == 0:
320
+ raise ValueError("isolist must not be empty")
321
+
322
+ self.progress.emit(60, "Building model…")
323
+ model_fit = build_ellipse_model(img_for_fit.shape, isolist,
324
+ high_harmonics=self.p["high_harm"])
325
+ resid_fit = img_for_fit - model_fit
326
+
327
+ self.progress.emit(95, "Upsampling / finalizing…")
328
+
329
+ if ds > 1:
330
+ model_full = self._upsample_nn(model_fit, ds, (self.img.shape[0], self.img.shape[1]))
331
+ resid_full = self.img - model_full
332
+ # scale isolist params to full-res
333
+ scaled = []
334
+ for iso in isolist:
335
+ x0 = float(getattr(iso, "x0", getattr(iso, "x0_center", geom_for_fit.x0))) * ds
336
+ y0 = float(getattr(iso, "y0", getattr(iso, "y0_center", geom_for_fit.y0))) * ds
337
+ sma = float(getattr(iso, "sma", getattr(iso, "sma0", geom_for_fit.sma))) * ds
338
+ eps = float(getattr(iso, "eps", getattr(iso, "ellipticity", geom_for_fit.eps)))
339
+ pa = float(getattr(iso, "pa", getattr(iso, "position_angle", geom_for_fit.pa)))
340
+ scaled.append(SimpleNamespace(x0=x0, y0=y0, sma=sma, eps=eps, pa=pa))
341
+ self.finished.emit(model_full.astype(np.float32),
342
+ resid_full.astype(np.float32),
343
+ scaled)
344
+ else:
345
+ self.finished.emit(model_fit.astype(np.float32),
346
+ resid_fit.astype(np.float32),
347
+ isolist)
348
+
349
+ except Exception as e:
350
+ self.error.emit(str(e))
351
+
352
+
353
+ # ===========================
354
+ # Main Dialog
355
+ # ===========================
356
+
357
+ class IsophoteModelerDialog(QDialog):
358
+ pushRequested = pyqtSignal(str, int, object) # kept for legacy, not used with doc_manager
359
+
360
+ def __init__(self, mono_image: np.ndarray, parent: Optional[QWidget] = None,
361
+ title_hint: Optional[str] = None, image_manager=None, doc_manager=None):
362
+ super().__init__(parent)
363
+ self.image_manager = image_manager
364
+ self.doc_manager = doc_manager
365
+
366
+ self._ellipse_item = None
367
+ self._max_item = None
368
+ self._min_item = None
369
+ self._isolist = None
370
+ self._last_fit_params = None
371
+ self._preview_right01 = None
372
+
373
+ self._perf = {1: 0.060714, 4: 0.004286}
374
+ self._last_run_timer = None
375
+
376
+ if Ellipse is None:
377
+ QMessageBox.critical(self, "Photutils Missing",
378
+ "photutils.isophote is required for GLIMR.")
379
+ self.close(); return
380
+
381
+ self.setWindowTitle(title_hint or "GLIMR — GaLaxy Isophote Modeler & Residual Revealer")
382
+ self.setMinimumSize(1100, 700)
383
+ self.setWindowFlags(self.windowFlags()
384
+ | Qt.WindowType.WindowMaximizeButtonHint
385
+ | Qt.WindowType.WindowMinimizeButtonHint)
386
+ self.setSizeGripEnabled(True)
387
+
388
+ self._img = mono_image.astype(np.float32, copy=False)
389
+ self._model = None
390
+ self._resid = None
391
+
392
+ # ---- Views ----
393
+ self.left = _SyncedView()
394
+ self.right = _SyncedView()
395
+ self.left.viewChanged.connect(self.right.setPeerView)
396
+ self.right.viewChanged.connect(self.left.setPeerView)
397
+ self.left.mousePosClicked.connect(self._on_left_click)
398
+
399
+ self._in01 = self._compute_input01()
400
+ lpix = self._np_to_qpix_linear01(self._in01)
401
+ self.left.setSceneImage(lpix, self._in01.shape)
402
+ self.right.setSceneImage(lpix, self._in01.shape)
403
+ self.right.setPeerView(
404
+ self.left.transform(),
405
+ self.left.horizontalScrollBar().value(),
406
+ self.left.verticalScrollBar().value()
407
+ )
408
+
409
+ # overlays
410
+ self._wedge_item = None
411
+
412
+ # ---- Controls ----
413
+ ctl = QWidget(); form = QFormLayout(ctl)
414
+
415
+ self.fix_center = QCheckBox("Fix Center")
416
+ self.fix_pa = QCheckBox("Fix PA")
417
+ self.fix_eps = QCheckBox("Fix Ellipticity")
418
+ self.high_harm = QCheckBox("Add a3/b3/a4/b4 in model")
419
+
420
+ h, w = self._img.shape
421
+ max_rad = min(h, w) / 1.2
422
+
423
+ self.sma0 = FloatSlider(1.0, max_rad, 20.0, decimals=1, unit=" px")
424
+ self.minsma = FloatSlider(0.0, max_rad, 0.0, decimals=1, unit=" px")
425
+ self.maxsma = FloatSlider(1.0, max_rad, max_rad, decimals=1, unit=" px")
426
+ self.step = FloatSlider(0.01, 3.00, 1.00, decimals=2)
427
+ self.sclip = FloatSlider(1.0, 10.0, 3.0, decimals=2)
428
+ self.nclip = FloatSlider(0.0, 20.0, 1.0, decimals=0)
429
+
430
+ self.eps = FloatSlider(0.0, 0.95, 0.20, decimals=3)
431
+ self.pa_deg = FloatSlider(-180.0, 180.0, 90.0, decimals=1, unit="°")
432
+
433
+ self.use_wedge = QCheckBox("Exclude wedge (deg)")
434
+ self.wedge_pa = FloatSlider(-180.0, 180.0, 0.0, decimals=1, unit="°")
435
+ self.wedge_width = FloatSlider(0.0, 180.0, 30.0, decimals=1, unit="°")
436
+
437
+ self._cx, self._cy = w/2.0, h/2.0
438
+ self.center_label = QLabel(f"Center: ({self._cx:.1f}, {self._cy:.1f})")
439
+ pick_center_btn = QPushButton("Pick Center (Shift+click) • Move (Ctrl+drag ellipse)")
440
+
441
+ self.hq_interp = QCheckBox("High-quality interpolation (slower)")
442
+ self.hq_interp.setChecked(False)
443
+ self.quick_preview = QCheckBox("Quick preview (4× downsample)")
444
+ self.quick_preview.setChecked(True)
445
+
446
+ run_btn = QPushButton("Fit Model"); run_btn.clicked.connect(self._run_fit)
447
+ self.preview_blend = QCheckBox("Show original outside max ellipse")
448
+ self.preview_blend.setChecked(True)
449
+
450
+ self.normalize_input = QCheckBox("Normalize before fitting (Linear Data)")
451
+ self.normalize_input.setToolTip(
452
+ "Apply global statistical stretch to the input before fitting/preview.\n"
453
+ "Uses mono median target of 0.25."
454
+ )
455
+ self.normalize_input.setChecked(False)
456
+
457
+ form.addRow(self.normalize_input)
458
+ form.addRow(QLabel("<b>Geometry & Start</b>"))
459
+ form.addRow("sma0", self.sma0)
460
+ form.addRow("min sma", self.minsma)
461
+ form.addRow("max sma", self.maxsma)
462
+ form.addRow("step", self.step)
463
+ self.ring_est_label = QLabel("≈ 0 rings"); form.addRow(self.ring_est_label)
464
+ form.addRow("σ-clip (sclip)", self.sclip)
465
+ form.addRow("σ-clip iters", self.nclip)
466
+ form.addRow(self._help_row(self.fix_center, "Fix (x0,y0) across radii."))
467
+ form.addRow(self._help_row(self.fix_pa, "Fix PA across radii."))
468
+ form.addRow(self._help_row(self.fix_eps, "Fix ellipticity ε across radii."))
469
+ form.addRow(self._help_row(self.high_harm, "Include a3/b3/a4/b4 in model."))
470
+ form.addRow(pick_center_btn)
471
+
472
+ form.addRow(QLabel("<b>Center / Shape</b>"))
473
+ form.addRow(self.center_label)
474
+ form.addRow("ellipticity ε", self.eps)
475
+ form.addRow("PA (deg)", self.pa_deg)
476
+
477
+ form.addRow(QLabel("<b>Wedge Mask</b>"))
478
+ wr = QWidget(); wr_l = QHBoxLayout(wr); wr_l.setContentsMargins(0,0,0,0)
479
+ wr_l.addWidget(self.use_wedge); wr_l.addWidget(QLabel("PA0")); wr_l.addWidget(self.wedge_pa)
480
+ wr_l.addWidget(QLabel("±width/2")); wr_l.addWidget(self.wedge_width)
481
+ form.addRow(wr)
482
+ form.addRow(self.hq_interp)
483
+ form.addRow(self.preview_blend)
484
+ form.addRow(self.quick_preview)
485
+ form.addRow(run_btn)
486
+
487
+ self.save_resid_shifted = QCheckBox("Shift residuals to ≥ 0 on save")
488
+ self.save_resid_shifted.setChecked(True)
489
+ form.addRow(self.save_resid_shifted)
490
+
491
+ # Export rows: push to NEW documents via doc_manager
492
+ model_row, self._model_lowres_hint = self._make_export_row("model")
493
+ resid_row, self._resid_lowres_hint = self._make_export_row("resid")
494
+ form.addRow(model_row); form.addRow(resid_row)
495
+
496
+ split = QSplitter(Qt.Orientation.Horizontal)
497
+ split.addWidget(self.left); split.addWidget(self.right)
498
+ split.setSizes([700, 700])
499
+
500
+ root = QHBoxLayout(self); root.addWidget(split, 4); root.addWidget(ctl, 0)
501
+
502
+ # connections
503
+ pick_center_btn.clicked.connect(lambda: QMessageBox.information(
504
+ self, "Center Picking",
505
+ "Shift+LeftClick in the left image to set the center.\n"
506
+ "Hold Ctrl and drag the cyan ellipse to adjust."
507
+ ))
508
+ for s in (self.sma0, self.maxsma, self.eps, self.pa_deg, self.minsma):
509
+ s.valueChanged.connect(lambda _=None: self._create_or_update_overlay())
510
+ self.preview_blend.stateChanged.connect(lambda _=None: self._rebuild_right_preview())
511
+ for s in (self.minsma, self.sma0, self.maxsma):
512
+ s.valueChanged.connect(lambda _=None: self._enforce_sma_order())
513
+ for s in (self.wedge_pa, self.wedge_width):
514
+ s.valueChanged.connect(lambda _=None: self._update_wedge_overlay())
515
+ self.use_wedge.stateChanged.connect(self._update_wedge_overlay)
516
+
517
+ def _update_ring_estimate():
518
+ mn = float(self.minsma.value()); mx = float(self.maxsma.value())
519
+ st = max(1e-6, float(self.step.value()))
520
+ n = int(max(0, (mx - mn) / st))
521
+ ds = 4 if self.quick_preview.isChecked() else 1
522
+ spr = self._perf.get(ds)
523
+ if spr is None:
524
+ other = 4 if ds == 1 else 1
525
+ other_spr = self._perf.get(other)
526
+ if other_spr is not None:
527
+ spr = other_spr * ((other / ds) ** 2)
528
+ if spr is not None and n > 0:
529
+ eta_sec = spr * n
530
+ s = max(0.0, float(eta_sec))
531
+ if s < 1.0: eta = f"{s:.1f}s"
532
+ else:
533
+ m, s = divmod(int(round(s)), 60)
534
+ if m < 1: eta = f"{s:d}s"
535
+ else:
536
+ h, m = divmod(m, 60)
537
+ eta = f"{m:d}m {s:d}s" if h < 1 else f"{h:d}h {m:d}m"
538
+ tag = "quick" if ds > 1 else "full"
539
+ self.ring_est_label.setText(f"≈ {n:,} rings • est: {eta} ({tag})")
540
+ else:
541
+ self.ring_est_label.setText(f"≈ {n:,} rings")
542
+ if n > 10000:
543
+ new_step = max(st, (mx - mn) / 10000.0)
544
+ if abs(new_step - st) > 1e-12:
545
+ self.step.setValue(new_step)
546
+ for s in (self.minsma, self.maxsma, self.step):
547
+ s.valueChanged.connect(lambda _=None: self._update_ring_estimate())
548
+ self._update_ring_estimate()
549
+
550
+ self.quick_preview.stateChanged.connect(lambda _=None: self._update_ring_estimate())
551
+ self._update_lowres_hints()
552
+ self.quick_preview.stateChanged.connect(lambda _=None: self._update_lowres_hints())
553
+ self.normalize_input.stateChanged.connect(lambda _=None: self._recompute_input_view())
554
+ self._update_wedge_overlay()
555
+
556
+ # ---------- event/utility ----------
557
+ def _compute_input01(self) -> np.ndarray:
558
+ x = self._img.astype(np.float32, copy=False)
559
+ try:
560
+ if self.normalize_input.isChecked():
561
+ x = stretch_mono_image(
562
+ x, target_median=0.25, normalize=False, apply_curves=False, curves_boost=0.0
563
+ ).astype(np.float32, copy=False)
564
+ else:
565
+ x = np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
566
+ except Exception:
567
+ x = np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
568
+ return x
569
+
570
+ def _recompute_input_view(self):
571
+ self._in01 = self._compute_input01()
572
+ pix = self._np_to_qpix_linear01(self._in01)
573
+ self.left.setSceneImage(pix, self._in01.shape)
574
+ if self._resid is None:
575
+ self.right.setSceneImage(pix, self._in01.shape)
576
+ else:
577
+ self._rebuild_right_preview()
578
+
579
+ def _update_lowres_hints(self):
580
+ on = self.quick_preview.isChecked()
581
+ ds = 4 if on else 1
582
+ text = f"low-res (fit at {ds}× downsample)" if on else ""
583
+ for lbl in (self._model_lowres_hint, self._resid_lowres_hint):
584
+ lbl.setText(text); lbl.setVisible(on)
585
+
586
+ def _make_export_row(self, which: str):
587
+ row = QWidget(self); lay = QHBoxLayout(row); lay.setContentsMargins(0,0,0,0)
588
+ save_btn = QPushButton(f"Save {which.capitalize()} FITS…", row)
589
+ save_btn.clicked.connect(lambda: self._save_fits(which=which))
590
+ lay.addWidget(save_btn, 0)
591
+
592
+ new_btn = QPushButton(f"New Doc: {which.capitalize()} (normalized)", row)
593
+ new_btn.clicked.connect(lambda: self._push_product(which=which, variant="normalized"))
594
+ lay.addWidget(new_btn, 0)
595
+
596
+ if which == "resid":
597
+ vis_btn = QPushButton("New Doc: Residual (visible)", row)
598
+ vis_btn.setToolTip("Push exactly what you see in the right preview pane")
599
+ vis_btn.clicked.connect(lambda: self._push_product(which="resid", variant="visible"))
600
+ lay.addWidget(vis_btn, 0)
601
+
602
+ stretch_btn = QPushButton("New Doc: Residual (stretched)", row)
603
+ stretch_btn.setToolTip("Symmetric preview stretch (0 → gray)")
604
+ stretch_btn.clicked.connect(lambda: self._push_product(which="resid", variant="stretched"))
605
+ lay.addWidget(stretch_btn, 0)
606
+
607
+ hint = QLabel("", row)
608
+ hint.setStyleSheet("color:#b58900; font-style: italic;"); hint.setVisible(False)
609
+ lay.addWidget(hint, 0, Qt.AlignmentFlag.AlignVCenter); lay.addStretch(1)
610
+ return row, hint
611
+
612
+ def _update_ring_estimate(self):
613
+ """Update the '≈ N rings' label (and ETA if we have a profile)."""
614
+ mn = float(self.minsma.value())
615
+ mx = float(self.maxsma.value())
616
+ st = max(1e-6, float(self.step.value()))
617
+ rings = int(max(0, (mx - mn) / st))
618
+
619
+ # Soft cap to ~10k rings by raising step if needed
620
+ if rings > 10000:
621
+ new_step = (mx - mn) / 10000.0
622
+ if new_step > st:
623
+ self.step.setValue(new_step)
624
+ st = new_step
625
+ rings = int(max(0, (mx - mn) / st))
626
+
627
+ ds = 4 if self.quick_preview.isChecked() else 1
628
+ txt = f"≈ {rings:,} rings"
629
+
630
+ # Seconds-per-ring profile (EMA-learned); scale from the other ds if missing
631
+ spr = self._perf.get(ds)
632
+ if spr is None:
633
+ other = 4 if ds == 1 else 1
634
+ if self._perf.get(other) is not None:
635
+ spr = self._perf[other] * ((other / ds) ** 2)
636
+
637
+ if spr is not None and rings > 0:
638
+ eta = self._humanize_secs(spr * rings)
639
+ txt += f" • est: {eta}" + (" (quick preview)" if ds > 1 else "")
640
+
641
+ self.ring_est_label.setText(txt)
642
+
643
+ def _humanize_secs(self, secs: float) -> str:
644
+ secs = max(0.0, float(secs))
645
+ if secs < 1.0:
646
+ return f"{secs:.2f}s"
647
+ m, s = divmod(int(round(secs)), 60)
648
+ if m == 0:
649
+ return f"{s}s"
650
+ h, m = divmod(m, 60)
651
+ if h == 0:
652
+ return f"{m}m {s}s"
653
+ return f"{h}h {m}m"
654
+
655
+ def _apply_geometry_to_overlay(self):
656
+ if self._ellipse_item is None:
657
+ return
658
+ self._create_or_update_overlay()
659
+
660
+ def _resid_to_disp01(self, resid, mask, pct=99.5):
661
+ r = np.nan_to_num(resid, 0.0, 0.0, 0.0).astype(np.float32)
662
+ abs_in = np.abs(r[mask])
663
+ S = float(np.percentile(abs_in, pct)) if abs_in.size else 1.0
664
+ if not np.isfinite(S) or S <= 0:
665
+ S = float(np.max(np.abs(r)) or 1.0)
666
+ disp = np.empty_like(self._img, dtype=np.float32)
667
+ disp[mask] = 0.5 + (r[mask] / (2.0 * S))
668
+ disp[~mask] = np.clip(self._img[~mask], 0.0, 1.0)
669
+ return np.clip(disp, 0.0, 1.0)
670
+
671
+ def _auto_estimate_from_moments(self):
672
+ img = np.nan_to_num(self._img, nan=0.0).astype(np.float64)
673
+ h, w = img.shape
674
+ yy, xx = np.mgrid[0:h, 0:w]
675
+ q = np.quantile(img, 0.80)
676
+ mask = img >= q
677
+ if not np.any(mask):
678
+ QMessageBox.information(self, "Auto-estimate", "Could not find bright core pixels.")
679
+ return
680
+ I = img[mask]; x = xx[mask]; y = yy[mask]
681
+ I_sum = I.sum()
682
+ cx = float((I * x).sum() / I_sum)
683
+ cy = float((I * y).sum() / I_sum)
684
+ x0 = x - cx; y0 = y - cy
685
+ cov_xx = float((I * x0 * x0).sum() / I_sum)
686
+ cov_yy = float((I * y0 * y0).sum() / I_sum)
687
+ cov_xy = float((I * x0 * y0).sum() / I_sum)
688
+ cov = np.array([[cov_xx, cov_xy],[cov_xy, cov_yy]])
689
+ evals, evecs = np.linalg.eigh(cov)
690
+ order = np.argsort(evals)[::-1]
691
+ evals = evals[order]; evecs = evecs[:, order]
692
+ sigma_a = np.sqrt(max(evals[0], 1e-6))
693
+ sigma_b = np.sqrt(max(evals[1], 1e-6))
694
+ axis_ratio = float(np.clip(sigma_b / sigma_a, 1e-3, 0.999))
695
+ eps = 1.0 - axis_ratio
696
+ vx, vy = evecs[0,0], evecs[1,0]
697
+ pa_deg = float(np.rad2deg(np.arctan2(vy, vx)))
698
+ sma = float(2.5 * sigma_a)
699
+ self._set_center(cx, cy)
700
+ self.eps.setValue(min(0.95, max(0.0, eps)))
701
+ self.pa_deg.setValue(pa_deg)
702
+ self.sma0.setValue(max(5.0, min(sma, min(h, w)/1.2)))
703
+
704
+
705
+ def _normalize01_for_push(self, arr: np.ndarray):
706
+ a = np.asarray(arr, dtype=np.float32)
707
+ a = np.nan_to_num(a, nan=0.0, posinf=0.0, neginf=0.0)
708
+ vmin = float(a.min()); vmax = float(a.max())
709
+ if not np.isfinite(vmin) or not np.isfinite(vmax) or vmax <= vmin + 1e-12:
710
+ return np.zeros_like(a, dtype=np.float32), vmin, vmax
711
+ out = (a - vmin) / (vmax - vmin)
712
+ return out.astype(np.float32, copy=False), vmin, vmax
713
+
714
+ def _residual_preview_stretch01(self, resid: np.ndarray, pct: float = 99.5):
715
+ r = np.nan_to_num(resid, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
716
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
717
+ _, m = self._elliptical_alpha(r.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
718
+ inside = (m <= 1.0)
719
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
720
+ S = float(np.percentile(abs_in, pct)) if abs_in.size else 1.0
721
+ if not np.isfinite(S) or S <= 0:
722
+ S = float(np.max(np.abs(r)) or 1.0)
723
+ out01 = np.clip(0.5 + (r / (2.0 * S)), 0.0, 1.0).astype(np.float32, copy=False)
724
+ return out01, S
725
+
726
+ def _pix_from_01(self, img01):
727
+ vals = np.nan_to_num(img01, nan=0.0, posinf=1.0, neginf=0.0)
728
+ u8 = (np.clip(vals, 0.0, 1.0) * 255.0).astype(np.uint8)
729
+ h, w = u8.shape
730
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
731
+ return QPixmap.fromImage(qimg.copy())
732
+
733
+ def _np_to_qpix_linear01(self, img: np.ndarray) -> QPixmap:
734
+ img = np.nan_to_num(img, 0.0, 0.0, 0.0)
735
+ u8 = np.clip(img * 255.0, 0, 255).astype(np.uint8)
736
+ h, w = u8.shape
737
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
738
+ return QPixmap.fromImage(qimg.copy())
739
+
740
+ def _enforce_sma_order(self):
741
+ changed = False
742
+ if self.minsma.value() > self.sma0.value():
743
+ self.minsma.setValue(self.sma0.value()); changed = True
744
+ if self.sma0.value() > self.maxsma.value():
745
+ self.sma0.setValue(self.maxsma.value()); changed = True
746
+ if changed:
747
+ self._create_or_update_overlay()
748
+
749
+ def _ellipse_mask(self, shape, cx, cy, sma, eps, pa_deg):
750
+ h, w = shape
751
+ a = float(max(1.0, sma))
752
+ b = float(max(1.0, a * (1.0 - eps)))
753
+ pa = np.deg2rad(pa_deg)
754
+ yy, xx = np.mgrid[0:h, 0:w]
755
+ x0 = xx - cx; y0 = yy - cy
756
+ c, s = np.cos(pa), np.sin(pa)
757
+ xr = x0 * c + y0 * s
758
+ yr = -x0 * s + y0 * c
759
+ return (xr / a) ** 2 + (yr / b) ** 2 <= 1.0
760
+
761
+ def _create_or_update_overlay(self):
762
+ if self.left.scene() is None:
763
+ return
764
+ a0 = max(1.0, float(self.sma0.value()))
765
+ b0 = max(1.0, a0 * (1.0 - float(self.eps.value())))
766
+ rect0 = QRectF(self._cx - a0, self._cy - b0, 2*a0, 2*b0)
767
+
768
+ if getattr(self, "_ellipse_item", None) is None:
769
+ self._ellipse_item = DraggableEllipse(rect0, on_center_moved=self._set_center)
770
+ self._ellipse_item.setTransformOriginPoint(self._ellipse_item.rect().center())
771
+ self._ellipse_item.setRotation(self.pa_deg.value())
772
+ self.left.scene().addItem(self._ellipse_item)
773
+ else:
774
+ c = self._ellipse_item.center_scene()
775
+ self._ellipse_item.setRect(rect0)
776
+ self._ellipse_item.setTransformOriginPoint(self._ellipse_item.rect().center())
777
+ self._ellipse_item.setRotation(self.pa_deg.value())
778
+ self._ellipse_item.set_center_scene(c)
779
+
780
+ aI = max(1.0, float(self.minsma.value()))
781
+ bI = max(1.0, aI * (1.0 - float(self.eps.value())))
782
+ rectI = QRectF(self._cx - aI, self._cy - bI, 2*aI, 2*bI)
783
+ if self._min_item is None:
784
+ self._min_item = QGraphicsEllipseItem(rectI)
785
+ penI = QPen(Qt.GlobalColor.magenta); penI.setWidthF(1.0); penI.setStyle(Qt.PenStyle.DotLine)
786
+ self._min_item.setPen(penI); self._min_item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
787
+ self._min_item.setZValue(8); self.left.scene().addItem(self._min_item)
788
+ else:
789
+ self._min_item.setRect(rectI)
790
+ self._min_item.setTransformOriginPoint(self._min_item.rect().center())
791
+ self._min_item.setRotation(self.pa_deg.value())
792
+
793
+ aM = max(1.0, float(self.maxsma.value()))
794
+ bM = max(1.0, aM * (1.0 - float(self.eps.value())))
795
+ rectM = QRectF(self._cx - aM, self._cy - bM, 2*aM, 2*bM)
796
+ if self._max_item is None:
797
+ self._max_item = QGraphicsEllipseItem(rectM)
798
+ penM = QPen(Qt.GlobalColor.yellow); penM.setWidthF(1.0); penM.setStyle(Qt.PenStyle.DashLine)
799
+ self._max_item.setPen(penM); self._max_item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
800
+ self._max_item.setZValue(9); self.left.scene().addItem(self._max_item)
801
+ else:
802
+ self._max_item.setRect(rectM)
803
+ self._max_item.setTransformOriginPoint(self._max_item.rect().center())
804
+ self._max_item.setRotation(self.pa_deg.value())
805
+
806
+ self._update_wedge_overlay()
807
+
808
+ def eventFilter(self, obj, ev):
809
+ if obj is self.left.scene() and getattr(self, "_ellipse_item", None) is not None:
810
+ if ev.type() == QEvent.Type.GraphicsSceneMouseRelease:
811
+ c = self._ellipse_item.center_scene()
812
+ self._set_center(c.x(), c.y())
813
+ return super().eventFilter(obj, ev)
814
+
815
+ def _update_wedge_overlay(self):
816
+ if getattr(self, "_wedge_item", None) and self.left.scene():
817
+ self.left.scene().removeItem(self._wedge_item); self._wedge_item = None
818
+ if not self.use_wedge.isChecked() or self.left.scene() is None:
819
+ return
820
+ cx, cy = self._cx, self._cy
821
+ pa = np.deg2rad(self.wedge_pa.value())
822
+ half = np.deg2rad(self.wedge_width.value()/2.0)
823
+ h, w = self._img.shape
824
+ R = float(np.hypot(w, h))
825
+ path = QPainterPath(QPointF(cx, cy))
826
+ path.arcTo(cx-R, cy-R, 2*R, 2*R, -np.rad2deg(pa-half), self.wedge_width.value())
827
+ path.lineTo(QPointF(cx, cy))
828
+ item = QGraphicsPathItem(path)
829
+ item.setOpacity(0.2)
830
+ item.setBrush(QBrush(Qt.BrushStyle.Dense4Pattern))
831
+ item.setPen(QPen(Qt.PenStyle.NoPen))
832
+ self.left.scene().addItem(item)
833
+ self._wedge_item = item
834
+
835
+ def _make_wedge_mask(self, h, w):
836
+ if not self.use_wedge.isChecked():
837
+ return np.zeros((h, w), dtype=bool)
838
+ cx, cy = self._cx, self._cy
839
+ pa = np.deg2rad(self.wedge_pa.value())
840
+ half = np.deg2rad(self.wedge_width.value()/2.0)
841
+ yy, xx = np.mgrid[0:h, 0:w]
842
+ ang = np.arctan2(yy - cy, xx - cx)
843
+ d = np.arctan2(np.sin(ang - pa), np.cos(ang - pa))
844
+ return np.abs(d) <= half
845
+
846
+ def _help_row(self, ctrl: QWidget, help_text: str) -> QWidget:
847
+ row = QWidget(self); lay = QHBoxLayout(row); lay.setContentsMargins(0, 0, 0, 0)
848
+ lay.addWidget(ctrl, 1)
849
+ btn = QToolButton(row); btn.setAutoRaise(True)
850
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
851
+ btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion))
852
+ btn.setFixedSize(QSize(18, 18)); btn.setIconSize(QSize(14, 14))
853
+ btn.setToolTip(help_text); ctrl.setToolTip(help_text); ctrl.setWhatsThis(help_text)
854
+ title = ctrl.text() if hasattr(ctrl, "text") else "Help"
855
+ btn.clicked.connect(lambda: QMessageBox.information(self, title, help_text))
856
+ lay.addWidget(btn, 0)
857
+ return row
858
+
859
+ def _set_center(self, x: float, y: float):
860
+ h, w = self._img.shape
861
+ self._cx = float(np.clip(x, 0, w-1)); self._cy = float(np.clip(y, 0, h-1))
862
+ self.center_label.setText(f"Center: ({self._cx:.1f}, {self._cy:.1f})")
863
+ self._update_wedge_overlay(); self._create_or_update_overlay()
864
+
865
+ def _on_left_click(self, x, y):
866
+ self._set_center(x, y)
867
+
868
+ # ---------- fit / preview ----------
869
+ def _run_fit(self):
870
+ p = dict(
871
+ cx=self._cx, cy=self._cy,
872
+ sma0=float(self.sma0.value()),
873
+ minsma=float(self.minsma.value()),
874
+ maxsma=float(self.maxsma.value()),
875
+ step=float(self.step.value()),
876
+ sclip=float(self.sclip.value()),
877
+ nclip=float(self.nclip.value()),
878
+ eps=float(self.eps.value()),
879
+ pa_deg=float(self.pa_deg.value()),
880
+ fix_center=self.fix_center.isChecked(),
881
+ fix_pa=self.fix_pa.isChecked(),
882
+ fix_eps=self.fix_eps.isChecked(),
883
+ high_harm=self.high_harm.isChecked(),
884
+ use_wedge=self.use_wedge.isChecked(),
885
+ wedge_pa=float(self.wedge_pa.value()),
886
+ wedge_width=float(self.wedge_width.value()),
887
+ hq_interp=self.hq_interp.isChecked(),
888
+ downsample=4 if self.quick_preview.isChecked() else 1,
889
+ )
890
+
891
+ n_est = int(max(0, (p["maxsma"] - p["minsma"]) / max(1e-6, p["step"])))
892
+ ds = int(max(1, p["downsample"]))
893
+ self._last_run_timer = (time.perf_counter(), n_est, ds)
894
+
895
+ spr = self._perf.get(ds)
896
+ if spr is None:
897
+ other = 4 if ds == 1 else 1
898
+ other_spr = self._perf.get(other)
899
+ if other_spr is not None:
900
+ spr = other_spr * ((other / ds) ** 2)
901
+ busy_hint = ""
902
+ if spr is not None and n_est > 0:
903
+ eta_sec = spr * n_est
904
+ s = max(0.0, float(eta_sec))
905
+ if s < 1.0: eta = f"{s:.1f}s"
906
+ else:
907
+ m, s = divmod(int(round(s)), 60)
908
+ if m < 1: eta = f"{s:d}s"
909
+ else:
910
+ h, m = divmod(m, 60)
911
+ eta = f"{m:d}m {s:d}s" if h < 1 else f"{h:d}h {m:d}m"
912
+ busy_hint = f" (~{n_est:,} rings, est {eta})"
913
+
914
+ self._last_fit_params = dict(p)
915
+ self._busy = QProgressDialog(f"Fitting…{busy_hint}", None, 0, 100, self)
916
+ self._busy.setWindowModality(Qt.WindowModality.ApplicationModal)
917
+ self._busy.setAutoClose(False); self._busy.setAutoReset(False)
918
+ self._busy.show()
919
+
920
+ fit_img = self._in01
921
+ self._thread = QThread(self)
922
+ self._worker = _FitWorker(fit_img, p)
923
+ self._worker.moveToThread(self._thread)
924
+ self._thread.started.connect(self._worker.run)
925
+ self._worker.progress.connect(lambda pct, msg: (self._busy.setValue(pct), self._busy.setLabelText(msg)))
926
+ self._worker.finished.connect(self._on_fit_finished)
927
+ self._worker.error.connect(self._on_fit_error)
928
+ self._worker.finished.connect(self._thread.quit)
929
+ self._worker.finished.connect(self._worker.deleteLater)
930
+ self._thread.finished.connect(self._thread.deleteLater)
931
+ self._thread.start()
932
+
933
+ def _build_preview_cache_from_fit(self):
934
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
935
+ alpha, m = self._elliptical_alpha(self._resid.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
936
+ r = np.nan_to_num(self._resid, 0.0, 0.0, 0.0).astype(np.float32, copy=False)
937
+ inside = (m <= 1.0)
938
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
939
+ S = float(np.percentile(abs_in, 99.5)) if abs_in.size else 1.0
940
+ if not np.isfinite(S) or S <= 0:
941
+ S = float(np.max(np.abs(r)) or 1.0)
942
+ self._alpha_fit = alpha
943
+ self._resid01_fit = np.clip(0.5 + (r/(2.0*S)), 0.0, 1.0)
944
+ self._S_fit = S
945
+
946
+ def _rebuild_right_preview(self):
947
+ if self._resid is None:
948
+ return
949
+ orig01 = getattr(self, "_orig01", np.clip(self._img, 0.0, 1.0).astype(np.float32))
950
+ if self.preview_blend.isChecked():
951
+ disp01 = self._alpha_fit * self._resid01_fit + (1.0 - self._alpha_fit) * orig01
952
+ else:
953
+ disp01 = self._resid01_fit
954
+ self._preview_right01 = disp01.astype(np.float32, copy=True)
955
+ self.right.setSceneImage(self._pix_from_01(disp01), self._resid.shape)
956
+
957
+ def _refresh_preview(self):
958
+ if self._resid is None:
959
+ return
960
+ if getattr(self, "_in01", None) is None:
961
+ self._in01 = self._compute_input01()
962
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
963
+ alpha, m = self._elliptical_alpha(self._resid.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
964
+ r = np.nan_to_num(self._resid, 0.0, 0.0, 0.0).astype(np.float32)
965
+ inside = (m <= 1.0)
966
+ if self.use_wedge.isChecked():
967
+ inside &= ~self._make_wedge_mask(*self._resid.shape)
968
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
969
+ S = float(np.percentile(abs_in, 99.5)) if abs_in.size else 1.0
970
+ if not np.isfinite(S) or S <= 0:
971
+ S = float(np.max(np.abs(r)) or 1.0)
972
+ self._last_preview_scale_S = S
973
+ resid01 = np.clip(0.5 + (r / (2.0 * S)), 0.0, 1.0)
974
+ orig01 = self._in01
975
+ disp01 = alpha * resid01 + (1.0 - alpha) * orig01 if self.preview_blend.isChecked() else resid01
976
+ self._preview_right01 = disp01.astype(np.float32, copy=True)
977
+ self.right.setSceneImage(self._pix_from_01(disp01), self._resid.shape)
978
+
979
+ def _fit_boundary(self):
980
+ if getattr(self, "_isolist", None):
981
+ for iso in reversed(self._isolist):
982
+ cx = float(getattr(iso, "x0", getattr(iso, "x0_center", np.nan)))
983
+ cy = float(getattr(iso, "y0", getattr(iso, "y0_center", np.nan)))
984
+ sma = float(getattr(iso, "sma", getattr(iso, "sma0", np.nan)))
985
+ eps = float(getattr(iso, "eps", getattr(iso, "ellipticity", np.nan)))
986
+ pa = float(getattr(iso, "pa", getattr(iso, "position_angle", np.nan)))
987
+ if (np.isfinite([cx, cy, sma, eps, pa]).all() and sma > 0.0 and 0.0 <= eps < 1.0):
988
+ return cx, cy, sma, float(np.clip(eps, 0.0, 0.95)), float(np.rad2deg(pa))
989
+ return (self._cx, self._cy,
990
+ float(self.maxsma.value()),
991
+ float(self.eps.value()),
992
+ float(self.pa_deg.value()))
993
+
994
+ def _elliptical_alpha(self, shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04):
995
+ if not np.isfinite(pa_deg):
996
+ pa_deg = float(self.pa_deg.value())
997
+ a = float(max(1.0, sma))
998
+ b = float(max(1.0, a * (1.0 - float(eps))))
999
+ th = np.deg2rad(pa_deg)
1000
+ h, w = shape
1001
+ yy, xx = np.mgrid[0:h, 0:w]
1002
+ x = xx - cx; y = yy - cy
1003
+ c, s = np.cos(th), np.sin(th)
1004
+ xr = x*c + y*s
1005
+ yr = -x*s + y*c
1006
+ m = np.sqrt((xr/a)**2 + (yr/b)**2)
1007
+ band = max(1e-3, float(feather_frac))
1008
+ alpha = np.clip((1.0 - m) / band, 0.0, 1.0)
1009
+ return alpha, m
1010
+
1011
+ def _on_fit_finished(self, model, resid, isolist):
1012
+ if self._last_run_timer is not None:
1013
+ start_t, rings, ds = self._last_run_timer
1014
+ self._last_run_timer = None
1015
+ if rings > 0:
1016
+ elapsed = max(0.0, time.perf_counter() - start_t)
1017
+ spr_meas = elapsed / rings
1018
+ prev = self._perf.get(ds)
1019
+ alpha = 0.35
1020
+ self._perf[ds] = spr_meas if prev is None else (alpha * spr_meas + (1 - alpha) * prev)
1021
+ self._update_lowres_hints()
1022
+ self._model = model; self._resid = resid; self._isolist = isolist
1023
+ if hasattr(self, "_busy") and self._busy is not None:
1024
+ self._busy.close(); self._busy = None
1025
+ self._build_preview_cache_from_fit(); self._rebuild_right_preview()
1026
+
1027
+ def _on_fit_error(self, msg):
1028
+ if hasattr(self, "_busy") and self._busy is not None:
1029
+ self._busy.close(); self._busy = None
1030
+ self._last_run_timer = None
1031
+ QMessageBox.critical(self, "Isophote Fit Error", msg)
1032
+
1033
+ # ---------- export (push to doc_manager) ----------
1034
+ def _push_product(self, which: str, variant: str):
1035
+ if self.doc_manager is None:
1036
+ QMessageBox.information(self, "GLIMR", "No document manager available.")
1037
+ return
1038
+ arr = self._resid if which == "resid" else self._model
1039
+ if arr is None:
1040
+ QMessageBox.information(self, "GLIMR", f"Run the fit first to generate the {which}.")
1041
+ return
1042
+
1043
+ step_name = f"Isophote {which.capitalize()}"
1044
+ ds = int(max(1, (self._last_fit_params or {}).get("downsample", 1)))
1045
+ if ds > 1: step_name += " (quick preview)"
1046
+
1047
+ meta_common = {
1048
+ "from": "GLIMR",
1049
+ "product": which,
1050
+ "downsample": ds,
1051
+ "isophote_params": (
1052
+ {k: self._last_fit_params.get(k) for k in (
1053
+ "cx","cy","sma0","minsma","maxsma","step","sclip","nclip",
1054
+ "eps","pa_deg","fix_center","fix_pa","fix_eps",
1055
+ "high_harm","use_wedge","wedge_pa","wedge_width","hq_interp","downsample"
1056
+ )} if self._last_fit_params else None
1057
+ )
1058
+ }
1059
+
1060
+ title = ""
1061
+ if which == "resid" and variant == "visible":
1062
+ if self._preview_right01 is None:
1063
+ self._refresh_preview()
1064
+ data01 = np.clip(np.nan_to_num(self._preview_right01, nan=0.0, posinf=1.0, neginf=0.0), 0.0, 1.0).astype(np.float32)
1065
+ meta = {**meta_common, "push_variant": "visible_preview",
1066
+ "preview_blend": bool(self.preview_blend.isChecked()),
1067
+ "feather_frac": 0.04,
1068
+ "note": "Exact right-pane display"}
1069
+ title = "GLIMR Residual (visible)"
1070
+ elif which == "resid" and variant == "stretched":
1071
+ data01, S = self._residual_preview_stretch01(arr, pct=99.5)
1072
+ meta = {**meta_common, "push_variant": "preview_stretch",
1073
+ "stretch_pct": 99.5, "stretch_scale_S": float(S),
1074
+ "zero_maps_to_gray_0p5": True}
1075
+ title = "GLIMR Residual (stretched)"
1076
+ else:
1077
+ # normalized (min→0, max→1), optionally shift residuals to ≥0 first
1078
+ data = np.asarray(arr, dtype=np.float32)
1079
+ if which == "resid" and self.save_resid_shifted.isChecked():
1080
+ mn = float(np.nanmin(data))
1081
+ if np.isfinite(mn) and mn < 0.0:
1082
+ data = data - mn
1083
+ data01, vmin, vmax = self._normalize01_for_push(data)
1084
+ meta = {**meta_common, "push_variant": "normalized_01",
1085
+ "source_range_min": float(vmin), "source_range_max": float(vmax)}
1086
+ title = f"GLIMR {which.capitalize()}"
1087
+ if ds > 1: title += " (quick)"
1088
+
1089
+ ok = self._push_array_to_doc_manager(data01, title, meta)
1090
+ if not ok:
1091
+ QMessageBox.warning(self, "GLIMR", "Could not create a new document in doc manager.")
1092
+
1093
+ def _push_array_to_doc_manager(self, arr01: np.ndarray, title: str, meta: dict) -> bool:
1094
+ """Create a brand-new document in your DocManager."""
1095
+ dm = self.doc_manager
1096
+ if dm is None:
1097
+ return False
1098
+
1099
+ # ensure float32 [0..1]
1100
+ img = np.asarray(arr01, dtype=np.float32)
1101
+
1102
+ # 1) Preferred: open_array(img, metadata=None, title=None)
1103
+ fn = getattr(dm, "open_array", None)
1104
+ if callable(fn):
1105
+ try:
1106
+ fn(img, metadata=dict(meta or {}), title=title)
1107
+ return True
1108
+ except Exception:
1109
+ pass
1110
+
1111
+ # 2) Also supported in your manager: create_document(image, metadata=None, name=None)
1112
+ fn = getattr(dm, "create_document", None)
1113
+ if callable(fn):
1114
+ try:
1115
+ fn(image=img, metadata=dict(meta or {}), name=title)
1116
+ return True
1117
+ except Exception:
1118
+ pass
1119
+
1120
+ # 3) Alias present: open_numpy == open_array (same signature)
1121
+ fn = getattr(dm, "open_numpy", None)
1122
+ if callable(fn):
1123
+ try:
1124
+ fn(img, metadata=dict(meta or {}), title=title)
1125
+ return True
1126
+ except Exception:
1127
+ pass
1128
+
1129
+ # If none of the above worked, report failure
1130
+ return False
1131
+
1132
+
1133
+ # ---------- save ----------
1134
+ def _save_fits(self, which="resid"):
1135
+ arr = self._resid if which == "resid" else self._model
1136
+ if arr is None:
1137
+ QMessageBox.information(self, "Nothing to save",
1138
+ f"Run the fit first to generate the {which}.")
1139
+ return
1140
+ fn, _ = QFileDialog.getSaveFileName(self, f"Save {which} FITS", f"{which}.fits", "FITS files (*.fits *.fit)")
1141
+ if not fn:
1142
+ return
1143
+ try:
1144
+ data = np.asarray(arr, dtype=np.float32)
1145
+ orig_min = float(np.nanmin(data)); orig_max = float(np.nanmax(data))
1146
+ pedestal = 0.0
1147
+ if which == "resid" and self.save_resid_shifted.isChecked():
1148
+ if np.isfinite(orig_min) and orig_min < 0.0:
1149
+ pedestal = -orig_min; data = data + pedestal
1150
+ hdr = fits.Header()
1151
+ hdr["CREATOR"] = ("GLIMR", "SASpro Isophote Modeler")
1152
+ hdr["PRODUCT"] = (which, "model or resid")
1153
+ hdr["ORIGMIN"] = (orig_min, "Min before any pedestal")
1154
+ hdr["ORIGMAX"] = (orig_max, "Max before any pedestal")
1155
+ hdr["PEDESTAL"] = (float(pedestal), "Added so min(data)>=0 at save time")
1156
+ ds = 1
1157
+ if getattr(self, "_last_fit_params", None):
1158
+ ds = int(max(1, self._last_fit_params.get("downsample", 1)))
1159
+ hdr["DSFACTOR"] = (ds, "Downsample factor used for fit (then upsampled)")
1160
+ p = self._last_fit_params or {}
1161
+ hdr["ISO_EPS"] = (float(p.get("eps", np.nan)), "Seed ellipticity")
1162
+ hdr["ISO_PA"] = (float(p.get("pa_deg", np.nan)), "Seed PA (deg)")
1163
+ hdr["ISO_SMA0"] = (float(p.get("sma0", np.nan)), "Initial SMA (px)")
1164
+ hdr["ISO_MIN"] = (float(p.get("minsma", np.nan)), "Min SMA (px)")
1165
+ hdr["ISO_MAX"] = (float(p.get("maxsma", np.nan)), "Max SMA (px)")
1166
+ hdr["ISO_STEP"] = (float(p.get("step", np.nan)), "SMA step (px)")
1167
+ hdr["ISO_SCLIP"] = (float(p.get("sclip", np.nan)), "Sigma clip")
1168
+ hdr["ISO_NCLIP"] = (int(p.get("nclip", 0)), "Sigma clip iters")
1169
+ hdr["ISO_FXC"] = (bool(p.get("fix_center", False)), "Fix center")
1170
+ hdr["ISO_FPA"] = (bool(p.get("fix_pa", False)), "Fix PA")
1171
+ hdr["ISO_FEPS"] = (bool(p.get("fix_eps", False)), "Fix ellipticity")
1172
+ hdr["ISO_HARM"] = (bool(p.get("high_harm", False)), "Use a3/b3/a4/b4")
1173
+ hdr["ISO_WEDGE"] = (bool(p.get("use_wedge", False)), "Exclude wedge")
1174
+ if p.get("use_wedge", False):
1175
+ hdr["ISO_WPA"] = (float(p.get("wedge_pa", np.nan)), "Wedge PA (deg)")
1176
+ hdr["ISO_WWID"] = (float(p.get("wedge_width", np.nan)),"Wedge width (deg)")
1177
+ fits.PrimaryHDU(data.astype(np.float32), header=hdr).writeto(fn, overwrite=True)
1178
+ except Exception as e:
1179
+ QMessageBox.critical(self, "Save Error", str(e))