setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.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 (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.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,QProgressBar, QApplication
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,91 +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)
32
58
  try:
33
59
  self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
34
60
  except Exception:
35
- pass # older PyQt6 versions
61
+ pass
62
+
63
+ # --- State / refs ---
36
64
  self._main = parent
37
65
  self.doc = document
66
+
38
67
  self._last_preview = None
68
+ self._preview_qimg = None
69
+ self._preview_scale = 1.0
70
+ self._fit_mode = True
71
+
72
+ self._panning = False
73
+ self._pan_last = None # QPoint
74
+
75
+ self._hdr_knee_user_locked = False
76
+ self._pending_close = False
77
+ self._suppress_replay_record = False
78
+
79
+ # ---- Clip-stats scheduling (define EARLY so init callbacks can't crash) ----
80
+ self._clip_timer = None
39
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
40
97
  self._follow_conn = None
98
+ self._job_running = False
99
+ self._job_mode = ""
100
+
101
+ # --- Follow active document changes (optional) ---
41
102
  if hasattr(self._main, "currentDocumentChanged"):
42
103
  try:
43
- # store connection so we can cleanly disconnect
44
104
  self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
45
105
  self._follow_conn = True
46
106
  except Exception:
47
107
  self._follow_conn = None
48
- self._panning = False
49
- self._pan_last = None # QPoint
50
- self._preview_scale = 1.0 # NEW: zoom factor for preview
51
- self._preview_qimg = None # NEW: store unscaled QImage for clean scaling
52
- self._suppress_replay_record = False
53
108
 
54
- # --- Controls ---
109
+ # ------------------------------------------------------------------
110
+ # Controls
111
+ # ------------------------------------------------------------------
112
+
113
+ # Target median
55
114
  self.spin_target = QDoubleSpinBox()
56
115
  self.spin_target.setRange(0.01, 0.99)
57
116
  self.spin_target.setSingleStep(0.01)
58
117
  self.spin_target.setValue(0.25)
59
118
  self.spin_target.setDecimals(3)
60
119
 
120
+ # Linked channels
61
121
  self.chk_linked = QCheckBox(self.tr("Linked channels"))
62
122
  self.chk_linked.setChecked(False)
63
123
 
124
+ # Normalize
64
125
  self.chk_normalize = QCheckBox(self.tr("Normalize to [0..1]"))
65
126
  self.chk_normalize.setChecked(False)
66
127
 
67
- # 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 ---
68
273
  self.chk_curves = QCheckBox(self.tr("Curves boost"))
69
274
  self.chk_curves.setChecked(False)
70
275
 
71
276
  self.curves_row = QWidget()
72
- 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)
73
279
  cr_lay.setSpacing(8)
280
+
74
281
  cr_lay.addWidget(QLabel(self.tr("Strength:")))
75
282
  self.sld_curves = QSlider(Qt.Orientation.Horizontal)
76
- self.sld_curves.setRange(0, 100) # 0.00 … 1.00 mapped to 0…100
77
- self.sld_curves.setSingleStep(1)
78
- self.sld_curves.setPageStep(5)
79
- self.sld_curves.setValue(20) # default 0.20
80
- self.lbl_curves_val = QLabel("0.20")
81
- 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)
82
285
  cr_lay.addWidget(self.sld_curves, 1)
286
+
287
+ self.lbl_curves_val = QLabel(f"{self.sld_curves.value()/100:.2f}")
83
288
  cr_lay.addWidget(self.lbl_curves_val)
84
- self.curves_row.setEnabled(False) # disabled until checkbox is ticked
85
- self.chk_curves.toggled.connect(self.curves_row.setEnabled)
86
289
 
87
- # Preview area
290
+ self.curves_row.setEnabled(False)
291
+
292
+ # ------------------------------------------------------------------
293
+ # Preview UI
294
+ # ------------------------------------------------------------------
88
295
  self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
89
296
  self.preview_label.setMinimumSize(QSize(320, 240))
90
- self.preview_label.setScaledContents(False)
297
+ self.preview_label.setScaledContents(False)
298
+ self.preview_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
299
+
91
300
  self.preview_scroll = QScrollArea()
92
- self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
301
+ self.preview_scroll.setWidgetResizable(False)
93
302
  self.preview_scroll.setWidget(self.preview_label)
94
303
  self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
95
304
  self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
305
+ self.preview_scroll.viewport().installEventFilter(self)
96
306
 
97
- self._fit_mode = True # NEW: start in Fit mode
98
-
99
- # --- Zoom buttons row (place before the main layout or right above preview) ---
100
- # --- Zoom buttons row ---
307
+ # Zoom buttons
101
308
  zoom_row = QHBoxLayout()
102
-
103
- # Use themed tool buttons (consistent with the rest of SASpro)
104
309
  self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
105
310
  self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
106
311
  self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
107
312
  self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
108
313
 
109
-
110
314
  zoom_row.addStretch(1)
111
315
  for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
112
316
  zoom_row.addWidget(b)
@@ -116,48 +320,475 @@ class StatisticalStretchDialog(QDialog):
116
320
  self.btn_preview = QPushButton(self.tr("Preview"))
117
321
  self.btn_apply = QPushButton(self.tr("Apply"))
118
322
  self.btn_close = QPushButton(self.tr("Close"))
119
-
120
- self.btn_preview.clicked.connect(self._do_preview)
121
- self.btn_apply.clicked.connect(self._do_apply)
122
- self.btn_close.clicked.connect(self.close)
123
-
124
- # --- Layout ---
323
+ self.btn_reset = QPushButton(self.tr("Reset ⟳"))
324
+
325
+ self.btn_clipstats = QPushButton(self.tr("Clip stats"))
326
+ self.lbl_clipstats = QLabel("")
327
+ self.lbl_clipstats.setWordWrap(True)
328
+ self.lbl_clipstats.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
329
+ self.lbl_clipstats.setMinimumHeight(38)
330
+ self.lbl_clipstats.setFrameShape(QLabel.Shape.StyledPanel)
331
+ self.lbl_clipstats.setFrameShadow(QLabel.Shadow.Sunken)
332
+ self.lbl_clipstats.setContentsMargins(6, 4, 6, 4)
333
+
334
+ # --- In-UI busy indicator (Wayland-friendly) ---
335
+ self.busy_row = QWidget()
336
+ br = QHBoxLayout(self.busy_row)
337
+ br.setContentsMargins(0, 0, 0, 0)
338
+ br.setSpacing(8)
339
+
340
+ self.lbl_busy = QLabel(self.tr("Processing…"))
341
+ self.lbl_busy.setStyleSheet("color:#888;")
342
+ self.pbar_busy = QProgressBar()
343
+ self.pbar_busy.setRange(0, 0) # indeterminate
344
+ self.pbar_busy.setTextVisible(False)
345
+ self.pbar_busy.setFixedHeight(10)
346
+
347
+ br.addWidget(self.lbl_busy)
348
+ br.addWidget(self.pbar_busy, 1)
349
+
350
+ self.busy_row.setVisible(False) # hidden until needed
351
+
352
+ # ------------------------------------------------------------------
353
+ # Layout
354
+ # ------------------------------------------------------------------
125
355
  form = QFormLayout()
126
356
  form.addRow(self.tr("Target median:"), self.spin_target)
127
357
  form.addRow("", self.chk_linked)
358
+ form.addRow("", self.row_bp)
359
+ form.addRow("", self.chk_no_black_clip)
360
+ form.addRow("", self.chk_hdr)
361
+ form.addRow("", self.hdr_row)
362
+ form.addRow("", self.luma_row)
363
+ form.addRow("", self.luma_blend_row)
128
364
  form.addRow("", self.chk_normalize)
129
365
  form.addRow("", self.chk_curves)
130
366
  form.addRow("", self.curves_row)
131
367
 
132
368
  left = QVBoxLayout()
133
369
  left.addLayout(form)
134
- row = QHBoxLayout()
135
- row.addWidget(self.btn_preview)
136
- row.addWidget(self.btn_apply)
137
- row.addStretch(1)
138
- left.addLayout(row)
370
+
371
+ btn_row = QHBoxLayout()
372
+ btn_row.addWidget(self.btn_preview)
373
+ btn_row.addWidget(self.btn_apply)
374
+ btn_row.addWidget(self.btn_clipstats)
375
+ btn_row.addStretch(1)
376
+ btn_row.addWidget(self.btn_reset)
377
+ btn_row.addStretch(1)
378
+ left.addLayout(btn_row)
379
+
380
+ left.addWidget(self.lbl_clipstats)
381
+ left.addWidget(self.busy_row)
139
382
  left.addStretch(1)
140
383
 
384
+ right = QVBoxLayout()
385
+ right.addLayout(zoom_row)
386
+ right.addWidget(self.preview_scroll, 1)
387
+
141
388
  main = QHBoxLayout(self)
142
389
  main.addLayout(left, 0)
143
-
144
- # NEW: right column with zoom row + preview
145
- right = QVBoxLayout()
146
- right.addLayout(zoom_row) # ← actually add the zoom controls
147
- right.addWidget(self.preview_scroll, 1) # preview below the buttons
148
390
  main.addLayout(right, 1)
149
391
 
392
+ # ------------------------------------------------------------------
393
+ # Behavior / wiring
394
+ # ------------------------------------------------------------------
395
+
396
+ # Blackpoint slider -> label + debounced clip stats
397
+ def _on_bp_changed(v: int):
398
+ self.lbl_bp.setText(f"{v/100:.2f}")
399
+ self._schedule_clip_stats()
400
+
401
+ self.sld_bp.valueChanged.connect(_on_bp_changed)
402
+
403
+ # No-black-clip toggles blackpoint UI + triggers stats
404
+ def _on_no_black_clip_toggled(on: bool):
405
+ self.row_bp.setEnabled(not on)
406
+ self._schedule_clip_stats()
407
+
408
+ self.chk_no_black_clip.toggled.connect(_on_no_black_clip_toggled)
409
+ _on_no_black_clip_toggled(self.chk_no_black_clip.isChecked())
410
+
411
+ # Curves
412
+ self.chk_curves.toggled.connect(self.curves_row.setEnabled)
413
+ self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
414
+
415
+ # HDR enable toggles HDR row
416
+ self.chk_hdr.toggled.connect(self.hdr_row.setEnabled)
417
+ self.sld_hdr_amt.valueChanged.connect(lambda v: self.lbl_hdr_amt.setText(f"{v/100:.2f}"))
418
+ self.sld_hdr_knee.valueChanged.connect(lambda v: self.lbl_hdr_knee.setText(f"{v/100:.2f}"))
419
+ self.sld_hdr_knee.sliderPressed.connect(lambda: setattr(self, "_hdr_knee_user_locked", True))
420
+
421
+ # Auto-suggest HDR knee from target (unless user locked)
422
+ def _suggest_hdr_knee_from_target():
423
+ if getattr(self, "_hdr_knee_user_locked", False):
424
+ return
425
+ t = float(self.spin_target.value())
426
+ knee = float(np.clip(t + 0.10, 0.10, 0.95))
427
+ self.sld_hdr_knee.blockSignals(True)
428
+ self.sld_hdr_knee.setValue(int(round(knee * 100)))
429
+ self.sld_hdr_knee.blockSignals(False)
430
+ self.lbl_hdr_knee.setText(f"{knee:.2f}")
431
+
432
+ self.spin_target.valueChanged.connect(_suggest_hdr_knee_from_target)
433
+
434
+ # Zoom buttons
150
435
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
151
436
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
152
437
  self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
153
438
  self.btn_zoom_fit.clicked.connect(self._fit_preview)
154
439
 
155
- self.preview_scroll.viewport().installEventFilter(self)
156
- self.preview_label.installEventFilter(self)
440
+ # Main buttons
441
+ self.btn_preview.clicked.connect(self._do_preview)
442
+ self.btn_apply.clicked.connect(self._do_apply)
443
+ self.btn_reset.clicked.connect(self._reset_defaults)
444
+ self.btn_close.clicked.connect(self.close)
445
+ self.btn_clipstats.clicked.connect(self._do_clip_stats)
446
+
447
+ # Debounced clip stats timer
448
+ self._clip_timer = QTimer(self)
449
+ self._clip_timer.setSingleShot(True)
450
+ self._clip_timer.setInterval(500)
451
+ self._clip_timer.timeout.connect(self._do_clip_stats)
452
+
453
+ # Initialize UI state
454
+ _suggest_hdr_knee_from_target()
455
+ self.sld_luma_blend.valueChanged.connect(
456
+ lambda v: self.lbl_luma_blend.setText(f"{v/100:.2f}")
457
+ )
458
+
459
+ # Luma-only: one unified handler for all dependent UI state
460
+ def _on_luma_only_toggled(on: bool):
461
+ # enable luma mode dropdown only when luma-only is on
462
+ self.cmb_luma.setEnabled(on)
463
+
464
+ # linked channels doesn't make sense in luma-only mode
465
+ self.chk_linked.setEnabled(not on)
466
+
467
+ # luma blend row only meaningful when luma-only is enabled
468
+ self.luma_blend_row.setEnabled(on)
157
469
 
470
+ # mode-affecting => refresh clip stats
471
+ self._schedule_clip_stats()
472
+
473
+ self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
474
+ _on_luma_only_toggled(self.chk_luma_only.isChecked())
475
+
476
+
477
+ # Initial preview + clip stats
158
478
  self._populate_initial_preview()
159
479
 
480
+
160
481
  # ----- helpers -----
482
+ def _show_busy(self, title: str, text: str):
483
+ # title kept for signature compatibility; not shown
484
+ try:
485
+ self.lbl_busy.setText(text or self.tr("Processing…"))
486
+ self.busy_row.setVisible(True)
487
+ # make sure UI repaints before thread work starts
488
+ QApplication.processEvents()
489
+ except Exception:
490
+ pass
491
+
492
+ def _hide_busy(self):
493
+ try:
494
+ if getattr(self, "busy_row", None) is not None:
495
+ self.busy_row.setVisible(False)
496
+ except Exception:
497
+ pass
498
+
499
+
500
+ def _set_controls_enabled(self, enabled: bool):
501
+ try:
502
+ self.btn_preview.setEnabled(enabled)
503
+ self.btn_apply.setEnabled(enabled)
504
+ if getattr(self, "btn_reset", None) is not None:
505
+ self.btn_reset.setEnabled(enabled) # <-- NEW
506
+ if getattr(self, "btn_clipstats", None) is not None:
507
+ self.btn_clipstats.setEnabled(enabled)
508
+ except Exception:
509
+ pass
510
+
511
+ def _reset_defaults(self):
512
+ """Reset all controls back to factory defaults."""
513
+ if getattr(self, "_job_running", False):
514
+ return
515
+
516
+ # Defaults (must match your __init__ setValue/setChecked calls)
517
+ DEFAULT_TARGET = 0.25
518
+ DEFAULT_LINKED = False
519
+ DEFAULT_NORMALIZE = False
520
+ DEFAULT_BP_SLIDER = 500 # 5.00
521
+ DEFAULT_NO_BLACK_CLIP = False
522
+
523
+ DEFAULT_HDR_ON = False
524
+ DEFAULT_HDR_AMT = 15 # 0.15
525
+ DEFAULT_HDR_KNEE = 75 # 0.75
526
+
527
+ DEFAULT_LUMA_ONLY = False
528
+ DEFAULT_LUMA_MODE = "rec709"
529
+ DEFAULT_LUMA_BLEND = 60 # 0.60
530
+
531
+ DEFAULT_CURVES_ON = False
532
+ DEFAULT_CURVES_STRENGTH = 20 # 0.20
533
+
534
+ # Avoid cascading signal storms while we set everything
535
+ widgets = [
536
+ self.spin_target,
537
+ self.chk_linked,
538
+ self.chk_normalize,
539
+ self.sld_bp,
540
+ self.chk_no_black_clip,
541
+ self.chk_hdr,
542
+ self.sld_hdr_amt,
543
+ self.sld_hdr_knee,
544
+ self.chk_luma_only,
545
+ self.cmb_luma,
546
+ self.sld_luma_blend,
547
+ self.chk_curves,
548
+ self.sld_curves,
549
+ ]
550
+
551
+ old_blocks = []
552
+ for w in widgets:
553
+ try:
554
+ old_blocks.append((w, w.blockSignals(True)))
555
+ except Exception:
556
+ pass
557
+
558
+ try:
559
+ # Reset “user locked” HDR knee behavior
560
+ self._hdr_knee_user_locked = False
561
+
562
+ # Core controls
563
+ self.spin_target.setValue(DEFAULT_TARGET)
564
+ self.chk_linked.setChecked(DEFAULT_LINKED)
565
+ self.chk_normalize.setChecked(DEFAULT_NORMALIZE)
566
+
567
+ # Black point
568
+ self.chk_no_black_clip.setChecked(DEFAULT_NO_BLACK_CLIP)
569
+ self.sld_bp.setValue(DEFAULT_BP_SLIDER)
570
+ self.lbl_bp.setText(f"{DEFAULT_BP_SLIDER/100:.2f}")
571
+
572
+ # HDR
573
+ self.chk_hdr.setChecked(DEFAULT_HDR_ON)
574
+ self.sld_hdr_amt.setValue(DEFAULT_HDR_AMT)
575
+ self.lbl_hdr_amt.setText(f"{DEFAULT_HDR_AMT/100:.2f}")
576
+ self.sld_hdr_knee.setValue(DEFAULT_HDR_KNEE)
577
+ self.lbl_hdr_knee.setText(f"{DEFAULT_HDR_KNEE/100:.2f}")
578
+
579
+ # Luma-only + mode + blend
580
+ self.chk_luma_only.setChecked(DEFAULT_LUMA_ONLY)
581
+ if DEFAULT_LUMA_MODE:
582
+ self.cmb_luma.setCurrentText(DEFAULT_LUMA_MODE)
583
+ self.sld_luma_blend.setValue(DEFAULT_LUMA_BLEND)
584
+ self.lbl_luma_blend.setText(f"{DEFAULT_LUMA_BLEND/100:.2f}")
585
+
586
+ # Curves
587
+ self.chk_curves.setChecked(DEFAULT_CURVES_ON)
588
+ self.sld_curves.setValue(DEFAULT_CURVES_STRENGTH)
589
+ self.lbl_curves_val.setText(f"{DEFAULT_CURVES_STRENGTH/100:.2f}")
590
+
591
+ finally:
592
+ # Restore signal states
593
+ for w, _prev in old_blocks:
594
+ try:
595
+ w.blockSignals(False)
596
+ except Exception:
597
+ pass
598
+
599
+ # Re-apply dependent enable/disable states exactly like normal interactions
600
+ try:
601
+ # no-black-clip disables BP row
602
+ self.row_bp.setEnabled(not self.chk_no_black_clip.isChecked())
603
+ except Exception:
604
+ pass
605
+
606
+ try:
607
+ # HDR enables HDR row
608
+ self.hdr_row.setEnabled(self.chk_hdr.isChecked())
609
+ except Exception:
610
+ pass
611
+
612
+ try:
613
+ # Curves enables curves row
614
+ self.curves_row.setEnabled(self.chk_curves.isChecked())
615
+ except Exception:
616
+ pass
617
+
618
+ try:
619
+ # Luma-only enables dropdown + blend row, disables linked
620
+ luma_on = self.chk_luma_only.isChecked()
621
+ self.cmb_luma.setEnabled(luma_on)
622
+ self.luma_blend_row.setEnabled(luma_on)
623
+ self.chk_linked.setEnabled(not luma_on)
624
+ except Exception:
625
+ pass
626
+
627
+ # Auto-suggest HDR knee from target (since we cleared lock)
628
+ try:
629
+ t = float(self.spin_target.value())
630
+ knee = float(np.clip(t + 0.10, 0.10, 0.95))
631
+ self.sld_hdr_knee.setValue(int(round(knee * 100)))
632
+ self.lbl_hdr_knee.setText(f"{knee:.2f}")
633
+ except Exception:
634
+ pass
635
+
636
+ # Refresh baseline preview + clip stats
637
+ self._populate_initial_preview()
638
+
639
+
640
+ def _clip_mode_label(self, imgf: np.ndarray) -> str:
641
+ # Mono image
642
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
643
+ return self.tr("Mono")
644
+
645
+ # RGB image
646
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
647
+ if luma_only:
648
+ return self.tr("Luma-only (L ≤ bp)")
649
+
650
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
651
+ if linked:
652
+ return self.tr("Linked (L ≤ bp)")
653
+
654
+ return self.tr("Unlinked (any channel ≤ bp)")
655
+
656
+
657
+ def _do_clip_stats(self):
658
+ imgf = self._get_source_float()
659
+ if imgf is None or imgf.size == 0:
660
+ self.lbl_clipstats.setText(self.tr("No image loaded."))
661
+ return
662
+
663
+ sig = float(self.sld_bp.value()) / 100.0
664
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
665
+
666
+ # Modes that affect how we count / threshold
667
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
668
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
669
+
670
+ # Outputs we’ll fill
671
+ bp = None # float threshold (mono / L-based modes)
672
+ bp3 = None # per-channel thresholds (unlinked RGB)
673
+ clipped = None # [H,W] bool
674
+
675
+ # --- Compute blackpoint threshold(s) exactly like stretch.py ---
676
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
677
+ mono = imgf.squeeze().astype(np.float32, copy=False)
678
+ if no_black_clip:
679
+ bp = float(mono.min())
680
+ else:
681
+ bp, _ = _compute_blackpoint_sigma(mono, sig)
682
+
683
+ clipped = (mono <= bp)
684
+
685
+ else:
686
+ rgb = imgf.astype(np.float32, copy=False)
687
+
688
+ if luma_only or linked:
689
+ # One threshold for the pixel: use luminance proxy
690
+ L = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
691
+ if no_black_clip:
692
+ bp = float(L.min())
693
+ else:
694
+ bp, _ = _compute_blackpoint_sigma(L, sig)
695
+
696
+ clipped = (L <= bp)
697
+
698
+ else:
699
+ # Unlinked: per-channel thresholds
700
+ if no_black_clip:
701
+ bp3 = np.array(
702
+ [float(rgb[..., 0].min()),
703
+ float(rgb[..., 1].min()),
704
+ float(rgb[..., 2].min())],
705
+ dtype=np.float32
706
+ )
707
+ else:
708
+ bp3 = _compute_blackpoint_sigma_per_channel(rgb, sig).astype(np.float32, copy=False)
709
+
710
+ # Pixel considered clipped if ANY channel would clip
711
+ clipped = np.any(rgb <= bp3.reshape((1, 1, 3)), axis=2)
712
+
713
+ # --- Count pixels (NOT rgb elements) ---
714
+ clipped_count = int(np.count_nonzero(clipped))
715
+ total = int(clipped.size)
716
+ pct = 100.0 * clipped_count / max(1, total)
717
+
718
+ # --- Optional masked-area stats ---
719
+ masked_note = ""
720
+ m = self._active_mask_array()
721
+ if m is not None:
722
+ affected = (m > 0.01)
723
+ aff_total = int(np.count_nonzero(affected))
724
+ aff_clip = int(np.count_nonzero(clipped & affected))
725
+ aff_pct = 100.0 * aff_clip / max(1, aff_total)
726
+ masked_note = self.tr(f" | masked area: {aff_clip:,}/{aff_total:,} ({aff_pct:.4f}%)")
727
+
728
+ mode_lbl = self._clip_mode_label(imgf)
729
+
730
+ # --- No-black-clip message (must be mode-aware) ---
731
+ if no_black_clip:
732
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
733
+ bp_text = self.tr(f"min={float(bp):.6f}")
734
+ else:
735
+ if luma_only or linked:
736
+ bp_text = self.tr(f"L min={float(bp):.6f}")
737
+ else:
738
+ # bp3 exists here
739
+ bp_text = self.tr(
740
+ f"R min={float(bp3[0]):.6f}, G min={float(bp3[1]):.6f}, B min={float(bp3[2]):.6f}"
741
+ )
742
+
743
+ self.lbl_clipstats.setText(
744
+ self.tr(f"Black clipping disabled ({mode_lbl}). Threshold={bp_text}: "
745
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
746
+ )
747
+ return
748
+
749
+ # --- Normal message: show correct threshold(s) ---
750
+ if (imgf.ndim == 3 and imgf.shape[2] == 3) and not (luma_only or linked):
751
+ # Unlinked RGB: show per-channel thresholds
752
+ bp_disp = self.tr(
753
+ f"R={float(bp3[0]):.6f}, G={float(bp3[1]):.6f}, B={float(bp3[2]):.6f}"
754
+ )
755
+ else:
756
+ # Mono or L-based: single threshold
757
+ bp_disp = self.tr(f"{float(bp):.6f}")
758
+
759
+ self.lbl_clipstats.setText(
760
+ self.tr(f"Black clip ({mode_lbl}) @ {bp_disp}: "
761
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
762
+ )
763
+
764
+
765
+ def _start_stretch_job(self, mode: str):
766
+ """
767
+ mode: 'preview' or 'apply'
768
+ """
769
+ if getattr(self, "_job_running", False):
770
+ return
771
+
772
+ self._job_running = True
773
+ self._job_mode = mode
774
+
775
+ self._set_controls_enabled(False)
776
+ self._show_busy("Statistical Stretch", "Processing preview…" if mode == "preview" else "Applying stretch…")
777
+
778
+
779
+ self._thread = QThread(self._main)
780
+ self._worker = _StretchWorker(self)
781
+ self._worker.moveToThread(self._thread)
782
+
783
+ self._thread.started.connect(self._worker.run)
784
+ self._worker.finished.connect(self._on_stretch_done)
785
+ self._worker.finished.connect(self._thread.quit)
786
+ self._worker.finished.connect(self._worker.deleteLater)
787
+ self._thread.finished.connect(self._thread.deleteLater)
788
+
789
+ self._thread.start()
790
+
791
+
161
792
  def _get_source_float(self) -> np.ndarray:
162
793
  """
163
794
  Return a float32 array scaled into ~[0..1] for stretching.
@@ -211,12 +842,43 @@ class StatisticalStretchDialog(QDialog):
211
842
  self._update_preview_scaled()
212
843
 
213
844
  def _zoom_by(self, factor: float):
214
- """Incremental zoom around the current center; exits Fit mode."""
845
+ vp = self.preview_scroll.viewport()
846
+ center = vp.rect().center()
847
+ self._zoom_at(factor, center)
848
+
849
+ def _zoom_at(self, factor: float, vp_pos):
850
+ """Zoom keeping the image point under vp_pos (viewport coords) stationary."""
851
+ if self._preview_qimg is None:
852
+ return
853
+
854
+ old_scale = float(self._preview_scale)
855
+
856
+ # Content coords (in scaled-image pixels) currently under the mouse
857
+ hsb = self.preview_scroll.horizontalScrollBar()
858
+ vsb = self.preview_scroll.verticalScrollBar()
859
+ cx = hsb.value() + int(vp_pos.x())
860
+ cy = vsb.value() + int(vp_pos.y())
861
+
862
+ # Convert to image-space coords (unscaled)
863
+ ix = cx / old_scale
864
+ iy = cy / old_scale
865
+
866
+ # Apply zoom
215
867
  self._fit_mode = False
216
- new_scale = self._preview_scale * float(factor)
217
- self._preview_scale = max(0.05, min(new_scale, 8.0))
868
+ new_scale = max(0.05, min(old_scale * float(factor), 8.0))
869
+ self._preview_scale = new_scale
870
+
871
+ # Rebuild pixmap/label size
218
872
  self._update_preview_scaled()
219
873
 
874
+ # New content coords for same image-space point
875
+ ncx = int(ix * new_scale)
876
+ ncy = int(iy * new_scale)
877
+
878
+ # Set scrollbars so that point stays under the mouse
879
+ hsb.setValue(ncx - int(vp_pos.x()))
880
+ vsb.setValue(ncy - int(vp_pos.y()))
881
+
220
882
 
221
883
  # --- MASK helpers ----------------------------------------------------
222
884
  def _active_mask_array(self) -> np.ndarray | None:
@@ -239,10 +901,13 @@ class StatisticalStretchDialog(QDialog):
239
901
  elif m.ndim == 3: # RGB/whatever → luminance
240
902
  m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
241
903
 
242
- m = m.astype(np.float32, copy=False)
243
- # normalize if integer / out-of-range
244
- if m.dtype.kind in "ui":
245
- m /= float(np.iinfo(m.dtype).max)
904
+ orig = m
905
+ # normalize if integer
906
+ if orig.dtype.kind in "ui":
907
+ m = orig.astype(np.float32) / float(np.iinfo(orig.dtype).max)
908
+ else:
909
+ m = orig.astype(np.float32, copy=False)
910
+
246
911
  m = np.clip(m, 0.0, 1.0)
247
912
 
248
913
  th, tw = self.doc.image.shape[:2]
@@ -273,6 +938,14 @@ class StatisticalStretchDialog(QDialog):
273
938
  imgf = self._get_source_float()
274
939
  if imgf is None:
275
940
  return None
941
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
942
+ hdr_on = bool(self.chk_hdr.isChecked())
943
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
944
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
945
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
946
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
947
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
948
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
276
949
 
277
950
  target = float(self.spin_target.value())
278
951
  linked = bool(self.chk_linked.isChecked())
@@ -287,6 +960,11 @@ class StatisticalStretchDialog(QDialog):
287
960
  normalize=normalize,
288
961
  apply_curves=apply_curves,
289
962
  curves_boost=curves_boost,
963
+ blackpoint_sigma=blackpoint_sigma,
964
+ no_black_clip=no_black_clip,
965
+ hdr_compress=hdr_on,
966
+ hdr_amount=hdr_amount,
967
+ hdr_knee=hdr_knee,
290
968
  )
291
969
  else:
292
970
  out = stretch_color_image(
@@ -296,6 +974,14 @@ class StatisticalStretchDialog(QDialog):
296
974
  normalize=normalize,
297
975
  apply_curves=apply_curves,
298
976
  curves_boost=curves_boost,
977
+ blackpoint_sigma=blackpoint_sigma,
978
+ no_black_clip=no_black_clip,
979
+ hdr_compress=hdr_on,
980
+ hdr_amount=hdr_amount,
981
+ hdr_knee=hdr_knee,
982
+ luma_only=luma_only,
983
+ luma_mode=luma_mode,
984
+ luma_blend=luma_blend, # <-- NEW
299
985
  )
300
986
 
301
987
  # ✅ If a mask is active, blend stretched result with original
@@ -349,6 +1035,10 @@ class StatisticalStretchDialog(QDialog):
349
1035
  return
350
1036
  self.doc = doc
351
1037
  self._populate_initial_preview()
1038
+ try:
1039
+ self._schedule_clip_stats()
1040
+ except Exception:
1041
+ pass
352
1042
 
353
1043
  # ----- slots -----
354
1044
  def _populate_initial_preview(self):
@@ -356,56 +1046,68 @@ class StatisticalStretchDialog(QDialog):
356
1046
  src = self._get_source_float()
357
1047
  if src is not None:
358
1048
  self._set_preview_pixmap(np.clip(src, 0, 1))
1049
+ try:
1050
+ self.lbl_clipstats.setText(self.tr("Calculating clip stats…"))
1051
+ except Exception:
1052
+ pass
1053
+ try:
1054
+ self._schedule_clip_stats()
1055
+ except Exception:
1056
+ pass
1057
+
359
1058
 
360
1059
  def _do_preview(self):
361
- try:
362
- out = self._run_stretch()
363
- if out is None:
364
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
365
- return
366
- self._set_preview_pixmap(out)
367
- except Exception as e:
368
- QMessageBox.warning(self, "Preview failed", str(e))
1060
+ self._start_stretch_job("preview")
1061
+
369
1062
 
370
1063
  def _do_apply(self):
371
- try:
372
- out = self._run_stretch()
373
- if out is None:
374
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
375
- return
1064
+ self._start_stretch_job("apply")
376
1065
 
377
- # Preserve mono vs color shape
378
- if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
379
- out = out[..., 0]
380
-
381
- # --- Gather current UI state ------------------------------------
382
- target = float(self.spin_target.value())
383
- linked = bool(self.chk_linked.isChecked())
384
- normalize = bool(self.chk_normalize.isChecked())
385
- apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
386
- curves_boost = 0.0
387
- if getattr(self, "sld_curves", None) is not None:
388
- curves_boost = float(self.sld_curves.value()) / 100.0
389
-
390
- # Build human-readable step name
391
- parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
392
- if normalize:
393
- parts.append("norm")
394
- if apply_curves:
395
- parts.append(f"curves={curves_boost:.2f}")
396
- if self._active_mask_array() is not None:
397
- parts.append("masked")
398
- step_name = f"Statistical Stretch ({', '.join(parts)})"
399
-
400
- # Apply to document
401
- self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
402
-
403
- # Turn off display stretch on the active view, if any
404
- mw = self.parent()
405
- if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
406
- view = mw.mdi.activeSubWindow().widget()
407
- if getattr(view, "autostretch_enabled", False):
408
- view.set_autostretch(False)
1066
+ def _apply_out_to_doc(self, out: np.ndarray):
1067
+ # Preserve mono vs color shape
1068
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
1069
+ out = out[..., 0]
1070
+
1071
+ # --- Gather current UI state ------------------------------------
1072
+ target = float(self.spin_target.value())
1073
+ linked = bool(self.chk_linked.isChecked())
1074
+ normalize = bool(self.chk_normalize.isChecked())
1075
+ apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
1076
+ curves_boost = float(self.sld_curves.value()) / 100.0 if getattr(self, "sld_curves", None) is not None else 0.0
1077
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
1078
+ hdr_on = bool(self.chk_hdr.isChecked())
1079
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
1080
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
1081
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
1082
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
1083
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
1084
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
1085
+
1086
+ parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
1087
+ if normalize:
1088
+ parts.append("norm")
1089
+ if apply_curves:
1090
+ parts.append(f"curves={curves_boost:.2f}")
1091
+ if self._active_mask_array() is not None:
1092
+ parts.append("masked")
1093
+ parts.append(f"bpσ={blackpoint_sigma:.2f}")
1094
+ if hdr_on and hdr_amount > 0:
1095
+ parts.append(f"hdr={hdr_amount:.2f}@{hdr_knee:.2f}")
1096
+ if luma_only:
1097
+ parts.append(f"luma={luma_mode}")
1098
+ parts.append(f"blend={luma_blend:.2f}")
1099
+ if no_black_clip:
1100
+ parts.append("no_black_clip")
1101
+
1102
+ step_name = f"Statistical Stretch ({', '.join(parts)})"
1103
+ self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
1104
+
1105
+ # Turn off display stretch on the active view, if any
1106
+ mw = self.parent()
1107
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
1108
+ view = mw.mdi.activeSubWindow().widget()
1109
+ if getattr(view, "autostretch_enabled", False):
1110
+ view.set_autostretch(False)
409
1111
 
410
1112
  # Existing logging, now using the same values as above
411
1113
  if hasattr(mw, "_log"):
@@ -414,6 +1116,10 @@ class StatisticalStretchDialog(QDialog):
414
1116
  mw._log(
415
1117
  "Applied Statistical Stretch "
416
1118
  f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
1119
+ f"bp_sigma={blackpoint_sigma:.2f}, "
1120
+ f"hdr={'ON' if hdr_on else 'OFF'}"
1121
+ f"{', amt='+str(round(hdr_amount,2))+' knee='+str(round(hdr_knee,2)) if hdr_on else ''}, "
1122
+ f"luma={'ON' if luma_only else 'OFF'}{', mode='+luma_mode if luma_only else ''}, "
417
1123
  f"curves={'ON' if curves_on else 'OFF'}"
418
1124
  f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
419
1125
  f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
@@ -427,6 +1133,14 @@ class StatisticalStretchDialog(QDialog):
427
1133
  "normalize": normalize,
428
1134
  "apply_curves": apply_curves,
429
1135
  "curves_boost": curves_boost,
1136
+ "blackpoint_sigma": blackpoint_sigma,
1137
+ "no_black_clip": no_black_clip,
1138
+ "hdr_compress": hdr_on,
1139
+ "hdr_amount": hdr_amount,
1140
+ "hdr_knee": hdr_knee,
1141
+ "luma_only": luma_only,
1142
+ "luma_mode": luma_mode,
1143
+ "luma_blend": luma_blend,
430
1144
  }
431
1145
 
432
1146
  # ✅ Remember this as the last headless-style command
@@ -454,13 +1168,9 @@ class StatisticalStretchDialog(QDialog):
454
1168
  # optional debug
455
1169
  print("Statistical Stretch: replay recording suppressed for this apply()")
456
1170
 
457
- self.close()
458
- return
1171
+ self.close()
459
1172
 
460
1173
 
461
- except Exception as e:
462
- QMessageBox.critical(self, "Apply failed", str(e))
463
-
464
1174
  def _refresh_document_from_active(self):
465
1175
  """
466
1176
  Refresh the dialog's document reference to the currently active document.
@@ -478,13 +1188,57 @@ class StatisticalStretchDialog(QDialog):
478
1188
  except Exception:
479
1189
  pass
480
1190
 
1191
+ @pyqtSlot(object, str)
1192
+ def _on_stretch_done(self, out, err: str):
1193
+ # dialog might be closing; guard
1194
+ if sip.isdeleted(self):
1195
+ return
1196
+
1197
+ self._hide_busy()
1198
+ self._set_controls_enabled(True)
1199
+ self._job_running = False
1200
+
1201
+ if err:
1202
+ QMessageBox.warning(self, "Stretch failed", err)
1203
+ return
1204
+
1205
+ if out is None:
1206
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
1207
+ return
1208
+
1209
+ if getattr(self, "_job_mode", "") == "preview":
1210
+ self._set_preview_pixmap(out)
1211
+ return
1212
+
1213
+ # apply mode: reuse your existing apply logic, but using `out` we already computed
1214
+ self._apply_out_to_doc(out)
1215
+
1216
+ if getattr(self, "_pending_close", False):
1217
+ self._pending_close = False
1218
+ self.close()
1219
+
481
1220
  def closeEvent(self, ev):
482
- # disconnect the “follow active document” hook
1221
+ # If a job is running, DO NOT close (WA_DeleteOnClose would delete the QThread)
1222
+ if getattr(self, "_job_running", False):
1223
+ self._pending_close = True
1224
+ try:
1225
+ self._hide_busy()
1226
+ except Exception:
1227
+ pass
1228
+ try:
1229
+ self.hide()
1230
+ except Exception:
1231
+ pass
1232
+ ev.ignore()
1233
+ return
1234
+
1235
+ # disconnect follow behavior
483
1236
  try:
484
1237
  if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
485
1238
  self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
486
1239
  except Exception:
487
1240
  pass
1241
+
488
1242
  super().closeEvent(ev)
489
1243
 
490
1244
 
@@ -509,30 +1263,24 @@ class StatisticalStretchDialog(QDialog):
509
1263
 
510
1264
  def eventFilter(self, obj, ev):
511
1265
  # Ctrl+wheel zoom
512
- if ev.type() == QEvent.Type.Wheel and (obj is self.preview_scroll.viewport() or obj is self.preview_label):
1266
+ if ev.type() == QEvent.Type.Wheel and obj is self.preview_scroll.viewport():
513
1267
  if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
514
- factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
515
- self._fit_mode = False # ← ensure we exit Fit mode
516
- self._preview_scale = max(0.05, min(self._preview_scale * factor, 8.0))
517
- self._update_preview_scaled()
1268
+ factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
1269
+ self._zoom_at(factor, ev.position())
518
1270
  return True
519
1271
  return False
520
1272
 
521
1273
  # Click+drag pan (left or middle mouse)
522
- if obj is self.preview_scroll.viewport() or obj is self.preview_label:
1274
+ if obj is self.preview_scroll.viewport():
523
1275
  if ev.type() == QEvent.Type.MouseButtonPress:
524
- if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
1276
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
525
1277
  self._panning = True
526
- self._pan_last = ev.position().toPoint()
527
- # show a "grab" cursor where the drag begins
528
- if obj is self.preview_label:
529
- self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
530
- else:
531
- self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
1278
+ self._pan_last = ev.globalPosition().toPoint()
1279
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
532
1280
  return True
533
1281
 
534
1282
  elif ev.type() == QEvent.Type.MouseMove and self._panning:
535
- pos = ev.position().toPoint()
1283
+ pos = ev.globalPosition().toPoint()
536
1284
  delta = pos - self._pan_last
537
1285
  self._pan_last = pos
538
1286
 
@@ -543,12 +1291,11 @@ class StatisticalStretchDialog(QDialog):
543
1291
  return True
544
1292
 
545
1293
  elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
546
- self._panning = False
547
- self._pan_last = None
548
- # restore cursor
549
- self.preview_label.unsetCursor()
550
- self.preview_scroll.viewport().unsetCursor()
551
- return True
1294
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
1295
+ self._panning = False
1296
+ self._pan_last = None
1297
+ self.preview_scroll.viewport().unsetCursor()
1298
+ return True
552
1299
 
553
1300
  return super().eventFilter(obj, ev)
554
1301