setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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 (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -3,16 +3,45 @@ from __future__ import annotations
3
3
  from PyQt6.QtCore import Qt, QSize, QEvent
4
4
  from PyQt6.QtWidgets import (
5
5
  QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QDoubleSpinBox,
6
- QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton
6
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton, QComboBox
7
7
  )
8
8
  from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
9
9
  import numpy as np
10
10
  from PyQt6 import sip
11
-
11
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot, Qt, QSize, QEvent, QTimer
12
+ from PyQt6.QtWidgets import QProgressDialog, QApplication
12
13
  from .doc_manager import ImageDocument
13
14
  # use your existing stretch code
14
- from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
15
+ from setiastro.saspro.imageops.stretch import (
16
+ stretch_mono_image,
17
+ stretch_color_image,
18
+ _compute_blackpoint_sigma,
19
+ _compute_blackpoint_sigma_per_channel,
20
+ )
21
+
15
22
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
23
+ from setiastro.saspro.luminancerecombine import LUMA_PROFILES
24
+
25
+ class _StretchWorker(QObject):
26
+ finished = pyqtSignal(object, str) # (out_array_or_None, error_message_or_empty)
27
+
28
+ def __init__(self, dialog_ref):
29
+ super().__init__()
30
+ self._dlg = dialog_ref
31
+
32
+ @pyqtSlot()
33
+ def run(self):
34
+ try:
35
+ # dialog might be closing; guard
36
+ if self._dlg is None or sip.isdeleted(self._dlg):
37
+ self.finished.emit(None, "Dialog was closed.")
38
+ return
39
+
40
+ out = self._dlg._run_stretch()
41
+ self.finished.emit(out, "")
42
+ except Exception as e:
43
+ self.finished.emit(None, str(e))
44
+
16
45
 
17
46
  class StatisticalStretchDialog(QDialog):
18
47
  """
@@ -22,83 +51,266 @@ class StatisticalStretchDialog(QDialog):
22
51
  super().__init__(parent)
23
52
  self.setWindowTitle(self.tr("Statistical Stretch"))
24
53
 
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.
54
+ # --- Top-level non-modal tool window (Linux WM friendly) ---
27
55
  self.setWindowFlag(Qt.WindowType.Window, True)
28
- # Non-modal: allow user to switch between images while dialog is open
29
56
  self.setWindowModality(Qt.WindowModality.NonModal)
30
- # Don’t let the generic modal flag override the explicit modality
31
57
  self.setModal(False)
58
+ try:
59
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
60
+ except Exception:
61
+ pass
32
62
 
63
+ # --- State / refs ---
33
64
  self._main = parent
34
65
  self.doc = document
66
+
35
67
  self._last_preview = None
68
+ self._preview_qimg = None
69
+ self._preview_scale = 1.0
70
+ self._fit_mode = True
36
71
 
37
- # Connect to active document change signal
38
- if hasattr(self._main, "currentDocumentChanged"):
39
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
40
72
  self._panning = False
41
73
  self._pan_last = None # QPoint
42
- self._preview_scale = 1.0 # NEW: zoom factor for preview
43
- self._preview_qimg = None # NEW: store unscaled QImage for clean scaling
74
+
75
+ self._hdr_knee_user_locked = False
76
+ self._pending_close = False
44
77
  self._suppress_replay_record = False
45
78
 
46
- # --- Controls ---
79
+ # ---- Clip-stats scheduling (define EARLY so init callbacks can't crash) ----
80
+ self._clip_timer = None
81
+
82
+ def _schedule_clip_stats():
83
+ # Safe early stub; once timer exists it will debounce
84
+ if getattr(self, "_job_running", False):
85
+ return
86
+ if sip.isdeleted(self):
87
+ return
88
+ t = getattr(self, "_clip_timer", None)
89
+ if t is None:
90
+ return
91
+ t.start()
92
+
93
+ self._schedule_clip_stats = _schedule_clip_stats
94
+
95
+ self._thread = None
96
+ self._worker = None
97
+ self._follow_conn = None
98
+ self._job_running = False
99
+ self._job_mode = ""
100
+
101
+ # --- Follow active document changes (optional) ---
102
+ if hasattr(self._main, "currentDocumentChanged"):
103
+ try:
104
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
105
+ self._follow_conn = True
106
+ except Exception:
107
+ self._follow_conn = None
108
+
109
+ # ------------------------------------------------------------------
110
+ # Controls
111
+ # ------------------------------------------------------------------
112
+
113
+ # Target median
47
114
  self.spin_target = QDoubleSpinBox()
48
115
  self.spin_target.setRange(0.01, 0.99)
49
116
  self.spin_target.setSingleStep(0.01)
50
117
  self.spin_target.setValue(0.25)
51
118
  self.spin_target.setDecimals(3)
52
119
 
120
+ # Linked channels
53
121
  self.chk_linked = QCheckBox(self.tr("Linked channels"))
54
122
  self.chk_linked.setChecked(False)
55
123
 
124
+ # Normalize
56
125
  self.chk_normalize = QCheckBox(self.tr("Normalize to [0..1]"))
57
126
  self.chk_normalize.setChecked(False)
58
127
 
59
- # NEW: Curves boost
128
+ # --- Black point sigma row ---
129
+ self.row_bp = QWidget()
130
+ bp_lay = QHBoxLayout(self.row_bp)
131
+ bp_lay.setContentsMargins(0, 0, 0, 0)
132
+ bp_lay.setSpacing(8)
133
+
134
+ bp_lay.addWidget(QLabel(self.tr("Black point σ:")))
135
+
136
+ self.sld_bp = QSlider(Qt.Orientation.Horizontal)
137
+ self.sld_bp.setRange(50, 600) # 0.50 .. 6.00
138
+ self.sld_bp.setValue(500) # 5.00 default (matches your label)
139
+ bp_lay.addWidget(self.sld_bp, 1)
140
+
141
+ self.lbl_bp = QLabel(f"{self.sld_bp.value()/100:.2f}")
142
+ bp_lay.addWidget(self.lbl_bp)
143
+
144
+ bp_tip = self.tr(
145
+ "Black point (σ) controls how aggressively the dark background is clipped.\n"
146
+ "Higher values clip more (darker background, more contrast), but can crush faint dust.\n"
147
+ "Lower values preserve faint background, but may leave the image gray.\n"
148
+ "Tip: start around 2.7–5.0 depending on gradient/noise."
149
+ )
150
+ self.row_bp.setToolTip(bp_tip)
151
+ self.sld_bp.setToolTip(bp_tip)
152
+ self.lbl_bp.setToolTip(bp_tip)
153
+
154
+ # No black clipping
155
+ self.chk_no_black_clip = QCheckBox(self.tr("No black clipping (Old Stat Stretch behavior)"))
156
+ self.chk_no_black_clip.setChecked(False)
157
+ self.chk_no_black_clip.setToolTip(self.tr(
158
+ "Disables black-point clipping.\n"
159
+ "Uses the image minimum as the black point (preserves faint background),\n"
160
+ "but the result may look flatter / hazier."
161
+ ))
162
+
163
+ # --- HDR compress ---
164
+ self.chk_hdr = QCheckBox(self.tr("HDR highlight compress"))
165
+ self.chk_hdr.setChecked(False)
166
+ self.chk_hdr.setToolTip(self.tr(
167
+ "Compresses bright highlights after the stretch.\n"
168
+ "Use lightly: high values can flatten the image and create star ringing."
169
+ ))
170
+
171
+ self.hdr_row = QWidget()
172
+ hdr_lay = QVBoxLayout(self.hdr_row)
173
+ hdr_lay.setContentsMargins(0, 0, 0, 0)
174
+ hdr_lay.setSpacing(6)
175
+
176
+ # HDR amount row
177
+ row_a = QHBoxLayout()
178
+ row_a.setContentsMargins(0, 0, 0, 0)
179
+ row_a.setSpacing(8)
180
+ row_a.addWidget(QLabel(self.tr("Amount:")))
181
+
182
+ self.sld_hdr_amt = QSlider(Qt.Orientation.Horizontal)
183
+ self.sld_hdr_amt.setRange(0, 100)
184
+ self.sld_hdr_amt.setValue(15)
185
+ row_a.addWidget(self.sld_hdr_amt, 1)
186
+
187
+ self.lbl_hdr_amt = QLabel(f"{self.sld_hdr_amt.value()/100:.2f}")
188
+ row_a.addWidget(self.lbl_hdr_amt)
189
+
190
+ self.sld_hdr_amt.setToolTip(self.tr(
191
+ "Compression strength (0–1).\n"
192
+ "Start low (0.10–0.15). Too much can flatten the image and ring stars."
193
+ ))
194
+ self.lbl_hdr_amt.setToolTip(self.sld_hdr_amt.toolTip())
195
+
196
+ # HDR knee row
197
+ row_k = QHBoxLayout()
198
+ row_k.setContentsMargins(0, 0, 0, 0)
199
+ row_k.setSpacing(8)
200
+ row_k.addWidget(QLabel(self.tr("Knee:")))
201
+
202
+ self.sld_hdr_knee = QSlider(Qt.Orientation.Horizontal)
203
+ self.sld_hdr_knee.setRange(10, 95)
204
+ self.sld_hdr_knee.setValue(75)
205
+ row_k.addWidget(self.sld_hdr_knee, 1)
206
+
207
+ self.lbl_hdr_knee = QLabel(f"{self.sld_hdr_knee.value()/100:.2f}")
208
+ row_k.addWidget(self.lbl_hdr_knee)
209
+
210
+ self.sld_hdr_knee.setToolTip(self.tr(
211
+ "Where compression begins (0–1).\n"
212
+ "Good starting point: knee ≈ target median + 0.10 to + 0.20.\n"
213
+ "Example: target 0.25 → knee 0.35–0.45."
214
+ ))
215
+ self.lbl_hdr_knee.setToolTip(self.sld_hdr_knee.toolTip())
216
+
217
+ hdr_lay.addLayout(row_a)
218
+ hdr_lay.addLayout(row_k)
219
+
220
+ self.hdr_row.setEnabled(False)
221
+
222
+ # --- Luma-only row (checkbox + dropdown on one line) ---
223
+ self.luma_row = QWidget()
224
+ lr = QHBoxLayout(self.luma_row)
225
+ lr.setContentsMargins(0, 0, 0, 0)
226
+ lr.setSpacing(8)
227
+
228
+ self.chk_luma_only = QCheckBox(self.tr("Luminance-only"))
229
+ self.chk_luma_only.setChecked(False)
230
+
231
+ self.cmb_luma = QComboBox()
232
+ keys = list(LUMA_PROFILES.keys())
233
+
234
+ def _cat(k):
235
+ return str(LUMA_PROFILES.get(k, {}).get("category", ""))
236
+
237
+ keys.sort(key=lambda k: (_cat(k), k.lower()))
238
+ self.cmb_luma.addItems(keys)
239
+ self.cmb_luma.setCurrentText("rec709")
240
+ self.cmb_luma.setEnabled(False)
241
+
242
+ lr.addWidget(self.chk_luma_only)
243
+ lr.addWidget(QLabel(self.tr("Mode:")))
244
+ lr.addWidget(self.cmb_luma, 1)
245
+
246
+ # --- Luma blend row (only meaningful when Luma-only is enabled) ---
247
+ self.luma_blend_row = QWidget()
248
+ lbr = QHBoxLayout(self.luma_blend_row)
249
+ lbr.setContentsMargins(0, 0, 0, 0)
250
+ lbr.setSpacing(8)
251
+
252
+ lbr.addWidget(QLabel(self.tr("Luma blend:")))
253
+
254
+ self.sld_luma_blend = QSlider(Qt.Orientation.Horizontal)
255
+ self.sld_luma_blend.setRange(0, 100) # 0=normal linked, 100=luma-only
256
+ self.sld_luma_blend.setValue(60) # nice default: “mostly luma” but tame
257
+ lbr.addWidget(self.sld_luma_blend, 1)
258
+
259
+ self.lbl_luma_blend = QLabel(f"{self.sld_luma_blend.value()/100:.2f}")
260
+ lbr.addWidget(self.lbl_luma_blend)
261
+
262
+ tip = self.tr(
263
+ "Blend between a normal linked RGB stretch (0.00) and a luminance-only stretch (1.00).\n"
264
+ "Use this to tame the saturation punch of luma-only."
265
+ )
266
+ self.luma_blend_row.setToolTip(tip)
267
+ self.sld_luma_blend.setToolTip(tip)
268
+ self.lbl_luma_blend.setToolTip(tip)
269
+
270
+ self.luma_blend_row.setEnabled(False)
271
+
272
+ # --- Curves boost ---
60
273
  self.chk_curves = QCheckBox(self.tr("Curves boost"))
61
274
  self.chk_curves.setChecked(False)
62
275
 
63
276
  self.curves_row = QWidget()
64
- cr_lay = QHBoxLayout(self.curves_row); cr_lay.setContentsMargins(0,0,0,0)
277
+ cr_lay = QHBoxLayout(self.curves_row)
278
+ cr_lay.setContentsMargins(0, 0, 0, 0)
65
279
  cr_lay.setSpacing(8)
280
+
66
281
  cr_lay.addWidget(QLabel(self.tr("Strength:")))
67
282
  self.sld_curves = QSlider(Qt.Orientation.Horizontal)
68
- self.sld_curves.setRange(0, 100) # 0.00 … 1.00 mapped to 0…100
69
- self.sld_curves.setSingleStep(1)
70
- self.sld_curves.setPageStep(5)
71
- self.sld_curves.setValue(20) # default 0.20
72
- self.lbl_curves_val = QLabel("0.20")
73
- self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
283
+ self.sld_curves.setRange(0, 100)
284
+ self.sld_curves.setValue(20)
74
285
  cr_lay.addWidget(self.sld_curves, 1)
286
+
287
+ self.lbl_curves_val = QLabel(f"{self.sld_curves.value()/100:.2f}")
75
288
  cr_lay.addWidget(self.lbl_curves_val)
76
- self.curves_row.setEnabled(False) # disabled until checkbox is ticked
77
- self.chk_curves.toggled.connect(self.curves_row.setEnabled)
78
289
 
79
- # Preview area
290
+ self.curves_row.setEnabled(False)
291
+
292
+ # ------------------------------------------------------------------
293
+ # Preview UI
294
+ # ------------------------------------------------------------------
80
295
  self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
81
296
  self.preview_label.setMinimumSize(QSize(320, 240))
82
- self.preview_label.setScaledContents(False)
297
+ self.preview_label.setScaledContents(False)
298
+ self.preview_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
299
+
83
300
  self.preview_scroll = QScrollArea()
84
- self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
301
+ self.preview_scroll.setWidgetResizable(False)
85
302
  self.preview_scroll.setWidget(self.preview_label)
86
303
  self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
87
304
  self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
305
+ self.preview_scroll.viewport().installEventFilter(self)
88
306
 
89
- self._fit_mode = True # NEW: start in Fit mode
90
-
91
- # --- Zoom buttons row (place before the main layout or right above preview) ---
92
- # --- Zoom buttons row ---
307
+ # Zoom buttons
93
308
  zoom_row = QHBoxLayout()
94
-
95
- # Use themed tool buttons (consistent with the rest of SASpro)
96
309
  self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
97
310
  self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
98
311
  self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
99
312
  self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
100
313
 
101
-
102
314
  zoom_row.addStretch(1)
103
315
  for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
104
316
  zoom_row.addWidget(b)
@@ -109,47 +321,326 @@ class StatisticalStretchDialog(QDialog):
109
321
  self.btn_apply = QPushButton(self.tr("Apply"))
110
322
  self.btn_close = QPushButton(self.tr("Close"))
111
323
 
112
- self.btn_preview.clicked.connect(self._do_preview)
113
- self.btn_apply.clicked.connect(self._do_apply)
114
- self.btn_close.clicked.connect(self.close)
115
-
116
- # --- Layout ---
324
+ self.btn_clipstats = QPushButton(self.tr("Clip stats"))
325
+ self.lbl_clipstats = QLabel("")
326
+ self.lbl_clipstats.setWordWrap(True)
327
+ self.lbl_clipstats.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
328
+ self.lbl_clipstats.setMinimumHeight(38)
329
+ self.lbl_clipstats.setFrameShape(QLabel.Shape.StyledPanel)
330
+ self.lbl_clipstats.setFrameShadow(QLabel.Shadow.Sunken)
331
+ self.lbl_clipstats.setContentsMargins(6, 4, 6, 4)
332
+
333
+ # ------------------------------------------------------------------
334
+ # Layout
335
+ # ------------------------------------------------------------------
117
336
  form = QFormLayout()
118
337
  form.addRow(self.tr("Target median:"), self.spin_target)
119
338
  form.addRow("", self.chk_linked)
339
+ form.addRow("", self.row_bp)
340
+ form.addRow("", self.chk_no_black_clip)
341
+ form.addRow("", self.chk_hdr)
342
+ form.addRow("", self.hdr_row)
343
+ form.addRow("", self.luma_row)
344
+ form.addRow("", self.luma_blend_row)
120
345
  form.addRow("", self.chk_normalize)
121
346
  form.addRow("", self.chk_curves)
122
347
  form.addRow("", self.curves_row)
123
348
 
124
349
  left = QVBoxLayout()
125
350
  left.addLayout(form)
126
- row = QHBoxLayout()
127
- row.addWidget(self.btn_preview)
128
- row.addWidget(self.btn_apply)
129
- row.addStretch(1)
130
- left.addLayout(row)
351
+
352
+ btn_row = QHBoxLayout()
353
+ btn_row.addWidget(self.btn_preview)
354
+ btn_row.addWidget(self.btn_apply)
355
+ btn_row.addWidget(self.btn_clipstats)
356
+ btn_row.addStretch(1)
357
+ left.addLayout(btn_row)
358
+
359
+ left.addWidget(self.lbl_clipstats)
131
360
  left.addStretch(1)
132
361
 
362
+ right = QVBoxLayout()
363
+ right.addLayout(zoom_row)
364
+ right.addWidget(self.preview_scroll, 1)
365
+
133
366
  main = QHBoxLayout(self)
134
367
  main.addLayout(left, 0)
135
-
136
- # NEW: right column with zoom row + preview
137
- right = QVBoxLayout()
138
- right.addLayout(zoom_row) # ← actually add the zoom controls
139
- right.addWidget(self.preview_scroll, 1) # preview below the buttons
140
368
  main.addLayout(right, 1)
141
369
 
370
+ # ------------------------------------------------------------------
371
+ # Behavior / wiring
372
+ # ------------------------------------------------------------------
373
+
374
+ # Blackpoint slider -> label + debounced clip stats
375
+ def _on_bp_changed(v: int):
376
+ self.lbl_bp.setText(f"{v/100:.2f}")
377
+ self._schedule_clip_stats()
378
+
379
+ self.sld_bp.valueChanged.connect(_on_bp_changed)
380
+
381
+ # No-black-clip toggles blackpoint UI + triggers stats
382
+ def _on_no_black_clip_toggled(on: bool):
383
+ self.row_bp.setEnabled(not on)
384
+ self._schedule_clip_stats()
385
+
386
+ self.chk_no_black_clip.toggled.connect(_on_no_black_clip_toggled)
387
+ _on_no_black_clip_toggled(self.chk_no_black_clip.isChecked())
388
+
389
+ # Curves
390
+ self.chk_curves.toggled.connect(self.curves_row.setEnabled)
391
+ self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
392
+
393
+ # HDR enable toggles HDR row
394
+ self.chk_hdr.toggled.connect(self.hdr_row.setEnabled)
395
+ self.sld_hdr_amt.valueChanged.connect(lambda v: self.lbl_hdr_amt.setText(f"{v/100:.2f}"))
396
+ self.sld_hdr_knee.valueChanged.connect(lambda v: self.lbl_hdr_knee.setText(f"{v/100:.2f}"))
397
+ self.sld_hdr_knee.sliderPressed.connect(lambda: setattr(self, "_hdr_knee_user_locked", True))
398
+
399
+ # Auto-suggest HDR knee from target (unless user locked)
400
+ def _suggest_hdr_knee_from_target():
401
+ if getattr(self, "_hdr_knee_user_locked", False):
402
+ return
403
+ t = float(self.spin_target.value())
404
+ knee = float(np.clip(t + 0.10, 0.10, 0.95))
405
+ self.sld_hdr_knee.blockSignals(True)
406
+ self.sld_hdr_knee.setValue(int(round(knee * 100)))
407
+ self.sld_hdr_knee.blockSignals(False)
408
+ self.lbl_hdr_knee.setText(f"{knee:.2f}")
409
+
410
+ self.spin_target.valueChanged.connect(_suggest_hdr_knee_from_target)
411
+
412
+ # Luma-only: enables dropdown, disables "linked channels"
413
+ self.chk_luma_only.toggled.connect(self.cmb_luma.setEnabled)
414
+
415
+ def _on_luma_only_toggled(on: bool):
416
+ self.chk_linked.setEnabled(not on)
417
+
418
+ self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
419
+
420
+ # Zoom buttons
142
421
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
143
422
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
144
423
  self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
145
424
  self.btn_zoom_fit.clicked.connect(self._fit_preview)
146
425
 
147
- self.preview_scroll.viewport().installEventFilter(self)
148
- self.preview_label.installEventFilter(self)
426
+ # Main buttons
427
+ self.btn_preview.clicked.connect(self._do_preview)
428
+ self.btn_apply.clicked.connect(self._do_apply)
429
+ self.btn_close.clicked.connect(self.close)
430
+ self.btn_clipstats.clicked.connect(self._do_clip_stats)
431
+
432
+ # Debounced clip stats timer
433
+ self._clip_timer = QTimer(self)
434
+ self._clip_timer.setSingleShot(True)
435
+ self._clip_timer.setInterval(500)
436
+ self._clip_timer.timeout.connect(self._do_clip_stats)
437
+
438
+ # Initialize UI state
439
+ _suggest_hdr_knee_from_target()
440
+ self.sld_luma_blend.valueChanged.connect(
441
+ lambda v: self.lbl_luma_blend.setText(f"{v/100:.2f}")
442
+ )
443
+
444
+ def _on_luma_only_toggled(on: bool):
445
+ self.chk_linked.setEnabled(not on)
446
+ self.luma_blend_row.setEnabled(on)
447
+
448
+ self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
449
+ _on_luma_only_toggled(self.chk_luma_only.isChecked())
149
450
 
451
+ # Initial preview + clip stats
150
452
  self._populate_initial_preview()
151
453
 
454
+
152
455
  # ----- helpers -----
456
+ def _show_busy(self, title: str, text: str):
457
+ # Avoid stacking dialogs
458
+ self._hide_busy()
459
+
460
+ dlg = QProgressDialog(text, None, 0, 0, self)
461
+ dlg.setWindowTitle(title)
462
+ dlg.setWindowModality(Qt.WindowModality.WindowModal) # blocks only this tool window
463
+ dlg.setMinimumDuration(0)
464
+ dlg.setValue(0)
465
+ dlg.setCancelButton(None) # no cancel button (keeps it simple)
466
+ dlg.setAutoClose(False)
467
+ dlg.setAutoReset(False)
468
+ dlg.setFixedWidth(320)
469
+ dlg.show()
470
+
471
+ # Ensure it paints before heavy work starts
472
+ QApplication.processEvents()
473
+ self._busy = dlg
474
+
475
+ def _hide_busy(self):
476
+ try:
477
+ if getattr(self, "_busy", None) is not None:
478
+ self._busy.close()
479
+ self._busy.deleteLater()
480
+ except Exception:
481
+ pass
482
+ self._busy = None
483
+
484
+ def _set_controls_enabled(self, enabled: bool):
485
+ try:
486
+ self.btn_preview.setEnabled(enabled)
487
+ self.btn_apply.setEnabled(enabled)
488
+ if getattr(self, "btn_clipstats", None) is not None:
489
+ self.btn_clipstats.setEnabled(enabled)
490
+ except Exception:
491
+ pass
492
+
493
+ def _clip_mode_label(self, imgf: np.ndarray) -> str:
494
+ # Mono image
495
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
496
+ return self.tr("Mono")
497
+
498
+ # RGB image
499
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
500
+ if luma_only:
501
+ return self.tr("Luma-only (L ≤ bp)")
502
+
503
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
504
+ if linked:
505
+ return self.tr("Linked (L ≤ bp)")
506
+
507
+ return self.tr("Unlinked (any channel ≤ bp)")
508
+
509
+
510
+ def _do_clip_stats(self):
511
+ imgf = self._get_source_float()
512
+ if imgf is None or imgf.size == 0:
513
+ self.lbl_clipstats.setText(self.tr("No image loaded."))
514
+ return
515
+
516
+ sig = float(self.sld_bp.value()) / 100.0
517
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
518
+
519
+ # Modes that affect how we count / threshold
520
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
521
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
522
+
523
+ # Outputs we’ll fill
524
+ bp = None # float threshold (mono / L-based modes)
525
+ bp3 = None # per-channel thresholds (unlinked RGB)
526
+ clipped = None # [H,W] bool
527
+
528
+ # --- Compute blackpoint threshold(s) exactly like stretch.py ---
529
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
530
+ mono = imgf.squeeze().astype(np.float32, copy=False)
531
+ if no_black_clip:
532
+ bp = float(mono.min())
533
+ else:
534
+ bp, _ = _compute_blackpoint_sigma(mono, sig)
535
+
536
+ clipped = (mono <= bp)
537
+
538
+ else:
539
+ rgb = imgf.astype(np.float32, copy=False)
540
+
541
+ if luma_only or linked:
542
+ # One threshold for the pixel: use luminance proxy
543
+ L = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
544
+ if no_black_clip:
545
+ bp = float(L.min())
546
+ else:
547
+ bp, _ = _compute_blackpoint_sigma(L, sig)
548
+
549
+ clipped = (L <= bp)
550
+
551
+ else:
552
+ # Unlinked: per-channel thresholds
553
+ if no_black_clip:
554
+ bp3 = np.array(
555
+ [float(rgb[..., 0].min()),
556
+ float(rgb[..., 1].min()),
557
+ float(rgb[..., 2].min())],
558
+ dtype=np.float32
559
+ )
560
+ else:
561
+ bp3 = _compute_blackpoint_sigma_per_channel(rgb, sig).astype(np.float32, copy=False)
562
+
563
+ # Pixel considered clipped if ANY channel would clip
564
+ clipped = np.any(rgb <= bp3.reshape((1, 1, 3)), axis=2)
565
+
566
+ # --- Count pixels (NOT rgb elements) ---
567
+ clipped_count = int(np.count_nonzero(clipped))
568
+ total = int(clipped.size)
569
+ pct = 100.0 * clipped_count / max(1, total)
570
+
571
+ # --- Optional masked-area stats ---
572
+ masked_note = ""
573
+ m = self._active_mask_array()
574
+ if m is not None:
575
+ affected = (m > 0.01)
576
+ aff_total = int(np.count_nonzero(affected))
577
+ aff_clip = int(np.count_nonzero(clipped & affected))
578
+ aff_pct = 100.0 * aff_clip / max(1, aff_total)
579
+ masked_note = self.tr(f" | masked area: {aff_clip:,}/{aff_total:,} ({aff_pct:.4f}%)")
580
+
581
+ mode_lbl = self._clip_mode_label(imgf)
582
+
583
+ # --- No-black-clip message (must be mode-aware) ---
584
+ if no_black_clip:
585
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
586
+ bp_text = self.tr(f"min={float(bp):.6f}")
587
+ else:
588
+ if luma_only or linked:
589
+ bp_text = self.tr(f"L min={float(bp):.6f}")
590
+ else:
591
+ # bp3 exists here
592
+ bp_text = self.tr(
593
+ f"R min={float(bp3[0]):.6f}, G min={float(bp3[1]):.6f}, B min={float(bp3[2]):.6f}"
594
+ )
595
+
596
+ self.lbl_clipstats.setText(
597
+ self.tr(f"Black clipping disabled ({mode_lbl}). Threshold={bp_text}: "
598
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
599
+ )
600
+ return
601
+
602
+ # --- Normal message: show correct threshold(s) ---
603
+ if (imgf.ndim == 3 and imgf.shape[2] == 3) and not (luma_only or linked):
604
+ # Unlinked RGB: show per-channel thresholds
605
+ bp_disp = self.tr(
606
+ f"R={float(bp3[0]):.6f}, G={float(bp3[1]):.6f}, B={float(bp3[2]):.6f}"
607
+ )
608
+ else:
609
+ # Mono or L-based: single threshold
610
+ bp_disp = self.tr(f"{float(bp):.6f}")
611
+
612
+ self.lbl_clipstats.setText(
613
+ self.tr(f"Black clip ({mode_lbl}) @ {bp_disp}: "
614
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
615
+ )
616
+
617
+
618
+ def _start_stretch_job(self, mode: str):
619
+ """
620
+ mode: 'preview' or 'apply'
621
+ """
622
+ if getattr(self, "_job_running", False):
623
+ return
624
+
625
+ self._job_running = True
626
+ self._job_mode = mode
627
+
628
+ self._set_controls_enabled(False)
629
+ self._show_busy("Statistical Stretch", "Processing…")
630
+
631
+ self._thread = QThread(self._main)
632
+ self._worker = _StretchWorker(self)
633
+ self._worker.moveToThread(self._thread)
634
+
635
+ self._thread.started.connect(self._worker.run)
636
+ self._worker.finished.connect(self._on_stretch_done)
637
+ self._worker.finished.connect(self._thread.quit)
638
+ self._worker.finished.connect(self._worker.deleteLater)
639
+ self._thread.finished.connect(self._thread.deleteLater)
640
+
641
+ self._thread.start()
642
+
643
+
153
644
  def _get_source_float(self) -> np.ndarray:
154
645
  """
155
646
  Return a float32 array scaled into ~[0..1] for stretching.
@@ -203,12 +694,43 @@ class StatisticalStretchDialog(QDialog):
203
694
  self._update_preview_scaled()
204
695
 
205
696
  def _zoom_by(self, factor: float):
206
- """Incremental zoom around the current center; exits Fit mode."""
697
+ vp = self.preview_scroll.viewport()
698
+ center = vp.rect().center()
699
+ self._zoom_at(factor, center)
700
+
701
+ def _zoom_at(self, factor: float, vp_pos):
702
+ """Zoom keeping the image point under vp_pos (viewport coords) stationary."""
703
+ if self._preview_qimg is None:
704
+ return
705
+
706
+ old_scale = float(self._preview_scale)
707
+
708
+ # Content coords (in scaled-image pixels) currently under the mouse
709
+ hsb = self.preview_scroll.horizontalScrollBar()
710
+ vsb = self.preview_scroll.verticalScrollBar()
711
+ cx = hsb.value() + int(vp_pos.x())
712
+ cy = vsb.value() + int(vp_pos.y())
713
+
714
+ # Convert to image-space coords (unscaled)
715
+ ix = cx / old_scale
716
+ iy = cy / old_scale
717
+
718
+ # Apply zoom
207
719
  self._fit_mode = False
208
- new_scale = self._preview_scale * float(factor)
209
- self._preview_scale = max(0.05, min(new_scale, 8.0))
720
+ new_scale = max(0.05, min(old_scale * float(factor), 8.0))
721
+ self._preview_scale = new_scale
722
+
723
+ # Rebuild pixmap/label size
210
724
  self._update_preview_scaled()
211
725
 
726
+ # New content coords for same image-space point
727
+ ncx = int(ix * new_scale)
728
+ ncy = int(iy * new_scale)
729
+
730
+ # Set scrollbars so that point stays under the mouse
731
+ hsb.setValue(ncx - int(vp_pos.x()))
732
+ vsb.setValue(ncy - int(vp_pos.y()))
733
+
212
734
 
213
735
  # --- MASK helpers ----------------------------------------------------
214
736
  def _active_mask_array(self) -> np.ndarray | None:
@@ -231,10 +753,13 @@ class StatisticalStretchDialog(QDialog):
231
753
  elif m.ndim == 3: # RGB/whatever → luminance
232
754
  m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
233
755
 
234
- m = m.astype(np.float32, copy=False)
235
- # normalize if integer / out-of-range
236
- if m.dtype.kind in "ui":
237
- m /= float(np.iinfo(m.dtype).max)
756
+ orig = m
757
+ # normalize if integer
758
+ if orig.dtype.kind in "ui":
759
+ m = orig.astype(np.float32) / float(np.iinfo(orig.dtype).max)
760
+ else:
761
+ m = orig.astype(np.float32, copy=False)
762
+
238
763
  m = np.clip(m, 0.0, 1.0)
239
764
 
240
765
  th, tw = self.doc.image.shape[:2]
@@ -265,6 +790,14 @@ class StatisticalStretchDialog(QDialog):
265
790
  imgf = self._get_source_float()
266
791
  if imgf is None:
267
792
  return None
793
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
794
+ hdr_on = bool(self.chk_hdr.isChecked())
795
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
796
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
797
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
798
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
799
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
800
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
268
801
 
269
802
  target = float(self.spin_target.value())
270
803
  linked = bool(self.chk_linked.isChecked())
@@ -279,6 +812,11 @@ class StatisticalStretchDialog(QDialog):
279
812
  normalize=normalize,
280
813
  apply_curves=apply_curves,
281
814
  curves_boost=curves_boost,
815
+ blackpoint_sigma=blackpoint_sigma,
816
+ no_black_clip=no_black_clip,
817
+ hdr_compress=hdr_on,
818
+ hdr_amount=hdr_amount,
819
+ hdr_knee=hdr_knee,
282
820
  )
283
821
  else:
284
822
  out = stretch_color_image(
@@ -288,6 +826,14 @@ class StatisticalStretchDialog(QDialog):
288
826
  normalize=normalize,
289
827
  apply_curves=apply_curves,
290
828
  curves_boost=curves_boost,
829
+ blackpoint_sigma=blackpoint_sigma,
830
+ no_black_clip=no_black_clip,
831
+ hdr_compress=hdr_on,
832
+ hdr_amount=hdr_amount,
833
+ hdr_knee=hdr_knee,
834
+ luma_only=luma_only,
835
+ luma_mode=luma_mode,
836
+ luma_blend=luma_blend, # <-- NEW
291
837
  )
292
838
 
293
839
  # ✅ If a mask is active, blend stretched result with original
@@ -341,6 +887,10 @@ class StatisticalStretchDialog(QDialog):
341
887
  return
342
888
  self.doc = doc
343
889
  self._populate_initial_preview()
890
+ try:
891
+ self._schedule_clip_stats()
892
+ except Exception:
893
+ pass
344
894
 
345
895
  # ----- slots -----
346
896
  def _populate_initial_preview(self):
@@ -348,56 +898,68 @@ class StatisticalStretchDialog(QDialog):
348
898
  src = self._get_source_float()
349
899
  if src is not None:
350
900
  self._set_preview_pixmap(np.clip(src, 0, 1))
901
+ try:
902
+ self.lbl_clipstats.setText(self.tr("Calculating clip stats…"))
903
+ except Exception:
904
+ pass
905
+ try:
906
+ self._schedule_clip_stats()
907
+ except Exception:
908
+ pass
909
+
351
910
 
352
911
  def _do_preview(self):
353
- try:
354
- out = self._run_stretch()
355
- if out is None:
356
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
357
- return
358
- self._set_preview_pixmap(out)
359
- except Exception as e:
360
- QMessageBox.warning(self, "Preview failed", str(e))
912
+ self._start_stretch_job("preview")
913
+
361
914
 
362
915
  def _do_apply(self):
363
- try:
364
- out = self._run_stretch()
365
- if out is None:
366
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
367
- return
916
+ self._start_stretch_job("apply")
917
+
918
+ def _apply_out_to_doc(self, out: np.ndarray):
919
+ # Preserve mono vs color shape
920
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
921
+ out = out[..., 0]
368
922
 
369
- # Preserve mono vs color shape
370
- if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
371
- out = out[..., 0]
372
-
373
- # --- Gather current UI state ------------------------------------
374
- target = float(self.spin_target.value())
375
- linked = bool(self.chk_linked.isChecked())
376
- normalize = bool(self.chk_normalize.isChecked())
377
- apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
378
- curves_boost = 0.0
379
- if getattr(self, "sld_curves", None) is not None:
380
- curves_boost = float(self.sld_curves.value()) / 100.0
381
-
382
- # Build human-readable step name
383
- parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
384
- if normalize:
385
- parts.append("norm")
386
- if apply_curves:
387
- parts.append(f"curves={curves_boost:.2f}")
388
- if self._active_mask_array() is not None:
389
- parts.append("masked")
390
- step_name = f"Statistical Stretch ({', '.join(parts)})"
391
-
392
- # Apply to document
393
- self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
394
-
395
- # Turn off display stretch on the active view, if any
396
- mw = self.parent()
397
- if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
398
- view = mw.mdi.activeSubWindow().widget()
399
- if getattr(view, "autostretch_enabled", False):
400
- view.set_autostretch(False)
923
+ # --- Gather current UI state ------------------------------------
924
+ target = float(self.spin_target.value())
925
+ linked = bool(self.chk_linked.isChecked())
926
+ normalize = bool(self.chk_normalize.isChecked())
927
+ apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
928
+ curves_boost = float(self.sld_curves.value()) / 100.0 if getattr(self, "sld_curves", None) is not None else 0.0
929
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
930
+ hdr_on = bool(self.chk_hdr.isChecked())
931
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
932
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
933
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
934
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
935
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
936
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
937
+
938
+ parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
939
+ if normalize:
940
+ parts.append("norm")
941
+ if apply_curves:
942
+ parts.append(f"curves={curves_boost:.2f}")
943
+ if self._active_mask_array() is not None:
944
+ parts.append("masked")
945
+ parts.append(f"bpσ={blackpoint_sigma:.2f}")
946
+ if hdr_on and hdr_amount > 0:
947
+ parts.append(f"hdr={hdr_amount:.2f}@{hdr_knee:.2f}")
948
+ if luma_only:
949
+ parts.append(f"luma={luma_mode}")
950
+ parts.append(f"blend={luma_blend:.2f}")
951
+ if no_black_clip:
952
+ parts.append("no_black_clip")
953
+
954
+ step_name = f"Statistical Stretch ({', '.join(parts)})"
955
+ self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
956
+
957
+ # Turn off display stretch on the active view, if any
958
+ mw = self.parent()
959
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
960
+ view = mw.mdi.activeSubWindow().widget()
961
+ if getattr(view, "autostretch_enabled", False):
962
+ view.set_autostretch(False)
401
963
 
402
964
  # Existing logging, now using the same values as above
403
965
  if hasattr(mw, "_log"):
@@ -406,6 +968,10 @@ class StatisticalStretchDialog(QDialog):
406
968
  mw._log(
407
969
  "Applied Statistical Stretch "
408
970
  f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
971
+ f"bp_sigma={blackpoint_sigma:.2f}, "
972
+ f"hdr={'ON' if hdr_on else 'OFF'}"
973
+ f"{', amt='+str(round(hdr_amount,2))+' knee='+str(round(hdr_knee,2)) if hdr_on else ''}, "
974
+ f"luma={'ON' if luma_only else 'OFF'}{', mode='+luma_mode if luma_only else ''}, "
409
975
  f"curves={'ON' if curves_on else 'OFF'}"
410
976
  f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
411
977
  f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
@@ -419,6 +985,14 @@ class StatisticalStretchDialog(QDialog):
419
985
  "normalize": normalize,
420
986
  "apply_curves": apply_curves,
421
987
  "curves_boost": curves_boost,
988
+ "blackpoint_sigma": blackpoint_sigma,
989
+ "no_black_clip": no_black_clip,
990
+ "hdr_compress": hdr_on,
991
+ "hdr_amount": hdr_amount,
992
+ "hdr_knee": hdr_knee,
993
+ "luma_only": luma_only,
994
+ "luma_mode": luma_mode,
995
+ "luma_blend": luma_blend,
422
996
  }
423
997
 
424
998
  # ✅ Remember this as the last headless-style command
@@ -446,13 +1020,8 @@ class StatisticalStretchDialog(QDialog):
446
1020
  # optional debug
447
1021
  print("Statistical Stretch: replay recording suppressed for this apply()")
448
1022
 
449
- # Dialog stays open so user can apply to other images
450
- # Update the document reference to reflect the now-active document
451
- self._refresh_document_from_active()
452
-
1023
+ self.close()
453
1024
 
454
- except Exception as e:
455
- QMessageBox.critical(self, "Apply failed", str(e))
456
1025
 
457
1026
  def _refresh_document_from_active(self):
458
1027
  """
@@ -471,6 +1040,59 @@ class StatisticalStretchDialog(QDialog):
471
1040
  except Exception:
472
1041
  pass
473
1042
 
1043
+ @pyqtSlot(object, str)
1044
+ def _on_stretch_done(self, out, err: str):
1045
+ # dialog might be closing; guard
1046
+ if sip.isdeleted(self):
1047
+ return
1048
+
1049
+ self._hide_busy()
1050
+ self._set_controls_enabled(True)
1051
+ self._job_running = False
1052
+
1053
+ if err:
1054
+ QMessageBox.warning(self, "Stretch failed", err)
1055
+ return
1056
+
1057
+ if out is None:
1058
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
1059
+ return
1060
+
1061
+ if getattr(self, "_job_mode", "") == "preview":
1062
+ self._set_preview_pixmap(out)
1063
+ return
1064
+
1065
+ # apply mode: reuse your existing apply logic, but using `out` we already computed
1066
+ self._apply_out_to_doc(out)
1067
+
1068
+ if getattr(self, "_pending_close", False):
1069
+ self._pending_close = False
1070
+ self.close()
1071
+
1072
+ def closeEvent(self, ev):
1073
+ # If a job is running, DO NOT close (WA_DeleteOnClose would delete the QThread)
1074
+ if getattr(self, "_job_running", False):
1075
+ self._pending_close = True
1076
+ try:
1077
+ self._hide_busy()
1078
+ except Exception:
1079
+ pass
1080
+ try:
1081
+ self.hide()
1082
+ except Exception:
1083
+ pass
1084
+ ev.ignore()
1085
+ return
1086
+
1087
+ # disconnect follow behavior
1088
+ try:
1089
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
1090
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1091
+ except Exception:
1092
+ pass
1093
+
1094
+ super().closeEvent(ev)
1095
+
474
1096
 
475
1097
  def _update_preview_scaled(self):
476
1098
  if self._preview_qimg is None:
@@ -493,30 +1115,24 @@ class StatisticalStretchDialog(QDialog):
493
1115
 
494
1116
  def eventFilter(self, obj, ev):
495
1117
  # Ctrl+wheel zoom
496
- if ev.type() == QEvent.Type.Wheel and (obj is self.preview_scroll.viewport() or obj is self.preview_label):
1118
+ if ev.type() == QEvent.Type.Wheel and obj is self.preview_scroll.viewport():
497
1119
  if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
498
- factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
499
- self._fit_mode = False # ← ensure we exit Fit mode
500
- self._preview_scale = max(0.05, min(self._preview_scale * factor, 8.0))
501
- self._update_preview_scaled()
1120
+ factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
1121
+ self._zoom_at(factor, ev.position())
502
1122
  return True
503
1123
  return False
504
1124
 
505
1125
  # Click+drag pan (left or middle mouse)
506
- if obj is self.preview_scroll.viewport() or obj is self.preview_label:
1126
+ if obj is self.preview_scroll.viewport():
507
1127
  if ev.type() == QEvent.Type.MouseButtonPress:
508
- if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
1128
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
509
1129
  self._panning = True
510
- self._pan_last = ev.position().toPoint()
511
- # show a "grab" cursor where the drag begins
512
- if obj is self.preview_label:
513
- self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
514
- else:
515
- self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
1130
+ self._pan_last = ev.globalPosition().toPoint()
1131
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
516
1132
  return True
517
1133
 
518
1134
  elif ev.type() == QEvent.Type.MouseMove and self._panning:
519
- pos = ev.position().toPoint()
1135
+ pos = ev.globalPosition().toPoint()
520
1136
  delta = pos - self._pan_last
521
1137
  self._pan_last = pos
522
1138
 
@@ -527,12 +1143,11 @@ class StatisticalStretchDialog(QDialog):
527
1143
  return True
528
1144
 
529
1145
  elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
530
- self._panning = False
531
- self._pan_last = None
532
- # restore cursor
533
- self.preview_label.unsetCursor()
534
- self.preview_scroll.viewport().unsetCursor()
535
- return True
1146
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
1147
+ self._panning = False
1148
+ self._pan_last = None
1149
+ self.preview_scroll.viewport().unsetCursor()
1150
+ return True
536
1151
 
537
1152
  return super().eventFilter(obj, ev)
538
1153