setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -3,16 +3,45 @@ from __future__ import annotations
3
3
  from PyQt6.QtCore import Qt, QSize, QEvent
4
4
  from PyQt6.QtWidgets import (
5
5
  QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QDoubleSpinBox,
6
- QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton
6
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton, QComboBox
7
7
  )
8
8
  from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
9
9
  import numpy as np
10
10
  from PyQt6 import sip
11
-
11
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot, Qt, QSize, QEvent, QTimer
12
+ from PyQt6.QtWidgets import QProgressDialog, QApplication
12
13
  from .doc_manager import ImageDocument
13
14
  # use your existing stretch code
14
- from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
15
+ from setiastro.saspro.imageops.stretch import (
16
+ stretch_mono_image,
17
+ stretch_color_image,
18
+ _compute_blackpoint_sigma,
19
+ _compute_blackpoint_sigma_per_channel,
20
+ )
21
+
15
22
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
23
+ from setiastro.saspro.luminancerecombine import LUMA_PROFILES
24
+
25
+ class _StretchWorker(QObject):
26
+ finished = pyqtSignal(object, str) # (out_array_or_None, error_message_or_empty)
27
+
28
+ def __init__(self, dialog_ref):
29
+ super().__init__()
30
+ self._dlg = dialog_ref
31
+
32
+ @pyqtSlot()
33
+ def run(self):
34
+ try:
35
+ # dialog might be closing; guard
36
+ if self._dlg is None or sip.isdeleted(self._dlg):
37
+ self.finished.emit(None, "Dialog was closed.")
38
+ return
39
+
40
+ out = self._dlg._run_stretch()
41
+ self.finished.emit(out, "")
42
+ except Exception as e:
43
+ self.finished.emit(None, str(e))
44
+
16
45
 
17
46
  class StatisticalStretchDialog(QDialog):
18
47
  """
@@ -22,83 +51,240 @@ 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
+ # --- Curves boost ---
60
247
  self.chk_curves = QCheckBox(self.tr("Curves boost"))
61
248
  self.chk_curves.setChecked(False)
62
249
 
63
250
  self.curves_row = QWidget()
64
- cr_lay = QHBoxLayout(self.curves_row); cr_lay.setContentsMargins(0,0,0,0)
251
+ cr_lay = QHBoxLayout(self.curves_row)
252
+ cr_lay.setContentsMargins(0, 0, 0, 0)
65
253
  cr_lay.setSpacing(8)
254
+
66
255
  cr_lay.addWidget(QLabel(self.tr("Strength:")))
67
256
  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}"))
257
+ self.sld_curves.setRange(0, 100)
258
+ self.sld_curves.setValue(20)
74
259
  cr_lay.addWidget(self.sld_curves, 1)
260
+
261
+ self.lbl_curves_val = QLabel(f"{self.sld_curves.value()/100:.2f}")
75
262
  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
263
 
79
- # Preview area
264
+ self.curves_row.setEnabled(False)
265
+
266
+ # ------------------------------------------------------------------
267
+ # Preview UI
268
+ # ------------------------------------------------------------------
80
269
  self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
81
270
  self.preview_label.setMinimumSize(QSize(320, 240))
82
- self.preview_label.setScaledContents(False)
271
+ self.preview_label.setScaledContents(False)
272
+ self.preview_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
273
+
83
274
  self.preview_scroll = QScrollArea()
84
- self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
275
+ self.preview_scroll.setWidgetResizable(False)
85
276
  self.preview_scroll.setWidget(self.preview_label)
86
277
  self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
87
278
  self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
279
+ self.preview_scroll.viewport().installEventFilter(self)
88
280
 
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 ---
281
+ # Zoom buttons
93
282
  zoom_row = QHBoxLayout()
94
-
95
- # Use themed tool buttons (consistent with the rest of SASpro)
96
283
  self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
97
284
  self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
98
285
  self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
99
286
  self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
100
287
 
101
-
102
288
  zoom_row.addStretch(1)
103
289
  for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
104
290
  zoom_row.addWidget(b)
@@ -109,47 +295,316 @@ class StatisticalStretchDialog(QDialog):
109
295
  self.btn_apply = QPushButton(self.tr("Apply"))
110
296
  self.btn_close = QPushButton(self.tr("Close"))
111
297
 
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 ---
298
+ self.btn_clipstats = QPushButton(self.tr("Clip stats"))
299
+ self.lbl_clipstats = QLabel("")
300
+ self.lbl_clipstats.setWordWrap(True)
301
+ self.lbl_clipstats.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
302
+ self.lbl_clipstats.setMinimumHeight(38)
303
+ self.lbl_clipstats.setFrameShape(QLabel.Shape.StyledPanel)
304
+ self.lbl_clipstats.setFrameShadow(QLabel.Shadow.Sunken)
305
+ self.lbl_clipstats.setContentsMargins(6, 4, 6, 4)
306
+
307
+ # ------------------------------------------------------------------
308
+ # Layout
309
+ # ------------------------------------------------------------------
117
310
  form = QFormLayout()
118
311
  form.addRow(self.tr("Target median:"), self.spin_target)
119
312
  form.addRow("", self.chk_linked)
313
+ form.addRow("", self.row_bp)
314
+ form.addRow("", self.chk_no_black_clip)
315
+ form.addRow("", self.chk_hdr)
316
+ form.addRow("", self.hdr_row)
317
+ form.addRow("", self.luma_row)
120
318
  form.addRow("", self.chk_normalize)
121
319
  form.addRow("", self.chk_curves)
122
320
  form.addRow("", self.curves_row)
123
321
 
124
322
  left = QVBoxLayout()
125
323
  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)
324
+
325
+ btn_row = QHBoxLayout()
326
+ btn_row.addWidget(self.btn_preview)
327
+ btn_row.addWidget(self.btn_apply)
328
+ btn_row.addWidget(self.btn_clipstats)
329
+ btn_row.addStretch(1)
330
+ left.addLayout(btn_row)
331
+
332
+ left.addWidget(self.lbl_clipstats)
131
333
  left.addStretch(1)
132
334
 
335
+ right = QVBoxLayout()
336
+ right.addLayout(zoom_row)
337
+ right.addWidget(self.preview_scroll, 1)
338
+
133
339
  main = QHBoxLayout(self)
134
340
  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
341
  main.addLayout(right, 1)
141
342
 
343
+ # ------------------------------------------------------------------
344
+ # Behavior / wiring
345
+ # ------------------------------------------------------------------
346
+
347
+ # Blackpoint slider -> label + debounced clip stats
348
+ def _on_bp_changed(v: int):
349
+ self.lbl_bp.setText(f"{v/100:.2f}")
350
+ self._schedule_clip_stats()
351
+
352
+ self.sld_bp.valueChanged.connect(_on_bp_changed)
353
+
354
+ # No-black-clip toggles blackpoint UI + triggers stats
355
+ def _on_no_black_clip_toggled(on: bool):
356
+ self.row_bp.setEnabled(not on)
357
+ self._schedule_clip_stats()
358
+
359
+ self.chk_no_black_clip.toggled.connect(_on_no_black_clip_toggled)
360
+ _on_no_black_clip_toggled(self.chk_no_black_clip.isChecked())
361
+
362
+ # Curves
363
+ self.chk_curves.toggled.connect(self.curves_row.setEnabled)
364
+ self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
365
+
366
+ # HDR enable toggles HDR row
367
+ self.chk_hdr.toggled.connect(self.hdr_row.setEnabled)
368
+ self.sld_hdr_amt.valueChanged.connect(lambda v: self.lbl_hdr_amt.setText(f"{v/100:.2f}"))
369
+ self.sld_hdr_knee.valueChanged.connect(lambda v: self.lbl_hdr_knee.setText(f"{v/100:.2f}"))
370
+ self.sld_hdr_knee.sliderPressed.connect(lambda: setattr(self, "_hdr_knee_user_locked", True))
371
+
372
+ # Auto-suggest HDR knee from target (unless user locked)
373
+ def _suggest_hdr_knee_from_target():
374
+ if getattr(self, "_hdr_knee_user_locked", False):
375
+ return
376
+ t = float(self.spin_target.value())
377
+ knee = float(np.clip(t + 0.10, 0.10, 0.95))
378
+ self.sld_hdr_knee.blockSignals(True)
379
+ self.sld_hdr_knee.setValue(int(round(knee * 100)))
380
+ self.sld_hdr_knee.blockSignals(False)
381
+ self.lbl_hdr_knee.setText(f"{knee:.2f}")
382
+
383
+ self.spin_target.valueChanged.connect(_suggest_hdr_knee_from_target)
384
+
385
+ # Luma-only: enables dropdown, disables "linked channels"
386
+ self.chk_luma_only.toggled.connect(self.cmb_luma.setEnabled)
387
+
388
+ def _on_luma_only_toggled(on: bool):
389
+ self.chk_linked.setEnabled(not on)
390
+
391
+ self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
392
+
393
+ # Zoom buttons
142
394
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
143
395
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
144
396
  self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
145
397
  self.btn_zoom_fit.clicked.connect(self._fit_preview)
146
398
 
147
- self.preview_scroll.viewport().installEventFilter(self)
148
- self.preview_label.installEventFilter(self)
399
+ # Main buttons
400
+ self.btn_preview.clicked.connect(self._do_preview)
401
+ self.btn_apply.clicked.connect(self._do_apply)
402
+ self.btn_close.clicked.connect(self.close)
403
+ self.btn_clipstats.clicked.connect(self._do_clip_stats)
404
+
405
+ # Debounced clip stats timer
406
+ self._clip_timer = QTimer(self)
407
+ self._clip_timer.setSingleShot(True)
408
+ self._clip_timer.setInterval(500)
409
+ self._clip_timer.timeout.connect(self._do_clip_stats)
410
+
411
+ # Initialize UI state
412
+ _suggest_hdr_knee_from_target()
413
+ _on_luma_only_toggled(self.chk_luma_only.isChecked())
149
414
 
415
+ # Initial preview + clip stats
150
416
  self._populate_initial_preview()
151
417
 
418
+
152
419
  # ----- helpers -----
420
+ def _show_busy(self, title: str, text: str):
421
+ # Avoid stacking dialogs
422
+ self._hide_busy()
423
+
424
+ dlg = QProgressDialog(text, None, 0, 0, self)
425
+ dlg.setWindowTitle(title)
426
+ dlg.setWindowModality(Qt.WindowModality.WindowModal) # blocks only this tool window
427
+ dlg.setMinimumDuration(0)
428
+ dlg.setValue(0)
429
+ dlg.setCancelButton(None) # no cancel button (keeps it simple)
430
+ dlg.setAutoClose(False)
431
+ dlg.setAutoReset(False)
432
+ dlg.setFixedWidth(320)
433
+ dlg.show()
434
+
435
+ # Ensure it paints before heavy work starts
436
+ QApplication.processEvents()
437
+ self._busy = dlg
438
+
439
+ def _hide_busy(self):
440
+ try:
441
+ if getattr(self, "_busy", None) is not None:
442
+ self._busy.close()
443
+ self._busy.deleteLater()
444
+ except Exception:
445
+ pass
446
+ self._busy = None
447
+
448
+ def _set_controls_enabled(self, enabled: bool):
449
+ try:
450
+ self.btn_preview.setEnabled(enabled)
451
+ self.btn_apply.setEnabled(enabled)
452
+ if getattr(self, "btn_clipstats", None) is not None:
453
+ self.btn_clipstats.setEnabled(enabled)
454
+ except Exception:
455
+ pass
456
+
457
+ def _clip_mode_label(self, imgf: np.ndarray) -> str:
458
+ # Mono image
459
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
460
+ return self.tr("Mono")
461
+
462
+ # RGB image
463
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
464
+ if luma_only:
465
+ return self.tr("Luma-only (L ≤ bp)")
466
+
467
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
468
+ if linked:
469
+ return self.tr("Linked (L ≤ bp)")
470
+
471
+ return self.tr("Unlinked (any channel ≤ bp)")
472
+
473
+
474
+ def _do_clip_stats(self):
475
+ imgf = self._get_source_float()
476
+ if imgf is None or imgf.size == 0:
477
+ self.lbl_clipstats.setText(self.tr("No image loaded."))
478
+ return
479
+
480
+ sig = float(self.sld_bp.value()) / 100.0
481
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
482
+
483
+ # Modes that affect how we count / threshold
484
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
485
+ linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
486
+
487
+ # Outputs we’ll fill
488
+ bp = None # float threshold (mono / L-based modes)
489
+ bp3 = None # per-channel thresholds (unlinked RGB)
490
+ clipped = None # [H,W] bool
491
+
492
+ # --- Compute blackpoint threshold(s) exactly like stretch.py ---
493
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
494
+ mono = imgf.squeeze().astype(np.float32, copy=False)
495
+ if no_black_clip:
496
+ bp = float(mono.min())
497
+ else:
498
+ bp, _ = _compute_blackpoint_sigma(mono, sig)
499
+
500
+ clipped = (mono <= bp)
501
+
502
+ else:
503
+ rgb = imgf.astype(np.float32, copy=False)
504
+
505
+ if luma_only or linked:
506
+ # One threshold for the pixel: use luminance proxy
507
+ L = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
508
+ if no_black_clip:
509
+ bp = float(L.min())
510
+ else:
511
+ bp, _ = _compute_blackpoint_sigma(L, sig)
512
+
513
+ clipped = (L <= bp)
514
+
515
+ else:
516
+ # Unlinked: per-channel thresholds
517
+ if no_black_clip:
518
+ bp3 = np.array(
519
+ [float(rgb[..., 0].min()),
520
+ float(rgb[..., 1].min()),
521
+ float(rgb[..., 2].min())],
522
+ dtype=np.float32
523
+ )
524
+ else:
525
+ bp3 = _compute_blackpoint_sigma_per_channel(rgb, sig).astype(np.float32, copy=False)
526
+
527
+ # Pixel considered clipped if ANY channel would clip
528
+ clipped = np.any(rgb <= bp3.reshape((1, 1, 3)), axis=2)
529
+
530
+ # --- Count pixels (NOT rgb elements) ---
531
+ clipped_count = int(np.count_nonzero(clipped))
532
+ total = int(clipped.size)
533
+ pct = 100.0 * clipped_count / max(1, total)
534
+
535
+ # --- Optional masked-area stats ---
536
+ masked_note = ""
537
+ m = self._active_mask_array()
538
+ if m is not None:
539
+ affected = (m > 0.01)
540
+ aff_total = int(np.count_nonzero(affected))
541
+ aff_clip = int(np.count_nonzero(clipped & affected))
542
+ aff_pct = 100.0 * aff_clip / max(1, aff_total)
543
+ masked_note = self.tr(f" | masked area: {aff_clip:,}/{aff_total:,} ({aff_pct:.4f}%)")
544
+
545
+ mode_lbl = self._clip_mode_label(imgf)
546
+
547
+ # --- No-black-clip message (must be mode-aware) ---
548
+ if no_black_clip:
549
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
550
+ bp_text = self.tr(f"min={float(bp):.6f}")
551
+ else:
552
+ if luma_only or linked:
553
+ bp_text = self.tr(f"L min={float(bp):.6f}")
554
+ else:
555
+ # bp3 exists here
556
+ bp_text = self.tr(
557
+ f"R min={float(bp3[0]):.6f}, G min={float(bp3[1]):.6f}, B min={float(bp3[2]):.6f}"
558
+ )
559
+
560
+ self.lbl_clipstats.setText(
561
+ self.tr(f"Black clipping disabled ({mode_lbl}). Threshold={bp_text}: "
562
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
563
+ )
564
+ return
565
+
566
+ # --- Normal message: show correct threshold(s) ---
567
+ if (imgf.ndim == 3 and imgf.shape[2] == 3) and not (luma_only or linked):
568
+ # Unlinked RGB: show per-channel thresholds
569
+ bp_disp = self.tr(
570
+ f"R={float(bp3[0]):.6f}, G={float(bp3[1]):.6f}, B={float(bp3[2]):.6f}"
571
+ )
572
+ else:
573
+ # Mono or L-based: single threshold
574
+ bp_disp = self.tr(f"{float(bp):.6f}")
575
+
576
+ self.lbl_clipstats.setText(
577
+ self.tr(f"Black clip ({mode_lbl}) @ {bp_disp}: "
578
+ f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
579
+ )
580
+
581
+
582
+ def _start_stretch_job(self, mode: str):
583
+ """
584
+ mode: 'preview' or 'apply'
585
+ """
586
+ if getattr(self, "_job_running", False):
587
+ return
588
+
589
+ self._job_running = True
590
+ self._job_mode = mode
591
+
592
+ self._set_controls_enabled(False)
593
+ self._show_busy("Statistical Stretch", "Processing…")
594
+
595
+ self._thread = QThread(self._main)
596
+ self._worker = _StretchWorker(self)
597
+ self._worker.moveToThread(self._thread)
598
+
599
+ self._thread.started.connect(self._worker.run)
600
+ self._worker.finished.connect(self._on_stretch_done)
601
+ self._worker.finished.connect(self._thread.quit)
602
+ self._worker.finished.connect(self._worker.deleteLater)
603
+ self._thread.finished.connect(self._thread.deleteLater)
604
+
605
+ self._thread.start()
606
+
607
+
153
608
  def _get_source_float(self) -> np.ndarray:
154
609
  """
155
610
  Return a float32 array scaled into ~[0..1] for stretching.
@@ -203,12 +658,43 @@ class StatisticalStretchDialog(QDialog):
203
658
  self._update_preview_scaled()
204
659
 
205
660
  def _zoom_by(self, factor: float):
206
- """Incremental zoom around the current center; exits Fit mode."""
661
+ vp = self.preview_scroll.viewport()
662
+ center = vp.rect().center()
663
+ self._zoom_at(factor, center)
664
+
665
+ def _zoom_at(self, factor: float, vp_pos):
666
+ """Zoom keeping the image point under vp_pos (viewport coords) stationary."""
667
+ if self._preview_qimg is None:
668
+ return
669
+
670
+ old_scale = float(self._preview_scale)
671
+
672
+ # Content coords (in scaled-image pixels) currently under the mouse
673
+ hsb = self.preview_scroll.horizontalScrollBar()
674
+ vsb = self.preview_scroll.verticalScrollBar()
675
+ cx = hsb.value() + int(vp_pos.x())
676
+ cy = vsb.value() + int(vp_pos.y())
677
+
678
+ # Convert to image-space coords (unscaled)
679
+ ix = cx / old_scale
680
+ iy = cy / old_scale
681
+
682
+ # Apply zoom
207
683
  self._fit_mode = False
208
- new_scale = self._preview_scale * float(factor)
209
- self._preview_scale = max(0.05, min(new_scale, 8.0))
684
+ new_scale = max(0.05, min(old_scale * float(factor), 8.0))
685
+ self._preview_scale = new_scale
686
+
687
+ # Rebuild pixmap/label size
210
688
  self._update_preview_scaled()
211
689
 
690
+ # New content coords for same image-space point
691
+ ncx = int(ix * new_scale)
692
+ ncy = int(iy * new_scale)
693
+
694
+ # Set scrollbars so that point stays under the mouse
695
+ hsb.setValue(ncx - int(vp_pos.x()))
696
+ vsb.setValue(ncy - int(vp_pos.y()))
697
+
212
698
 
213
699
  # --- MASK helpers ----------------------------------------------------
214
700
  def _active_mask_array(self) -> np.ndarray | None:
@@ -231,10 +717,13 @@ class StatisticalStretchDialog(QDialog):
231
717
  elif m.ndim == 3: # RGB/whatever → luminance
232
718
  m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
233
719
 
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)
720
+ orig = m
721
+ # normalize if integer
722
+ if orig.dtype.kind in "ui":
723
+ m = orig.astype(np.float32) / float(np.iinfo(orig.dtype).max)
724
+ else:
725
+ m = orig.astype(np.float32, copy=False)
726
+
238
727
  m = np.clip(m, 0.0, 1.0)
239
728
 
240
729
  th, tw = self.doc.image.shape[:2]
@@ -265,6 +754,13 @@ class StatisticalStretchDialog(QDialog):
265
754
  imgf = self._get_source_float()
266
755
  if imgf is None:
267
756
  return None
757
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
758
+ hdr_on = bool(self.chk_hdr.isChecked())
759
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
760
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
761
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
762
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
763
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
268
764
 
269
765
  target = float(self.spin_target.value())
270
766
  linked = bool(self.chk_linked.isChecked())
@@ -279,6 +775,11 @@ class StatisticalStretchDialog(QDialog):
279
775
  normalize=normalize,
280
776
  apply_curves=apply_curves,
281
777
  curves_boost=curves_boost,
778
+ blackpoint_sigma=blackpoint_sigma,
779
+ no_black_clip=no_black_clip,
780
+ hdr_compress=hdr_on,
781
+ hdr_amount=hdr_amount,
782
+ hdr_knee=hdr_knee,
282
783
  )
283
784
  else:
284
785
  out = stretch_color_image(
@@ -288,6 +789,13 @@ class StatisticalStretchDialog(QDialog):
288
789
  normalize=normalize,
289
790
  apply_curves=apply_curves,
290
791
  curves_boost=curves_boost,
792
+ blackpoint_sigma=blackpoint_sigma,
793
+ no_black_clip=no_black_clip,
794
+ hdr_compress=hdr_on,
795
+ hdr_amount=hdr_amount,
796
+ hdr_knee=hdr_knee,
797
+ luma_only=luma_only,
798
+ luma_mode=luma_mode,
291
799
  )
292
800
 
293
801
  # ✅ If a mask is active, blend stretched result with original
@@ -341,6 +849,10 @@ class StatisticalStretchDialog(QDialog):
341
849
  return
342
850
  self.doc = doc
343
851
  self._populate_initial_preview()
852
+ try:
853
+ self._schedule_clip_stats()
854
+ except Exception:
855
+ pass
344
856
 
345
857
  # ----- slots -----
346
858
  def _populate_initial_preview(self):
@@ -348,56 +860,66 @@ class StatisticalStretchDialog(QDialog):
348
860
  src = self._get_source_float()
349
861
  if src is not None:
350
862
  self._set_preview_pixmap(np.clip(src, 0, 1))
863
+ try:
864
+ self.lbl_clipstats.setText(self.tr("Calculating clip stats…"))
865
+ except Exception:
866
+ pass
867
+ try:
868
+ self._schedule_clip_stats()
869
+ except Exception:
870
+ pass
871
+
351
872
 
352
873
  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))
874
+ self._start_stretch_job("preview")
875
+
361
876
 
362
877
  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
878
+ self._start_stretch_job("apply")
879
+
880
+ def _apply_out_to_doc(self, out: np.ndarray):
881
+ # Preserve mono vs color shape
882
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
883
+ out = out[..., 0]
368
884
 
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)
885
+ # --- Gather current UI state ------------------------------------
886
+ target = float(self.spin_target.value())
887
+ linked = bool(self.chk_linked.isChecked())
888
+ normalize = bool(self.chk_normalize.isChecked())
889
+ apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
890
+ curves_boost = float(self.sld_curves.value()) / 100.0 if getattr(self, "sld_curves", None) is not None else 0.0
891
+ blackpoint_sigma = float(self.sld_bp.value()) / 100.0
892
+ hdr_on = bool(self.chk_hdr.isChecked())
893
+ hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
894
+ hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
895
+ luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
896
+ luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
897
+ no_black_clip = bool(self.chk_no_black_clip.isChecked())
898
+
899
+ parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
900
+ if normalize:
901
+ parts.append("norm")
902
+ if apply_curves:
903
+ parts.append(f"curves={curves_boost:.2f}")
904
+ if self._active_mask_array() is not None:
905
+ parts.append("masked")
906
+ parts.append(f"bpσ={blackpoint_sigma:.2f}")
907
+ if hdr_on and hdr_amount > 0:
908
+ parts.append(f"hdr={hdr_amount:.2f}@{hdr_knee:.2f}")
909
+ if luma_only:
910
+ parts.append(f"luma={luma_mode}")
911
+ if no_black_clip:
912
+ parts.append("no_black_clip")
913
+
914
+ step_name = f"Statistical Stretch ({', '.join(parts)})"
915
+ self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
916
+
917
+ # Turn off display stretch on the active view, if any
918
+ mw = self.parent()
919
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
920
+ view = mw.mdi.activeSubWindow().widget()
921
+ if getattr(view, "autostretch_enabled", False):
922
+ view.set_autostretch(False)
401
923
 
402
924
  # Existing logging, now using the same values as above
403
925
  if hasattr(mw, "_log"):
@@ -406,6 +928,10 @@ class StatisticalStretchDialog(QDialog):
406
928
  mw._log(
407
929
  "Applied Statistical Stretch "
408
930
  f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
931
+ f"bp_sigma={blackpoint_sigma:.2f}, "
932
+ f"hdr={'ON' if hdr_on else 'OFF'}"
933
+ f"{', amt='+str(round(hdr_amount,2))+' knee='+str(round(hdr_knee,2)) if hdr_on else ''}, "
934
+ f"luma={'ON' if luma_only else 'OFF'}{', mode='+luma_mode if luma_only else ''}, "
409
935
  f"curves={'ON' if curves_on else 'OFF'}"
410
936
  f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
411
937
  f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
@@ -419,6 +945,13 @@ class StatisticalStretchDialog(QDialog):
419
945
  "normalize": normalize,
420
946
  "apply_curves": apply_curves,
421
947
  "curves_boost": curves_boost,
948
+ "blackpoint_sigma": blackpoint_sigma,
949
+ "no_black_clip": no_black_clip,
950
+ "hdr_compress": hdr_on,
951
+ "hdr_amount": hdr_amount,
952
+ "hdr_knee": hdr_knee,
953
+ "luma_only": luma_only,
954
+ "luma_mode": luma_mode,
422
955
  }
423
956
 
424
957
  # ✅ Remember this as the last headless-style command
@@ -446,14 +979,9 @@ class StatisticalStretchDialog(QDialog):
446
979
  # optional debug
447
980
  print("Statistical Stretch: replay recording suppressed for this apply()")
448
981
 
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()
982
+ self.close()
452
983
 
453
984
 
454
- except Exception as e:
455
- QMessageBox.critical(self, "Apply failed", str(e))
456
-
457
985
  def _refresh_document_from_active(self):
458
986
  """
459
987
  Refresh the dialog's document reference to the currently active document.
@@ -471,6 +999,59 @@ class StatisticalStretchDialog(QDialog):
471
999
  except Exception:
472
1000
  pass
473
1001
 
1002
+ @pyqtSlot(object, str)
1003
+ def _on_stretch_done(self, out, err: str):
1004
+ # dialog might be closing; guard
1005
+ if sip.isdeleted(self):
1006
+ return
1007
+
1008
+ self._hide_busy()
1009
+ self._set_controls_enabled(True)
1010
+ self._job_running = False
1011
+
1012
+ if err:
1013
+ QMessageBox.warning(self, "Stretch failed", err)
1014
+ return
1015
+
1016
+ if out is None:
1017
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
1018
+ return
1019
+
1020
+ if getattr(self, "_job_mode", "") == "preview":
1021
+ self._set_preview_pixmap(out)
1022
+ return
1023
+
1024
+ # apply mode: reuse your existing apply logic, but using `out` we already computed
1025
+ self._apply_out_to_doc(out)
1026
+
1027
+ if getattr(self, "_pending_close", False):
1028
+ self._pending_close = False
1029
+ self.close()
1030
+
1031
+ def closeEvent(self, ev):
1032
+ # If a job is running, DO NOT close (WA_DeleteOnClose would delete the QThread)
1033
+ if getattr(self, "_job_running", False):
1034
+ self._pending_close = True
1035
+ try:
1036
+ self._hide_busy()
1037
+ except Exception:
1038
+ pass
1039
+ try:
1040
+ self.hide()
1041
+ except Exception:
1042
+ pass
1043
+ ev.ignore()
1044
+ return
1045
+
1046
+ # disconnect follow behavior
1047
+ try:
1048
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
1049
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1050
+ except Exception:
1051
+ pass
1052
+
1053
+ super().closeEvent(ev)
1054
+
474
1055
 
475
1056
  def _update_preview_scaled(self):
476
1057
  if self._preview_qimg is None:
@@ -493,30 +1074,24 @@ class StatisticalStretchDialog(QDialog):
493
1074
 
494
1075
  def eventFilter(self, obj, ev):
495
1076
  # Ctrl+wheel zoom
496
- if ev.type() == QEvent.Type.Wheel and (obj is self.preview_scroll.viewport() or obj is self.preview_label):
1077
+ if ev.type() == QEvent.Type.Wheel and obj is self.preview_scroll.viewport():
497
1078
  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()
1079
+ factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
1080
+ self._zoom_at(factor, ev.position())
502
1081
  return True
503
1082
  return False
504
1083
 
505
1084
  # Click+drag pan (left or middle mouse)
506
- if obj is self.preview_scroll.viewport() or obj is self.preview_label:
1085
+ if obj is self.preview_scroll.viewport():
507
1086
  if ev.type() == QEvent.Type.MouseButtonPress:
508
- if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
1087
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
509
1088
  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)
1089
+ self._pan_last = ev.globalPosition().toPoint()
1090
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
516
1091
  return True
517
1092
 
518
1093
  elif ev.type() == QEvent.Type.MouseMove and self._panning:
519
- pos = ev.position().toPoint()
1094
+ pos = ev.globalPosition().toPoint()
520
1095
  delta = pos - self._pan_last
521
1096
  self._pan_last = pos
522
1097
 
@@ -527,12 +1102,11 @@ class StatisticalStretchDialog(QDialog):
527
1102
  return True
528
1103
 
529
1104
  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
1105
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
1106
+ self._panning = False
1107
+ self._pan_last = None
1108
+ self.preview_scroll.viewport().unsetCursor()
1109
+ return True
536
1110
 
537
1111
  return super().eventFilter(obj, ev)
538
1112