setiastrosuitepro 1.6.7__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (37) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/cosmic.svg +40 -0
  3. setiastro/images/cosmicsat.svg +24 -0
  4. setiastro/images/graxpert.svg +19 -0
  5. setiastro/images/linearfit.svg +32 -0
  6. setiastro/images/pixelmath.svg +42 -0
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/add_stars.py +29 -5
  9. setiastro/saspro/blink_comparator_pro.py +74 -24
  10. setiastro/saspro/cosmicclarity.py +125 -18
  11. setiastro/saspro/crop_dialog_pro.py +96 -2
  12. setiastro/saspro/curve_editor_pro.py +60 -39
  13. setiastro/saspro/frequency_separation.py +1159 -208
  14. setiastro/saspro/gui/main_window.py +131 -31
  15. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  16. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  17. setiastro/saspro/imageops/stretch.py +531 -62
  18. setiastro/saspro/layers.py +13 -9
  19. setiastro/saspro/layers_dock.py +183 -3
  20. setiastro/saspro/legacy/numba_utils.py +43 -0
  21. setiastro/saspro/live_stacking.py +158 -70
  22. setiastro/saspro/multiscale_decomp.py +47 -12
  23. setiastro/saspro/numba_utils.py +72 -2
  24. setiastro/saspro/ops/commands.py +18 -18
  25. setiastro/saspro/shortcuts.py +122 -12
  26. setiastro/saspro/signature_insert.py +688 -33
  27. setiastro/saspro/stacking_suite.py +523 -316
  28. setiastro/saspro/stat_stretch.py +688 -130
  29. setiastro/saspro/subwindow.py +302 -71
  30. setiastro/saspro/widgets/common_utilities.py +28 -21
  31. setiastro/saspro/widgets/resource_monitor.py +7 -7
  32. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
  33. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
  34. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  35. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  36. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  37. {setiastrosuitepro-1.6.7.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,91 +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)
32
58
  try:
33
59
  self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
34
60
  except Exception:
35
- pass # older PyQt6 versions
61
+ pass
62
+
63
+ # --- State / refs ---
36
64
  self._main = parent
37
65
  self.doc = document
66
+
38
67
  self._last_preview = None
68
+ self._preview_qimg = None
69
+ self._preview_scale = 1.0
70
+ self._fit_mode = True
71
+
72
+ self._panning = False
73
+ self._pan_last = None # QPoint
74
+
75
+ self._hdr_knee_user_locked = False
76
+ self._pending_close = False
77
+ self._suppress_replay_record = False
78
+
79
+ # ---- Clip-stats scheduling (define EARLY so init callbacks can't crash) ----
80
+ self._clip_timer = None
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
39
94
 
95
+ self._thread = None
96
+ self._worker = None
40
97
  self._follow_conn = None
98
+ self._job_running = False
99
+ self._job_mode = ""
100
+
101
+ # --- Follow active document changes (optional) ---
41
102
  if hasattr(self._main, "currentDocumentChanged"):
42
103
  try:
43
- # store connection so we can cleanly disconnect
44
104
  self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
45
105
  self._follow_conn = True
46
106
  except Exception:
47
107
  self._follow_conn = None
48
- self._panning = False
49
- self._pan_last = None # QPoint
50
- self._preview_scale = 1.0 # NEW: zoom factor for preview
51
- self._preview_qimg = None # NEW: store unscaled QImage for clean scaling
52
- self._suppress_replay_record = False
53
108
 
54
- # --- Controls ---
109
+ # ------------------------------------------------------------------
110
+ # Controls
111
+ # ------------------------------------------------------------------
112
+
113
+ # Target median
55
114
  self.spin_target = QDoubleSpinBox()
56
115
  self.spin_target.setRange(0.01, 0.99)
57
116
  self.spin_target.setSingleStep(0.01)
58
117
  self.spin_target.setValue(0.25)
59
118
  self.spin_target.setDecimals(3)
60
119
 
120
+ # Linked channels
61
121
  self.chk_linked = QCheckBox(self.tr("Linked channels"))
62
122
  self.chk_linked.setChecked(False)
63
123
 
124
+ # Normalize
64
125
  self.chk_normalize = QCheckBox(self.tr("Normalize to [0..1]"))
65
126
  self.chk_normalize.setChecked(False)
66
127
 
67
- # NEW: Curves boost
128
+ # --- Black point sigma row ---
129
+ self.row_bp = QWidget()
130
+ bp_lay = QHBoxLayout(self.row_bp)
131
+ bp_lay.setContentsMargins(0, 0, 0, 0)
132
+ bp_lay.setSpacing(8)
133
+
134
+ bp_lay.addWidget(QLabel(self.tr("Black point σ:")))
135
+
136
+ self.sld_bp = QSlider(Qt.Orientation.Horizontal)
137
+ self.sld_bp.setRange(50, 600) # 0.50 .. 6.00
138
+ self.sld_bp.setValue(500) # 5.00 default (matches your label)
139
+ bp_lay.addWidget(self.sld_bp, 1)
140
+
141
+ self.lbl_bp = QLabel(f"{self.sld_bp.value()/100:.2f}")
142
+ bp_lay.addWidget(self.lbl_bp)
143
+
144
+ bp_tip = self.tr(
145
+ "Black point (σ) controls how aggressively the dark background is clipped.\n"
146
+ "Higher values clip more (darker background, more contrast), but can crush faint dust.\n"
147
+ "Lower values preserve faint background, but may leave the image gray.\n"
148
+ "Tip: start around 2.7–5.0 depending on gradient/noise."
149
+ )
150
+ self.row_bp.setToolTip(bp_tip)
151
+ self.sld_bp.setToolTip(bp_tip)
152
+ self.lbl_bp.setToolTip(bp_tip)
153
+
154
+ # No black clipping
155
+ self.chk_no_black_clip = QCheckBox(self.tr("No black clipping (Old Stat Stretch behavior)"))
156
+ self.chk_no_black_clip.setChecked(False)
157
+ self.chk_no_black_clip.setToolTip(self.tr(
158
+ "Disables black-point clipping.\n"
159
+ "Uses the image minimum as the black point (preserves faint background),\n"
160
+ "but the result may look flatter / hazier."
161
+ ))
162
+
163
+ # --- HDR compress ---
164
+ self.chk_hdr = QCheckBox(self.tr("HDR highlight compress"))
165
+ self.chk_hdr.setChecked(False)
166
+ self.chk_hdr.setToolTip(self.tr(
167
+ "Compresses bright highlights after the stretch.\n"
168
+ "Use lightly: high values can flatten the image and create star ringing."
169
+ ))
170
+
171
+ self.hdr_row = QWidget()
172
+ hdr_lay = QVBoxLayout(self.hdr_row)
173
+ hdr_lay.setContentsMargins(0, 0, 0, 0)
174
+ hdr_lay.setSpacing(6)
175
+
176
+ # HDR amount row
177
+ row_a = QHBoxLayout()
178
+ row_a.setContentsMargins(0, 0, 0, 0)
179
+ row_a.setSpacing(8)
180
+ row_a.addWidget(QLabel(self.tr("Amount:")))
181
+
182
+ self.sld_hdr_amt = QSlider(Qt.Orientation.Horizontal)
183
+ self.sld_hdr_amt.setRange(0, 100)
184
+ self.sld_hdr_amt.setValue(15)
185
+ row_a.addWidget(self.sld_hdr_amt, 1)
186
+
187
+ self.lbl_hdr_amt = QLabel(f"{self.sld_hdr_amt.value()/100:.2f}")
188
+ row_a.addWidget(self.lbl_hdr_amt)
189
+
190
+ self.sld_hdr_amt.setToolTip(self.tr(
191
+ "Compression strength (0–1).\n"
192
+ "Start low (0.10–0.15). Too much can flatten the image and ring stars."
193
+ ))
194
+ self.lbl_hdr_amt.setToolTip(self.sld_hdr_amt.toolTip())
195
+
196
+ # HDR knee row
197
+ row_k = QHBoxLayout()
198
+ row_k.setContentsMargins(0, 0, 0, 0)
199
+ row_k.setSpacing(8)
200
+ row_k.addWidget(QLabel(self.tr("Knee:")))
201
+
202
+ self.sld_hdr_knee = QSlider(Qt.Orientation.Horizontal)
203
+ self.sld_hdr_knee.setRange(10, 95)
204
+ self.sld_hdr_knee.setValue(75)
205
+ row_k.addWidget(self.sld_hdr_knee, 1)
206
+
207
+ self.lbl_hdr_knee = QLabel(f"{self.sld_hdr_knee.value()/100:.2f}")
208
+ row_k.addWidget(self.lbl_hdr_knee)
209
+
210
+ self.sld_hdr_knee.setToolTip(self.tr(
211
+ "Where compression begins (0–1).\n"
212
+ "Good starting point: knee ≈ target median + 0.10 to + 0.20.\n"
213
+ "Example: target 0.25 → knee 0.35–0.45."
214
+ ))
215
+ self.lbl_hdr_knee.setToolTip(self.sld_hdr_knee.toolTip())
216
+
217
+ hdr_lay.addLayout(row_a)
218
+ hdr_lay.addLayout(row_k)
219
+
220
+ self.hdr_row.setEnabled(False)
221
+
222
+ # --- Luma-only row (checkbox + dropdown on one line) ---
223
+ self.luma_row = QWidget()
224
+ lr = QHBoxLayout(self.luma_row)
225
+ lr.setContentsMargins(0, 0, 0, 0)
226
+ lr.setSpacing(8)
227
+
228
+ self.chk_luma_only = QCheckBox(self.tr("Luminance-only"))
229
+ self.chk_luma_only.setChecked(False)
230
+
231
+ self.cmb_luma = QComboBox()
232
+ keys = list(LUMA_PROFILES.keys())
233
+
234
+ def _cat(k):
235
+ return str(LUMA_PROFILES.get(k, {}).get("category", ""))
236
+
237
+ keys.sort(key=lambda k: (_cat(k), k.lower()))
238
+ self.cmb_luma.addItems(keys)
239
+ self.cmb_luma.setCurrentText("rec709")
240
+ self.cmb_luma.setEnabled(False)
241
+
242
+ lr.addWidget(self.chk_luma_only)
243
+ lr.addWidget(QLabel(self.tr("Mode:")))
244
+ lr.addWidget(self.cmb_luma, 1)
245
+
246
+ # --- Curves boost ---
68
247
  self.chk_curves = QCheckBox(self.tr("Curves boost"))
69
248
  self.chk_curves.setChecked(False)
70
249
 
71
250
  self.curves_row = QWidget()
72
- 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)
73
253
  cr_lay.setSpacing(8)
254
+
74
255
  cr_lay.addWidget(QLabel(self.tr("Strength:")))
75
256
  self.sld_curves = QSlider(Qt.Orientation.Horizontal)
76
- self.sld_curves.setRange(0, 100) # 0.00 … 1.00 mapped to 0…100
77
- self.sld_curves.setSingleStep(1)
78
- self.sld_curves.setPageStep(5)
79
- self.sld_curves.setValue(20) # default 0.20
80
- self.lbl_curves_val = QLabel("0.20")
81
- self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
257
+ self.sld_curves.setRange(0, 100)
258
+ self.sld_curves.setValue(20)
82
259
  cr_lay.addWidget(self.sld_curves, 1)
260
+
261
+ self.lbl_curves_val = QLabel(f"{self.sld_curves.value()/100:.2f}")
83
262
  cr_lay.addWidget(self.lbl_curves_val)
84
- self.curves_row.setEnabled(False) # disabled until checkbox is ticked
85
- self.chk_curves.toggled.connect(self.curves_row.setEnabled)
86
263
 
87
- # Preview area
264
+ self.curves_row.setEnabled(False)
265
+
266
+ # ------------------------------------------------------------------
267
+ # Preview UI
268
+ # ------------------------------------------------------------------
88
269
  self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
89
270
  self.preview_label.setMinimumSize(QSize(320, 240))
90
- self.preview_label.setScaledContents(False)
271
+ self.preview_label.setScaledContents(False)
272
+ self.preview_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
273
+
91
274
  self.preview_scroll = QScrollArea()
92
- self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
275
+ self.preview_scroll.setWidgetResizable(False)
93
276
  self.preview_scroll.setWidget(self.preview_label)
94
277
  self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
95
278
  self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
279
+ self.preview_scroll.viewport().installEventFilter(self)
96
280
 
97
- self._fit_mode = True # NEW: start in Fit mode
98
-
99
- # --- Zoom buttons row (place before the main layout or right above preview) ---
100
- # --- Zoom buttons row ---
281
+ # Zoom buttons
101
282
  zoom_row = QHBoxLayout()
102
-
103
- # Use themed tool buttons (consistent with the rest of SASpro)
104
283
  self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
105
284
  self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
106
285
  self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
107
286
  self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
108
287
 
109
-
110
288
  zoom_row.addStretch(1)
111
289
  for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
112
290
  zoom_row.addWidget(b)
@@ -117,47 +295,316 @@ class StatisticalStretchDialog(QDialog):
117
295
  self.btn_apply = QPushButton(self.tr("Apply"))
118
296
  self.btn_close = QPushButton(self.tr("Close"))
119
297
 
120
- self.btn_preview.clicked.connect(self._do_preview)
121
- self.btn_apply.clicked.connect(self._do_apply)
122
- self.btn_close.clicked.connect(self.close)
123
-
124
- # --- Layout ---
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
+ # ------------------------------------------------------------------
125
310
  form = QFormLayout()
126
311
  form.addRow(self.tr("Target median:"), self.spin_target)
127
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)
128
318
  form.addRow("", self.chk_normalize)
129
319
  form.addRow("", self.chk_curves)
130
320
  form.addRow("", self.curves_row)
131
321
 
132
322
  left = QVBoxLayout()
133
323
  left.addLayout(form)
134
- row = QHBoxLayout()
135
- row.addWidget(self.btn_preview)
136
- row.addWidget(self.btn_apply)
137
- row.addStretch(1)
138
- left.addLayout(row)
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)
139
333
  left.addStretch(1)
140
334
 
335
+ right = QVBoxLayout()
336
+ right.addLayout(zoom_row)
337
+ right.addWidget(self.preview_scroll, 1)
338
+
141
339
  main = QHBoxLayout(self)
142
340
  main.addLayout(left, 0)
143
-
144
- # NEW: right column with zoom row + preview
145
- right = QVBoxLayout()
146
- right.addLayout(zoom_row) # ← actually add the zoom controls
147
- right.addWidget(self.preview_scroll, 1) # preview below the buttons
148
341
  main.addLayout(right, 1)
149
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
150
394
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
151
395
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
152
396
  self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
153
397
  self.btn_zoom_fit.clicked.connect(self._fit_preview)
154
398
 
155
- self.preview_scroll.viewport().installEventFilter(self)
156
- 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())
157
414
 
415
+ # Initial preview + clip stats
158
416
  self._populate_initial_preview()
159
417
 
418
+
160
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
+
161
608
  def _get_source_float(self) -> np.ndarray:
162
609
  """
163
610
  Return a float32 array scaled into ~[0..1] for stretching.
@@ -211,12 +658,43 @@ class StatisticalStretchDialog(QDialog):
211
658
  self._update_preview_scaled()
212
659
 
213
660
  def _zoom_by(self, factor: float):
214
- """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
215
683
  self._fit_mode = False
216
- new_scale = self._preview_scale * float(factor)
217
- 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
218
688
  self._update_preview_scaled()
219
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
+
220
698
 
221
699
  # --- MASK helpers ----------------------------------------------------
222
700
  def _active_mask_array(self) -> np.ndarray | None:
@@ -239,10 +717,13 @@ class StatisticalStretchDialog(QDialog):
239
717
  elif m.ndim == 3: # RGB/whatever → luminance
240
718
  m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
241
719
 
242
- m = m.astype(np.float32, copy=False)
243
- # normalize if integer / out-of-range
244
- if m.dtype.kind in "ui":
245
- m /= float(np.iinfo(m.dtype).max)
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
+
246
727
  m = np.clip(m, 0.0, 1.0)
247
728
 
248
729
  th, tw = self.doc.image.shape[:2]
@@ -273,6 +754,13 @@ class StatisticalStretchDialog(QDialog):
273
754
  imgf = self._get_source_float()
274
755
  if imgf is None:
275
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())
276
764
 
277
765
  target = float(self.spin_target.value())
278
766
  linked = bool(self.chk_linked.isChecked())
@@ -287,6 +775,11 @@ class StatisticalStretchDialog(QDialog):
287
775
  normalize=normalize,
288
776
  apply_curves=apply_curves,
289
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,
290
783
  )
291
784
  else:
292
785
  out = stretch_color_image(
@@ -296,6 +789,13 @@ class StatisticalStretchDialog(QDialog):
296
789
  normalize=normalize,
297
790
  apply_curves=apply_curves,
298
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,
299
799
  )
300
800
 
301
801
  # ✅ If a mask is active, blend stretched result with original
@@ -349,6 +849,10 @@ class StatisticalStretchDialog(QDialog):
349
849
  return
350
850
  self.doc = doc
351
851
  self._populate_initial_preview()
852
+ try:
853
+ self._schedule_clip_stats()
854
+ except Exception:
855
+ pass
352
856
 
353
857
  # ----- slots -----
354
858
  def _populate_initial_preview(self):
@@ -356,56 +860,66 @@ class StatisticalStretchDialog(QDialog):
356
860
  src = self._get_source_float()
357
861
  if src is not None:
358
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
+
359
872
 
360
873
  def _do_preview(self):
361
- try:
362
- out = self._run_stretch()
363
- if out is None:
364
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
365
- return
366
- self._set_preview_pixmap(out)
367
- except Exception as e:
368
- QMessageBox.warning(self, "Preview failed", str(e))
874
+ self._start_stretch_job("preview")
875
+
369
876
 
370
877
  def _do_apply(self):
371
- try:
372
- out = self._run_stretch()
373
- if out is None:
374
- QMessageBox.information(self, "No image", "No image is loaded in the active document.")
375
- return
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]
376
884
 
377
- # Preserve mono vs color shape
378
- if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
379
- out = out[..., 0]
380
-
381
- # --- Gather current UI state ------------------------------------
382
- target = float(self.spin_target.value())
383
- linked = bool(self.chk_linked.isChecked())
384
- normalize = bool(self.chk_normalize.isChecked())
385
- apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
386
- curves_boost = 0.0
387
- if getattr(self, "sld_curves", None) is not None:
388
- curves_boost = float(self.sld_curves.value()) / 100.0
389
-
390
- # Build human-readable step name
391
- parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
392
- if normalize:
393
- parts.append("norm")
394
- if apply_curves:
395
- parts.append(f"curves={curves_boost:.2f}")
396
- if self._active_mask_array() is not None:
397
- parts.append("masked")
398
- step_name = f"Statistical Stretch ({', '.join(parts)})"
399
-
400
- # Apply to document
401
- self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
402
-
403
- # Turn off display stretch on the active view, if any
404
- mw = self.parent()
405
- if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
406
- view = mw.mdi.activeSubWindow().widget()
407
- if getattr(view, "autostretch_enabled", False):
408
- view.set_autostretch(False)
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)
409
923
 
410
924
  # Existing logging, now using the same values as above
411
925
  if hasattr(mw, "_log"):
@@ -414,6 +928,10 @@ class StatisticalStretchDialog(QDialog):
414
928
  mw._log(
415
929
  "Applied Statistical Stretch "
416
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 ''}, "
417
935
  f"curves={'ON' if curves_on else 'OFF'}"
418
936
  f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
419
937
  f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
@@ -427,6 +945,13 @@ class StatisticalStretchDialog(QDialog):
427
945
  "normalize": normalize,
428
946
  "apply_curves": apply_curves,
429
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,
430
955
  }
431
956
 
432
957
  # ✅ Remember this as the last headless-style command
@@ -454,12 +979,8 @@ class StatisticalStretchDialog(QDialog):
454
979
  # optional debug
455
980
  print("Statistical Stretch: replay recording suppressed for this apply()")
456
981
 
457
- self.close()
458
- return
459
-
982
+ self.close()
460
983
 
461
- except Exception as e:
462
- QMessageBox.critical(self, "Apply failed", str(e))
463
984
 
464
985
  def _refresh_document_from_active(self):
465
986
  """
@@ -478,13 +999,57 @@ class StatisticalStretchDialog(QDialog):
478
999
  except Exception:
479
1000
  pass
480
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
+
481
1031
  def closeEvent(self, ev):
482
- # disconnect the “follow active document” hook
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
483
1047
  try:
484
1048
  if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
485
1049
  self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
486
1050
  except Exception:
487
1051
  pass
1052
+
488
1053
  super().closeEvent(ev)
489
1054
 
490
1055
 
@@ -509,30 +1074,24 @@ class StatisticalStretchDialog(QDialog):
509
1074
 
510
1075
  def eventFilter(self, obj, ev):
511
1076
  # Ctrl+wheel zoom
512
- 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():
513
1078
  if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
514
- factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
515
- self._fit_mode = False # ← ensure we exit Fit mode
516
- self._preview_scale = max(0.05, min(self._preview_scale * factor, 8.0))
517
- self._update_preview_scaled()
1079
+ factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
1080
+ self._zoom_at(factor, ev.position())
518
1081
  return True
519
1082
  return False
520
1083
 
521
1084
  # Click+drag pan (left or middle mouse)
522
- if obj is self.preview_scroll.viewport() or obj is self.preview_label:
1085
+ if obj is self.preview_scroll.viewport():
523
1086
  if ev.type() == QEvent.Type.MouseButtonPress:
524
- if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
1087
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
525
1088
  self._panning = True
526
- self._pan_last = ev.position().toPoint()
527
- # show a "grab" cursor where the drag begins
528
- if obj is self.preview_label:
529
- self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
530
- else:
531
- self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
1089
+ self._pan_last = ev.globalPosition().toPoint()
1090
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
532
1091
  return True
533
1092
 
534
1093
  elif ev.type() == QEvent.Type.MouseMove and self._panning:
535
- pos = ev.position().toPoint()
1094
+ pos = ev.globalPosition().toPoint()
536
1095
  delta = pos - self._pan_last
537
1096
  self._pan_last = pos
538
1097
 
@@ -543,12 +1102,11 @@ class StatisticalStretchDialog(QDialog):
543
1102
  return True
544
1103
 
545
1104
  elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
546
- self._panning = False
547
- self._pan_last = None
548
- # restore cursor
549
- self.preview_label.unsetCursor()
550
- self.preview_scroll.viewport().unsetCursor()
551
- return True
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
552
1110
 
553
1111
  return super().eventFilter(obj, ev)
554
1112