setiastrosuitepro 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,470 @@
1
+ # pro/star_stretch.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+
6
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QEvent, QPointF
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QCheckBox,
9
+ QPushButton, QScrollArea, QWidget, QMessageBox
10
+ )
11
+ from PyQt6.QtGui import QPixmap, QImage, QMovie
12
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
13
+
14
+ # Shared utilities
15
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
16
+
17
+ # --- use your Numba kernels; fall back to pure numpy SCNR if needed ----
18
+ try:
19
+ from setiastro.saspro.legacy.numba_utils import applyPixelMath_numba, applySCNR_numba
20
+ _HAS_NUMBA = True
21
+ except Exception:
22
+ _HAS_NUMBA = False
23
+ # Fallback SCNR (Average Neutral) if legacy.numba_utils is unavailable
24
+ def applySCNR_numba(image_array: np.ndarray) -> np.ndarray:
25
+ img = image_array.astype(np.float32, copy=False)
26
+ if img.ndim != 3 or img.shape[2] != 3:
27
+ return img
28
+ r = img[..., 0]; g = img[..., 1]; b = img[..., 2]
29
+ g2 = np.minimum(g, 0.5 * (r + b))
30
+ out = img.copy()
31
+ out[..., 1] = g2
32
+ return np.clip(out, 0.0, 1.0)
33
+
34
+ # ---- small helpers --------------------------------------------------------
35
+
36
+ def _as_qimage_rgb8(float01: np.ndarray) -> QImage:
37
+ f = np.asarray(float01, dtype=np.float32)
38
+
39
+ # Ensure 3-channel RGB for preview
40
+ if f.ndim == 2:
41
+ f = np.stack([f]*3, axis=-1)
42
+ elif f.ndim == 3 and f.shape[2] == 1:
43
+ f = np.repeat(f, 3, axis=2)
44
+
45
+ # [0,1] -> uint8 and force C-contiguous
46
+ buf8 = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8, copy=False)
47
+ buf8 = np.ascontiguousarray(buf8)
48
+ h, w, _ = buf8.shape
49
+ bpl = int(buf8.strides[0])
50
+
51
+ # Prefer zero-copy via sip pointer if available; fall back to bytes
52
+ try:
53
+ from PyQt6 import sip
54
+ qimg = QImage(sip.voidptr(buf8.ctypes.data), w, h, bpl, QImage.Format.Format_RGB888)
55
+ qimg._keepalive = buf8 # keep numpy alive while qimg exists
56
+ return qimg.copy() # detach so Qt owns the pixels (safe for QPixmap.fromImage)
57
+ except Exception:
58
+ data = buf8.tobytes()
59
+ qimg = QImage(data, w, h, bpl, QImage.Format.Format_RGB888)
60
+ return qimg.copy() # detach to avoid lifetime issues
61
+
62
+ def _saturation_boost(rgb01: np.ndarray, amount: float) -> np.ndarray:
63
+ """
64
+ Fast saturation-like boost without HSV dependency:
65
+ C' = mean + (C - mean) * amount
66
+ """
67
+ if rgb01.ndim != 3 or rgb01.shape[2] != 3:
68
+ return rgb01
69
+ mean = rgb01.mean(axis=2, keepdims=True)
70
+ out = mean + (rgb01 - mean) * float(amount)
71
+ return np.clip(out, 0.0, 1.0)
72
+
73
+ # ---- background thread ----------------------------------------------------
74
+
75
+ class _StarStretchWorker(QThread):
76
+ preview_ready = pyqtSignal(object) # np.ndarray float32 0..1
77
+
78
+ def __init__(self, image: np.ndarray, stretch_factor: float, sat_amount: float, do_scnr: bool):
79
+ super().__init__()
80
+ self.image = image
81
+ self.stretch_factor = float(stretch_factor) # this is the "amount" for your pixel math
82
+ self.sat_amount = float(sat_amount)
83
+ self.do_scnr = bool(do_scnr)
84
+
85
+ def run(self):
86
+ imgf = _to_float01(self.image)
87
+ if imgf is None:
88
+ return
89
+
90
+ # If grayscale, make it 3-channel to keep the kernels happy, then restore shape
91
+ orig_ndim = imgf.ndim
92
+ need_collapse = False
93
+ if imgf.ndim == 2:
94
+ imgf = np.stack([imgf]*3, axis=-1)
95
+ need_collapse = True
96
+ elif imgf.ndim == 3 and imgf.shape[2] == 1:
97
+ imgf = np.repeat(imgf, 3, axis=2)
98
+ need_collapse = True
99
+
100
+ # --- Star Stretch: your Numba pixel math ---
101
+ # amount maps to the SASv2 slider (0..8); kernel uses: f=3**amount
102
+ out = applyPixelMath_numba(imgf.astype(np.float32, copy=False), self.stretch_factor)
103
+
104
+ # --- Optional saturation (RGB only) ---
105
+ if out.ndim == 3 and out.shape[2] == 3 and abs(self.sat_amount - 1.0) > 1e-6:
106
+ out = _saturation_boost(out, self.sat_amount)
107
+
108
+ # --- Optional SCNR (Average Neutral via your Numba kernel) ---
109
+ if self.do_scnr and out.ndim == 3 and out.shape[2] == 3:
110
+ out = applySCNR_numba(out.astype(np.float32, copy=False))
111
+
112
+ # collapse back to mono if we expanded earlier
113
+ if need_collapse:
114
+ out = out[..., 0]
115
+
116
+ self.preview_ready.emit(out.astype(np.float32, copy=False))
117
+
118
+ # ---- dialog ---------------------------------------------------------------
119
+
120
+ class StarStretchDialog(QDialog):
121
+ """
122
+ Star Stretch for SASpro.
123
+ - Works on active ImageDocument (passed in).
124
+ - Preview is computed in background thread.
125
+ - 'Apply to Document' records history via doc.apply_edit(..., step_name="Star Stretch").
126
+ """
127
+ def __init__(self, parent, document):
128
+ super().__init__(parent)
129
+ self.setWindowTitle("Star Stretch")
130
+ self.doc = document
131
+ self._preview: np.ndarray | None = None
132
+ self._pix: QPixmap | None = None
133
+ self._zoom = 0.25
134
+ self._panning = False
135
+ self._pan_start = QPointF()
136
+ self._apply_when_ready = False
137
+
138
+ # UI
139
+ main = QHBoxLayout(self)
140
+
141
+ # Left column (controls)
142
+ left = QVBoxLayout()
143
+ info = QLabel(
144
+ "Instructions:\n"
145
+ "1) Adjust stretch and options.\n"
146
+ "2) Preview the result.\n"
147
+ "3) Apply to the current document."
148
+ )
149
+ info.setWordWrap(True)
150
+ left.addWidget(info)
151
+
152
+ # Stretch slider (0..8.00)
153
+ self.lbl_st = QLabel("Stretch Amount: 5.00")
154
+ self.sld_st = QSlider(Qt.Orientation.Horizontal)
155
+ self.sld_st.setRange(0, 800)
156
+ self.sld_st.setValue(500)
157
+ self.sld_st.valueChanged.connect(self._on_stretch_changed)
158
+ left.addWidget(self.lbl_st)
159
+ left.addWidget(self.sld_st)
160
+
161
+ # Saturation slider (0..2.00)
162
+ self.lbl_sat = QLabel("Color Boost: 1.00")
163
+ self.sld_sat = QSlider(Qt.Orientation.Horizontal)
164
+ self.sld_sat.setRange(0, 200)
165
+ self.sld_sat.setValue(100)
166
+ self.sld_sat.valueChanged.connect(self._on_sat_changed)
167
+ left.addWidget(self.lbl_sat)
168
+ left.addWidget(self.sld_sat)
169
+
170
+ # SCNR checkbox
171
+ self.chk_scnr = QCheckBox("Remove Green via SCNR (Optional)")
172
+ left.addWidget(self.chk_scnr)
173
+
174
+ # Buttons row
175
+ rowb = QHBoxLayout()
176
+ self.btn_preview = QPushButton("Preview")
177
+ self.btn_apply = QPushButton("Apply to Document")
178
+ rowb.addWidget(self.btn_preview)
179
+ rowb.addWidget(self.btn_apply)
180
+ left.addLayout(rowb)
181
+
182
+ # Spinner
183
+ self.lbl_spin = QLabel()
184
+ self.lbl_spin.setAlignment(Qt.AlignmentFlag.AlignCenter)
185
+ self.lbl_spin.hide()
186
+ spinner_gif = _guess_spinner_path()
187
+ if spinner_gif and os.path.exists(spinner_gif):
188
+ mv = QMovie(spinner_gif)
189
+ self.lbl_spin.setMovie(mv)
190
+ self._spinner = mv
191
+ else:
192
+ self._spinner = None
193
+ left.addWidget(self.lbl_spin)
194
+
195
+ left.addStretch(1)
196
+ main.addLayout(left, 0)
197
+
198
+ # Right column (preview with zoom/pan)
199
+ right = QVBoxLayout()
200
+ zoombar = QHBoxLayout()
201
+ b_out = QPushButton("Zoom Out")
202
+ b_in = QPushButton("Zoom In")
203
+ b_fit = QPushButton("Fit to Preview")
204
+ b_out.clicked.connect(self._zoom_out)
205
+ b_in.clicked.connect(self._zoom_in)
206
+ b_fit.clicked.connect(self._fit)
207
+ zoombar.addWidget(b_out); zoombar.addWidget(b_in); zoombar.addWidget(b_fit)
208
+ right.addLayout(zoombar)
209
+
210
+ self.scroll = QScrollArea()
211
+ self.scroll.setWidgetResizable(True)
212
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
213
+ self.scroll.viewport().installEventFilter(self)
214
+
215
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
216
+ self.scroll.setWidget(self.label)
217
+
218
+ right.addWidget(self.scroll, 1)
219
+ main.addLayout(right, 1)
220
+
221
+ # signals
222
+ self.btn_preview.clicked.connect(self._run_preview)
223
+ self.btn_apply.clicked.connect(self._apply_to_doc)
224
+
225
+ # initialize preview with current doc image
226
+ self._update_preview_pix(self.doc.image)
227
+
228
+ # --- UI change handlers ---
229
+ def _on_stretch_changed(self, v: int):
230
+ self.lbl_st.setText(f"Stretch Amount: {v/100.0:.2f}")
231
+
232
+ def _on_sat_changed(self, v: int):
233
+ self.lbl_sat.setText(f"Color Boost: {v/100.0:.2f}")
234
+
235
+ # --- preview / processing ---
236
+ def _run_preview(self):
237
+ img = self.doc.image
238
+ if img is None:
239
+ QMessageBox.information(self, "No image", "Open an image first.")
240
+ return
241
+ self._show_spinner(True)
242
+ self.btn_preview.setEnabled(False)
243
+ self.btn_apply.setEnabled(False)
244
+
245
+ self._thr = _StarStretchWorker(
246
+ image=img,
247
+ stretch_factor=self.sld_st.value()/100.0,
248
+ sat_amount=self.sld_sat.value()/100.0,
249
+ do_scnr=self.chk_scnr.isChecked()
250
+ )
251
+ self._thr.preview_ready.connect(self._on_preview_ready)
252
+ self._thr.finished.connect(lambda: self._show_spinner(False))
253
+ self._thr.start()
254
+
255
+ def _on_preview_ready(self, out: np.ndarray):
256
+ out_masked = self._blend_with_mask(out)
257
+ self._preview = out_masked
258
+ self.btn_preview.setEnabled(True)
259
+ self.btn_apply.setEnabled(True)
260
+ self._update_preview_pix(out_masked)
261
+
262
+ mw = self._find_main_window()
263
+ if mw and hasattr(mw, "_log"):
264
+ mw._log("Star Stretch: preview generated.")
265
+
266
+ # NEW: if Apply was pressed before preview completed, finish now.
267
+ if self._apply_when_ready:
268
+ self._apply_when_ready = False
269
+ self._finish_apply()
270
+
271
+ def _apply_to_doc(self):
272
+ # If we don't have a preview yet, compute it and auto-apply when ready.
273
+ if self._preview is None:
274
+ if getattr(self, "_thr", None) and self._thr.isRunning():
275
+ # already computing; just mark to apply when it lands
276
+ self._apply_when_ready = True
277
+ return
278
+ self._apply_when_ready = True
279
+ self._run_preview()
280
+ return
281
+
282
+ # We do have a preview → finish immediately
283
+ self._finish_apply()
284
+
285
+ def _finish_apply(self):
286
+ try:
287
+ _marr, mid, mname = self._active_mask_layer()
288
+ meta = {
289
+ "step_name": "Star Stretch",
290
+ "star_stretch": {
291
+ "stretch_factor": self.sld_st.value()/100.0,
292
+ "color_boost": self.sld_sat.value()/100.0,
293
+ "scnr_green": self.chk_scnr.isChecked(),
294
+ "numba": _HAS_NUMBA,
295
+ },
296
+ # ✅ mask bookkeeping
297
+ "masked": bool(mid),
298
+ "mask_id": mid,
299
+ "mask_name": mname,
300
+ "mask_blend": "m*out + (1-m)*src",
301
+ }
302
+ self.doc.apply_edit(self._preview.copy(), metadata=meta, step_name="Star Stretch")
303
+
304
+ mw = self._find_main_window()
305
+ if mw and hasattr(mw, "_log"):
306
+ mw._log("Star Stretch: applied to document.")
307
+
308
+ # 🔁 Record as last headless-style command for Replay
309
+ try:
310
+ if mw and hasattr(mw, "_remember_last_headless_command"):
311
+ preset = {
312
+ "stretch_factor": self.sld_st.value()/100.0,
313
+ "color_boost": self.sld_sat.value()/100.0,
314
+ "scnr_green": self.chk_scnr.isChecked(),
315
+ }
316
+ mw._remember_last_headless_command(
317
+ "star_stretch",
318
+ preset,
319
+ description="Star Stretch",
320
+ )
321
+ except Exception:
322
+ # Don't let replay bookkeeping break the dialog
323
+ pass
324
+
325
+ except Exception as e:
326
+ QMessageBox.critical(self, "Apply failed", str(e))
327
+ return
328
+ self.accept()
329
+
330
+
331
+ # --- preview rendering ---
332
+ def _update_preview_pix(self, img: np.ndarray | None):
333
+ if img is None:
334
+ self.label.clear(); self._pix = None; return
335
+ qimg = _as_qimage_rgb8(_to_float01(img))
336
+ pm = QPixmap.fromImage(qimg)
337
+ self._pix = pm
338
+ self._apply_zoom()
339
+
340
+ def _apply_zoom(self):
341
+ if self._pix is None:
342
+ return
343
+ scaled = self._pix.scaled(self._pix.size()*self._zoom,
344
+ Qt.AspectRatioMode.KeepAspectRatio,
345
+ Qt.TransformationMode.SmoothTransformation)
346
+ self.label.setPixmap(scaled)
347
+ self.label.resize(scaled.size())
348
+
349
+ # --- zoom/pan ---
350
+ def _zoom_in(self): self._set_zoom(self._zoom * 1.25)
351
+ def _zoom_out(self): self._set_zoom(self._zoom / 1.25)
352
+ def _fit(self):
353
+ if self._pix is None: return
354
+ vp = self.scroll.viewport().size()
355
+ if self._pix.width()==0 or self._pix.height()==0: return
356
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
357
+ self._set_zoom(max(0.05, s))
358
+
359
+ def _set_zoom(self, z: float):
360
+ self._zoom = float(max(0.05, min(z, 8.0)))
361
+ self._apply_zoom()
362
+
363
+ # --- spinner ---
364
+ def _show_spinner(self, on: bool):
365
+ if self._spinner is None:
366
+ self.lbl_spin.setVisible(on)
367
+ return
368
+ if on:
369
+ self.lbl_spin.show(); self._spinner.start()
370
+ else:
371
+ self._spinner.stop(); self.lbl_spin.hide()
372
+
373
+ # --- event filter (wheel zoom + panning) ---
374
+ def eventFilter(self, obj, ev):
375
+ if obj is self.scroll.viewport():
376
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
377
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
378
+ ev.accept(); return True
379
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
380
+ self._panning = True; self._pan_start = ev.position()
381
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
382
+ ev.accept(); return True
383
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
384
+ d = ev.position() - self._pan_start
385
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
386
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
387
+ self._pan_start = ev.position()
388
+ ev.accept(); return True
389
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
390
+ self._panning = False
391
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
392
+ ev.accept(); return True
393
+ return super().eventFilter(obj, ev)
394
+
395
+ # --- helper ---
396
+ def _find_main_window(self):
397
+ p = self.parent()
398
+ while p is not None and not hasattr(p, "docman"):
399
+ p = p.parent()
400
+ return p
401
+
402
+ # --- mask helpers ---------------------------------------------------
403
+ def _active_mask_layer(self):
404
+ """Return (mask_array_float01, mask_id, mask_name) or (None, None, None)."""
405
+ doc = self.doc
406
+ mid = getattr(doc, "active_mask_id", None)
407
+ if not mid:
408
+ return None, None, None
409
+ layer = getattr(doc, "masks", {}).get(mid)
410
+ if layer is None:
411
+ return None, None, None
412
+ m = np.asarray(getattr(layer, "data", None), dtype=np.float32)
413
+ if m is None or m.size == 0:
414
+ return None, None, None
415
+ # ensure [0..1]
416
+ if m.dtype.kind in "ui":
417
+ m = m / float(np.iinfo(m.dtype).max)
418
+ else:
419
+ mx = float(m.max()) if m.size else 1.0
420
+ if mx > 1.0:
421
+ m = m / mx
422
+ m = np.clip(m, 0.0, 1.0)
423
+ return m, mid, getattr(layer, "name", "Mask")
424
+
425
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
426
+ """Nearest-neighbor resize using integer indexing (fast, dependency-free)."""
427
+ mh, mw = mask.shape[:2]
428
+ th, tw = out_hw
429
+ if (mh, mw) == (th, tw):
430
+ return mask
431
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
432
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
433
+ return mask[yi][:, xi]
434
+
435
+ def _blend_with_mask(self, stretched: np.ndarray) -> np.ndarray:
436
+ """Blend preview/apply with original using active mask if present."""
437
+ mask, _mid, _name = self._active_mask_layer()
438
+ if mask is None:
439
+ return stretched
440
+ src = _to_float01(self.doc.image)
441
+ out = stretched.astype(np.float32, copy=False)
442
+
443
+ # Make sure spatial size matches mask
444
+ th, tw = out.shape[:2]
445
+ m = self._resample_mask_if_needed(mask, (th, tw))
446
+
447
+ # Broadcast mask to 3ch when needed
448
+ if out.ndim == 3 and out.shape[2] == 3:
449
+ m = m[..., None]
450
+
451
+ # If preview changed mono↔RGB shape, match src first
452
+ if src.ndim == 2 and out.ndim == 3 and out.shape[2] == 3:
453
+ src = np.stack([src]*3, axis=-1)
454
+ elif src.ndim == 3 and src.shape[2] == 3 and out.ndim == 2:
455
+ src = src[..., 0] # collapse to mono
456
+
457
+ return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
458
+
459
+
460
+ def _guess_spinner_path() -> str | None:
461
+ here = os.path.dirname(__file__)
462
+ cands = [
463
+ os.path.join(here, "spinner.gif"),
464
+ os.path.join(os.path.dirname(here), "spinner.gif"),
465
+ os.path.join(os.getcwd(), "spinner.gif"),
466
+ ]
467
+ for c in cands:
468
+ if os.path.exists(c):
469
+ return c
470
+ return None