setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.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,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)
@@ -108,48 +320,475 @@ class StatisticalStretchDialog(QDialog):
108
320
  self.btn_preview = QPushButton(self.tr("Preview"))
109
321
  self.btn_apply = QPushButton(self.tr("Apply"))
110
322
  self.btn_close = QPushButton(self.tr("Close"))
111
-
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 ---
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
+ # ------------------------------------------------------------------
117
355
  form = QFormLayout()
118
356
  form.addRow(self.tr("Target median:"), self.spin_target)
119
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)
120
364
  form.addRow("", self.chk_normalize)
121
365
  form.addRow("", self.chk_curves)
122
366
  form.addRow("", self.curves_row)
123
367
 
124
368
  left = QVBoxLayout()
125
369
  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)
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)
131
382
  left.addStretch(1)
132
383
 
384
+ right = QVBoxLayout()
385
+ right.addLayout(zoom_row)
386
+ right.addWidget(self.preview_scroll, 1)
387
+
133
388
  main = QHBoxLayout(self)
134
389
  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
390
  main.addLayout(right, 1)
141
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
142
435
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
143
436
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
144
437
  self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
145
438
  self.btn_zoom_fit.clicked.connect(self._fit_preview)
146
439
 
147
- self.preview_scroll.viewport().installEventFilter(self)
148
- 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)
149
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
150
478
  self._populate_initial_preview()
151
479
 
480
+
152
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
+
153
792
  def _get_source_float(self) -> np.ndarray:
154
793
  """
155
794
  Return a float32 array scaled into ~[0..1] for stretching.
@@ -203,12 +842,43 @@ class StatisticalStretchDialog(QDialog):
203
842
  self._update_preview_scaled()
204
843
 
205
844
  def _zoom_by(self, factor: float):
206
- """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
207
867
  self._fit_mode = False
208
- new_scale = self._preview_scale * float(factor)
209
- 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
210
872
  self._update_preview_scaled()
211
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
+
212
882
 
213
883
  # --- MASK helpers ----------------------------------------------------
214
884
  def _active_mask_array(self) -> np.ndarray | None:
@@ -231,10 +901,13 @@ class StatisticalStretchDialog(QDialog):
231
901
  elif m.ndim == 3: # RGB/whatever → luminance
232
902
  m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
233
903
 
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)
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
+
238
911
  m = np.clip(m, 0.0, 1.0)
239
912
 
240
913
  th, tw = self.doc.image.shape[:2]
@@ -265,6 +938,14 @@ class StatisticalStretchDialog(QDialog):
265
938
  imgf = self._get_source_float()
266
939
  if imgf is None:
267
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
268
949
 
269
950
  target = float(self.spin_target.value())
270
951
  linked = bool(self.chk_linked.isChecked())
@@ -279,6 +960,11 @@ class StatisticalStretchDialog(QDialog):
279
960
  normalize=normalize,
280
961
  apply_curves=apply_curves,
281
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,
282
968
  )
283
969
  else:
284
970
  out = stretch_color_image(
@@ -288,6 +974,14 @@ class StatisticalStretchDialog(QDialog):
288
974
  normalize=normalize,
289
975
  apply_curves=apply_curves,
290
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
291
985
  )
292
986
 
293
987
  # ✅ If a mask is active, blend stretched result with original
@@ -341,6 +1035,10 @@ class StatisticalStretchDialog(QDialog):
341
1035
  return
342
1036
  self.doc = doc
343
1037
  self._populate_initial_preview()
1038
+ try:
1039
+ self._schedule_clip_stats()
1040
+ except Exception:
1041
+ pass
344
1042
 
345
1043
  # ----- slots -----
346
1044
  def _populate_initial_preview(self):
@@ -348,56 +1046,68 @@ class StatisticalStretchDialog(QDialog):
348
1046
  src = self._get_source_float()
349
1047
  if src is not None:
350
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
+
351
1058
 
352
1059
  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))
1060
+ self._start_stretch_job("preview")
1061
+
361
1062
 
362
1063
  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
1064
+ self._start_stretch_job("apply")
368
1065
 
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)
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)
401
1111
 
402
1112
  # Existing logging, now using the same values as above
403
1113
  if hasattr(mw, "_log"):
@@ -406,6 +1116,10 @@ class StatisticalStretchDialog(QDialog):
406
1116
  mw._log(
407
1117
  "Applied Statistical Stretch "
408
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 ''}, "
409
1123
  f"curves={'ON' if curves_on else 'OFF'}"
410
1124
  f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
411
1125
  f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
@@ -419,6 +1133,14 @@ class StatisticalStretchDialog(QDialog):
419
1133
  "normalize": normalize,
420
1134
  "apply_curves": apply_curves,
421
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,
422
1144
  }
423
1145
 
424
1146
  # ✅ Remember this as the last headless-style command
@@ -446,13 +1168,8 @@ class StatisticalStretchDialog(QDialog):
446
1168
  # optional debug
447
1169
  print("Statistical Stretch: replay recording suppressed for this apply()")
448
1170
 
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
-
1171
+ self.close()
453
1172
 
454
- except Exception as e:
455
- QMessageBox.critical(self, "Apply failed", str(e))
456
1173
 
457
1174
  def _refresh_document_from_active(self):
458
1175
  """
@@ -471,6 +1188,59 @@ class StatisticalStretchDialog(QDialog):
471
1188
  except Exception:
472
1189
  pass
473
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
+
1220
+ def closeEvent(self, ev):
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
1236
+ try:
1237
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
1238
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1239
+ except Exception:
1240
+ pass
1241
+
1242
+ super().closeEvent(ev)
1243
+
474
1244
 
475
1245
  def _update_preview_scaled(self):
476
1246
  if self._preview_qimg is None:
@@ -493,30 +1263,24 @@ class StatisticalStretchDialog(QDialog):
493
1263
 
494
1264
  def eventFilter(self, obj, ev):
495
1265
  # Ctrl+wheel zoom
496
- 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():
497
1267
  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()
1268
+ factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
1269
+ self._zoom_at(factor, ev.position())
502
1270
  return True
503
1271
  return False
504
1272
 
505
1273
  # Click+drag pan (left or middle mouse)
506
- if obj is self.preview_scroll.viewport() or obj is self.preview_label:
1274
+ if obj is self.preview_scroll.viewport():
507
1275
  if ev.type() == QEvent.Type.MouseButtonPress:
508
- if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
1276
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
509
1277
  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)
1278
+ self._pan_last = ev.globalPosition().toPoint()
1279
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
516
1280
  return True
517
1281
 
518
1282
  elif ev.type() == QEvent.Type.MouseMove and self._panning:
519
- pos = ev.position().toPoint()
1283
+ pos = ev.globalPosition().toPoint()
520
1284
  delta = pos - self._pan_last
521
1285
  self._pan_last = pos
522
1286
 
@@ -527,12 +1291,11 @@ class StatisticalStretchDialog(QDialog):
527
1291
  return True
528
1292
 
529
1293
  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
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
536
1299
 
537
1300
  return super().eventFilter(obj, ev)
538
1301