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