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,502 @@
1
+ # pro/stat_stretch.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import Qt, QSize, QEvent
4
+ from PyQt6.QtWidgets import (
5
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QDoubleSpinBox,
6
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton
7
+ )
8
+ from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
9
+ import numpy as np
10
+ from PyQt6 import sip
11
+
12
+ from .doc_manager import ImageDocument
13
+ # use your existing stretch code
14
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
15
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
16
+
17
+ class StatisticalStretchDialog(QDialog):
18
+ """
19
+ Non-destructive preview; Apply commits to the document image.
20
+ """
21
+ def __init__(self, parent, document: ImageDocument):
22
+ super().__init__(parent)
23
+ self.setWindowTitle("Statistical Stretch")
24
+
25
+ # --- IMPORTANT: avoid “attached modal” behavior on some Linux WMs ---
26
+ # Make this a proper top-level window (tool-style) rather than an attached sheet.
27
+ self.setWindowFlag(Qt.WindowType.Window, True)
28
+ # Block the app if you want, but don't use WindowModal
29
+ self.setWindowModality(Qt.WindowModality.ApplicationModal)
30
+ # Don’t let the generic modal flag override the explicit modality
31
+ self.setModal(False)
32
+
33
+ self.doc = document
34
+ self._last_preview = None
35
+ self._panning = False
36
+ self._pan_last = None # QPoint
37
+ self._preview_scale = 1.0 # NEW: zoom factor for preview
38
+ self._preview_qimg = None # NEW: store unscaled QImage for clean scaling
39
+ self._suppress_replay_record = False
40
+
41
+ # --- Controls ---
42
+ self.spin_target = QDoubleSpinBox()
43
+ self.spin_target.setRange(0.01, 0.99)
44
+ self.spin_target.setSingleStep(0.01)
45
+ self.spin_target.setValue(0.25)
46
+ self.spin_target.setDecimals(3)
47
+
48
+ self.chk_linked = QCheckBox("Linked channels")
49
+ self.chk_linked.setChecked(False)
50
+
51
+ self.chk_normalize = QCheckBox("Normalize to [0..1]")
52
+ self.chk_normalize.setChecked(False)
53
+
54
+ # NEW: Curves boost
55
+ self.chk_curves = QCheckBox("Curves boost")
56
+ self.chk_curves.setChecked(False)
57
+
58
+ self.curves_row = QWidget()
59
+ cr_lay = QHBoxLayout(self.curves_row); cr_lay.setContentsMargins(0,0,0,0)
60
+ cr_lay.setSpacing(8)
61
+ cr_lay.addWidget(QLabel("Strength:"))
62
+ self.sld_curves = QSlider(Qt.Orientation.Horizontal)
63
+ self.sld_curves.setRange(0, 100) # 0.00 … 1.00 mapped to 0…100
64
+ self.sld_curves.setSingleStep(1)
65
+ self.sld_curves.setPageStep(5)
66
+ self.sld_curves.setValue(20) # default 0.20
67
+ self.lbl_curves_val = QLabel("0.20")
68
+ self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
69
+ cr_lay.addWidget(self.sld_curves, 1)
70
+ cr_lay.addWidget(self.lbl_curves_val)
71
+ self.curves_row.setEnabled(False) # disabled until checkbox is ticked
72
+ self.chk_curves.toggled.connect(self.curves_row.setEnabled)
73
+
74
+ # Preview area
75
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
76
+ self.preview_label.setMinimumSize(QSize(320, 240))
77
+ self.preview_label.setScaledContents(False)
78
+ self.preview_scroll = QScrollArea()
79
+ self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
80
+ self.preview_scroll.setWidget(self.preview_label)
81
+ self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
82
+ self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
83
+
84
+ self._fit_mode = True # NEW: start in Fit mode
85
+
86
+ # --- Zoom buttons row (place before the main layout or right above preview) ---
87
+ zoom_row = QHBoxLayout()
88
+ self.btn_zoom_out = QToolButton(); self.btn_zoom_out.setText("−")
89
+ self.btn_zoom_in = QToolButton(); self.btn_zoom_in.setText("+")
90
+ self.btn_zoom_100 = QToolButton(); self.btn_zoom_100.setText("100%")
91
+ self.btn_zoom_fit = QToolButton(); self.btn_zoom_fit.setText("Fit")
92
+
93
+ zoom_row.addStretch(1)
94
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
95
+ zoom_row.addWidget(b)
96
+ zoom_row.addStretch(1)
97
+
98
+ # Buttons
99
+ self.btn_preview = QPushButton("Preview")
100
+ self.btn_apply = QPushButton("Apply")
101
+ self.btn_close = QPushButton("Close")
102
+
103
+ self.btn_preview.clicked.connect(self._do_preview)
104
+ self.btn_apply.clicked.connect(self._do_apply)
105
+ self.btn_close.clicked.connect(self.close)
106
+
107
+ # --- Layout ---
108
+ form = QFormLayout()
109
+ form.addRow("Target median:", self.spin_target)
110
+ form.addRow("", self.chk_linked)
111
+ form.addRow("", self.chk_normalize)
112
+ form.addRow("", self.chk_curves)
113
+ form.addRow("", self.curves_row)
114
+
115
+ left = QVBoxLayout()
116
+ left.addLayout(form)
117
+ row = QHBoxLayout()
118
+ row.addWidget(self.btn_preview)
119
+ row.addWidget(self.btn_apply)
120
+ row.addStretch(1)
121
+ left.addLayout(row)
122
+ left.addStretch(1)
123
+
124
+ main = QHBoxLayout(self)
125
+ main.addLayout(left, 0)
126
+
127
+ # NEW: right column with zoom row + preview
128
+ right = QVBoxLayout()
129
+ right.addLayout(zoom_row) # ← actually add the zoom controls
130
+ right.addWidget(self.preview_scroll, 1) # preview below the buttons
131
+ main.addLayout(right, 1)
132
+
133
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
134
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
135
+ self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
136
+ self.btn_zoom_fit.clicked.connect(self._fit_preview)
137
+
138
+ self.preview_scroll.viewport().installEventFilter(self)
139
+ self.preview_label.installEventFilter(self)
140
+
141
+ self._populate_initial_preview()
142
+
143
+ # ----- helpers -----
144
+ def _get_source_float(self) -> np.ndarray:
145
+ """
146
+ Return a float32 array scaled into ~[0..1] for stretching.
147
+ """
148
+ src = np.asarray(self.doc.image)
149
+ if src is None or src.size == 0:
150
+ return None
151
+
152
+ if np.issubdtype(src.dtype, np.integer):
153
+ # Assume 16-bit astro sources by default; adjust if you prefer
154
+ scale = 65535.0 if src.dtype.itemsize >= 2 else 255.0
155
+ return (src.astype(np.float32) / scale).clip(0, 1)
156
+ else:
157
+ f = src.astype(np.float32)
158
+ # If values are way above 1 (linear calibrated data), compress softly
159
+ mx = float(f.max()) if f.size else 1.0
160
+ if mx > 5.0:
161
+ f = f / mx
162
+ return f
163
+
164
+ def _apply_current_zoom(self):
165
+ """Apply the current zoom mode (fit or manual) to the preview image."""
166
+ if self._preview_qimg is None:
167
+ return
168
+ if self._fit_mode:
169
+ self._fit_preview()
170
+ else:
171
+ self._update_preview_scaled()
172
+
173
+ def _fit_preview(self):
174
+ """Fit the image into the visible scroll viewport."""
175
+ if self._preview_qimg is None:
176
+ return
177
+ vp = self.preview_scroll.viewport().size()
178
+ if vp.width() <= 1 or vp.height() <= 1:
179
+ return
180
+ iw, ih = self._preview_qimg.width(), self._preview_qimg.height()
181
+ if iw <= 0 or ih <= 0:
182
+ return
183
+ # compute scale to fit
184
+ sx = vp.width() / iw
185
+ sy = vp.height() / ih
186
+ self._preview_scale = max(0.05, min(sx, sy))
187
+ self._fit_mode = True
188
+ self._update_preview_scaled()
189
+
190
+ def _zoom_reset_100(self):
191
+ """Set zoom to 100% (1:1)."""
192
+ self._fit_mode = False
193
+ self._preview_scale = 1.0
194
+ self._update_preview_scaled()
195
+
196
+ def _zoom_by(self, factor: float):
197
+ """Incremental zoom around the current center; exits Fit mode."""
198
+ self._fit_mode = False
199
+ new_scale = self._preview_scale * float(factor)
200
+ self._preview_scale = max(0.05, min(new_scale, 8.0))
201
+ self._update_preview_scaled()
202
+
203
+
204
+ # --- MASK helpers ----------------------------------------------------
205
+ def _active_mask_array(self) -> np.ndarray | None:
206
+ """Return active mask as float32 [H,W] in 0..1, resized to doc image."""
207
+ try:
208
+ mid = getattr(self.doc, "active_mask_id", None)
209
+ if not mid:
210
+ return None
211
+ layer = getattr(self.doc, "masks", {}).get(mid)
212
+ if layer is None:
213
+ return None
214
+
215
+ m = np.asarray(getattr(layer, "data", None))
216
+ if m is None or m.size == 0:
217
+ return None
218
+
219
+ # squeeze to 2D
220
+ if m.ndim == 3 and m.shape[2] == 1:
221
+ m = m[..., 0]
222
+ elif m.ndim == 3: # RGB/whatever → luminance
223
+ m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
224
+
225
+ m = m.astype(np.float32, copy=False)
226
+ # normalize if integer / out-of-range
227
+ if m.dtype.kind in "ui":
228
+ m /= float(np.iinfo(m.dtype).max)
229
+ m = np.clip(m, 0.0, 1.0)
230
+
231
+ th, tw = self.doc.image.shape[:2]
232
+ sh, sw = m.shape[:2]
233
+ if (sh, sw) != (th, tw):
234
+ yi = (np.linspace(0, sh-1, th)).astype(np.int32)
235
+ xi = (np.linspace(0, sw-1, tw)).astype(np.int32)
236
+ m = m[yi][:, xi]
237
+
238
+ # honor opacity if present
239
+ opacity = float(getattr(layer, "opacity", 1.0) or 1.0)
240
+ if opacity < 1.0:
241
+ m *= opacity
242
+ return m
243
+ except Exception:
244
+ return None
245
+
246
+ def _blend_with_mask(self, base: np.ndarray, out: np.ndarray, mask: np.ndarray) -> np.ndarray:
247
+ """base/out can be mono or 3ch; mask is [H,W] in 0..1."""
248
+ if out.ndim == 3 and out.shape[2] == 3:
249
+ m = mask[..., None]
250
+ else:
251
+ m = mask
252
+ return base * (1.0 - m) + out * m
253
+
254
+
255
+ def _run_stretch(self) -> np.ndarray | None:
256
+ imgf = self._get_source_float()
257
+ if imgf is None:
258
+ return None
259
+
260
+ target = float(self.spin_target.value())
261
+ linked = bool(self.chk_linked.isChecked())
262
+ normalize = bool(self.chk_normalize.isChecked())
263
+ apply_curves = bool(self.chk_curves.isChecked())
264
+ curves_boost = float(self.sld_curves.value()) / 100.0
265
+
266
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
267
+ out = stretch_mono_image(
268
+ imgf.squeeze(),
269
+ target_median=target,
270
+ normalize=normalize,
271
+ apply_curves=apply_curves,
272
+ curves_boost=curves_boost,
273
+ )
274
+ else:
275
+ out = stretch_color_image(
276
+ imgf,
277
+ target_median=target,
278
+ linked=linked,
279
+ normalize=normalize,
280
+ apply_curves=apply_curves,
281
+ curves_boost=curves_boost,
282
+ )
283
+
284
+ # ✅ If a mask is active, blend stretched result with original
285
+ m = self._active_mask_array()
286
+ if m is not None:
287
+ base = imgf.astype(np.float32, copy=False)
288
+ out = self._blend_with_mask(base, out, m)
289
+
290
+ return out
291
+
292
+
293
+ def _set_preview_pixmap(self, arr: np.ndarray):
294
+ vis = arr
295
+ if vis is None or vis.size == 0:
296
+ self.preview_label.clear()
297
+ return
298
+
299
+ # Ensure 3 channels for display
300
+ if vis.ndim == 2:
301
+ vis3 = np.stack([vis] * 3, axis=-1)
302
+ elif vis.ndim == 3 and vis.shape[2] == 1:
303
+ vis3 = np.repeat(vis, 3, axis=2)
304
+ else:
305
+ vis3 = vis
306
+
307
+ # Convert to 8-bit RGB
308
+ if vis3.dtype == np.uint8:
309
+ buf8 = vis3
310
+ elif vis3.dtype == np.uint16:
311
+ buf8 = (vis3.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
312
+ else:
313
+ buf8 = (np.clip(vis3, 0.0, 1.0) * 255.0).astype(np.uint8)
314
+
315
+ # Must be C-contiguous for QImage
316
+ buf8 = np.ascontiguousarray(buf8)
317
+ h, w, _ = buf8.shape
318
+ bytes_per_line = buf8.strides[0]
319
+
320
+ # Build QImage from raw pointer; keep references alive
321
+ self._last_preview = buf8 # keep backing store alive
322
+ ptr = sip.voidptr(self._last_preview.ctypes.data)
323
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
324
+
325
+ self._preview_qimg = qimg
326
+ self._apply_current_zoom()
327
+
328
+ # ----- slots -----
329
+ def _populate_initial_preview(self):
330
+ # show the current (unstretched) image as baseline
331
+ src = self._get_source_float()
332
+ if src is not None:
333
+ self._set_preview_pixmap(np.clip(src, 0, 1))
334
+
335
+ def _do_preview(self):
336
+ try:
337
+ out = self._run_stretch()
338
+ if out is None:
339
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
340
+ return
341
+ self._set_preview_pixmap(out)
342
+ except Exception as e:
343
+ QMessageBox.warning(self, "Preview failed", str(e))
344
+
345
+ def _do_apply(self):
346
+ try:
347
+ out = self._run_stretch()
348
+ if out is None:
349
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
350
+ return
351
+
352
+ # Preserve mono vs color shape
353
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
354
+ out = out[..., 0]
355
+
356
+ # --- Gather current UI state ------------------------------------
357
+ target = float(self.spin_target.value())
358
+ linked = bool(self.chk_linked.isChecked())
359
+ normalize = bool(self.chk_normalize.isChecked())
360
+ apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
361
+ curves_boost = 0.0
362
+ if getattr(self, "sld_curves", None) is not None:
363
+ curves_boost = float(self.sld_curves.value()) / 100.0
364
+
365
+ # Build human-readable step name
366
+ parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
367
+ if normalize:
368
+ parts.append("norm")
369
+ if apply_curves:
370
+ parts.append(f"curves={curves_boost:.2f}")
371
+ if self._active_mask_array() is not None:
372
+ parts.append("masked")
373
+ step_name = f"Statistical Stretch ({', '.join(parts)})"
374
+
375
+ # Apply to document
376
+ self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
377
+
378
+ # Turn off display stretch on the active view, if any
379
+ mw = self.parent()
380
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
381
+ view = mw.mdi.activeSubWindow().widget()
382
+ if getattr(view, "autostretch_enabled", False):
383
+ view.set_autostretch(False)
384
+
385
+ # Existing logging, now using the same values as above
386
+ if hasattr(mw, "_log"):
387
+ curves_on = apply_curves
388
+ boost_val = curves_boost if curves_on else 0.0
389
+ mw._log(
390
+ "Applied Statistical Stretch "
391
+ f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
392
+ f"curves={'ON' if curves_on else 'OFF'}"
393
+ f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
394
+ f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
395
+ )
396
+
397
+ # --- Build preset for headless replay ---------------------------
398
+ # --- Build preset for headless replay ---------------------------
399
+ preset = {
400
+ "target_median": target,
401
+ "linked": linked,
402
+ "normalize": normalize,
403
+ "apply_curves": apply_curves,
404
+ "curves_boost": curves_boost,
405
+ }
406
+
407
+ # ✅ Remember this as the last headless-style command
408
+ # (unless we are in a headless/suppressed call)
409
+ suppress = bool(getattr(self, "_suppress_replay_record", False))
410
+ if not suppress:
411
+ from PyQt6.QtWidgets import QMainWindow
412
+ try:
413
+ mw2 = self.parent()
414
+ while mw2 is not None and not isinstance(mw2, QMainWindow):
415
+ mw2 = mw2.parent()
416
+
417
+ if mw2 is not None and hasattr(mw2, "remember_last_headless_command"):
418
+ mw2.remember_last_headless_command(
419
+ command_id="stat_stretch",
420
+ preset=preset,
421
+ description="Statistical Stretch",
422
+ )
423
+ print(f"Remembered Statistical Stretch last headless command: {preset}")
424
+ else:
425
+ print("No main window with remember_last_headless_command; cannot store stat_stretch preset")
426
+ except Exception as e:
427
+ print(f"Failed to remember Statistical Stretch last headless command: {e}")
428
+ else:
429
+ # optional debug
430
+ print("Statistical Stretch: replay recording suppressed for this apply()")
431
+
432
+ self.accept()
433
+
434
+
435
+ except Exception as e:
436
+ QMessageBox.critical(self, "Apply failed", str(e))
437
+
438
+
439
+ def _update_preview_scaled(self):
440
+ if self._preview_qimg is None:
441
+ self.preview_label.clear()
442
+ return
443
+ sw = max(1, int(self._preview_qimg.width() * self._preview_scale))
444
+ sh = max(1, int(self._preview_qimg.height() * self._preview_scale))
445
+ scaled = self._preview_qimg.scaled(
446
+ sw, sh,
447
+ Qt.AspectRatioMode.KeepAspectRatio,
448
+ Qt.TransformationMode.SmoothTransformation
449
+ )
450
+ self.preview_label.setPixmap(QPixmap.fromImage(scaled))
451
+ self.preview_label.resize(scaled.size()) # <- crucial for scrollbars
452
+
453
+ def resizeEvent(self, ev):
454
+ super().resizeEvent(ev)
455
+ if self._fit_mode:
456
+ self._fit_preview()
457
+
458
+ def eventFilter(self, obj, ev):
459
+ # Ctrl+wheel zoom
460
+ if ev.type() == QEvent.Type.Wheel and (obj is self.preview_scroll.viewport() or obj is self.preview_label):
461
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
462
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
463
+ self._fit_mode = False # ← ensure we exit Fit mode
464
+ self._preview_scale = max(0.05, min(self._preview_scale * factor, 8.0))
465
+ self._update_preview_scaled()
466
+ return True
467
+ return False
468
+
469
+ # Click+drag pan (left or middle mouse)
470
+ if obj is self.preview_scroll.viewport() or obj is self.preview_label:
471
+ if ev.type() == QEvent.Type.MouseButtonPress:
472
+ if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
473
+ self._panning = True
474
+ self._pan_last = ev.position().toPoint()
475
+ # show a "grab" cursor where the drag begins
476
+ if obj is self.preview_label:
477
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
478
+ else:
479
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
480
+ return True
481
+
482
+ elif ev.type() == QEvent.Type.MouseMove and self._panning:
483
+ pos = ev.position().toPoint()
484
+ delta = pos - self._pan_last
485
+ self._pan_last = pos
486
+
487
+ hsb = self.preview_scroll.horizontalScrollBar()
488
+ vsb = self.preview_scroll.verticalScrollBar()
489
+ hsb.setValue(hsb.value() - delta.x())
490
+ vsb.setValue(vsb.value() - delta.y())
491
+ return True
492
+
493
+ elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
494
+ self._panning = False
495
+ self._pan_last = None
496
+ # restore cursor
497
+ self.preview_label.unsetCursor()
498
+ self.preview_scroll.viewport().unsetCursor()
499
+ return True
500
+
501
+ return super().eventFilter(obj, ev)
502
+
@@ -0,0 +1,78 @@
1
+ # pro/status_log_dock.py
2
+ from PyQt6.QtCore import Qt, pyqtSlot
3
+ from PyQt6.QtGui import QTextCursor
4
+ from PyQt6.QtWidgets import (
5
+ QDockWidget, QWidget, QVBoxLayout, QPlainTextEdit, QPushButton, QHBoxLayout
6
+ )
7
+
8
+ class StatusLogDock(QDockWidget):
9
+ MAX_BLOCKS = 2000
10
+
11
+ def __init__(self, parent=None):
12
+ super().__init__("Stacking Log", parent)
13
+ self.setObjectName("StackingLogDock")
14
+ self.setAllowedAreas(
15
+ Qt.DockWidgetArea.BottomDockWidgetArea
16
+ | Qt.DockWidgetArea.LeftDockWidgetArea
17
+ | Qt.DockWidgetArea.RightDockWidgetArea
18
+ )
19
+
20
+ w = QWidget(self)
21
+ lay = QVBoxLayout(w); lay.setContentsMargins(6,6,6,6)
22
+
23
+ self.view = QPlainTextEdit(w)
24
+ self.view.setReadOnly(True)
25
+ self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
26
+ self.view.setStyleSheet(
27
+ "background-color: black; color: white; font-family: Monospace; padding: 6px;"
28
+ )
29
+ lay.addWidget(self.view, 1)
30
+
31
+ row = QHBoxLayout()
32
+ btn_clear = QPushButton("Clear", w)
33
+ btn_clear.clicked.connect(self.view.clear)
34
+ row.addWidget(btn_clear)
35
+ row.addStretch(1)
36
+ lay.addLayout(row)
37
+
38
+ self.setWidget(w)
39
+
40
+ @pyqtSlot(str)
41
+ def append_line(self, message: str):
42
+ doc = self.view.document()
43
+
44
+ # coalesce “Normalizing …” lines (replace last if same prefix)
45
+ if message.startswith("🔄 Normalizing") and doc.blockCount() > 0:
46
+ last = doc.findBlockByNumber(doc.blockCount() - 1)
47
+ if last.isValid() and last.text().startswith("🔄 Normalizing"):
48
+ cur = self.view.textCursor()
49
+ cur.movePosition(QTextCursor.MoveOperation.End)
50
+ cur.movePosition(QTextCursor.MoveOperation.StartOfBlock,
51
+ QTextCursor.MoveMode.KeepAnchor)
52
+ cur.removeSelectedText()
53
+ cur.insertText(message)
54
+ self.view.setTextCursor(cur)
55
+ else:
56
+ self.view.appendPlainText(message)
57
+ else:
58
+ self.view.appendPlainText(message)
59
+
60
+ # trim earliest lines
61
+ if doc.blockCount() > self.MAX_BLOCKS:
62
+ extra = doc.blockCount() - self.MAX_BLOCKS
63
+ cur = self.view.textCursor()
64
+ cur.movePosition(QTextCursor.MoveOperation.Start)
65
+ cur.movePosition(QTextCursor.MoveOperation.Down,
66
+ QTextCursor.MoveMode.KeepAnchor, extra)
67
+ cur.removeSelectedText()
68
+ self.view.setTextCursor(self.view.textCursor())
69
+
70
+ # autoscroll
71
+ sb = self.view.verticalScrollBar()
72
+ sb.setValue(sb.maximum())
73
+
74
+ def show_raise(self):
75
+ self.setVisible(True)
76
+ self.raise_()
77
+ if self.widget():
78
+ self.widget().setFocus()