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.
- setiastro/images/abeicon.svg +16 -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/pixelmath.svg +42 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +60 -39
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +131 -31
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/live_stacking.py +158 -70
- setiastro/saspro/multiscale_decomp.py +47 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/shortcuts.py +122 -12
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +523 -316
- setiastro/saspro/stat_stretch.py +688 -130
- setiastro/saspro/subwindow.py +302 -71
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +7 -7
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.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
|
|
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,240 @@ 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
|
|
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
|
-
#
|
|
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
|
+
# --- 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)
|
|
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)
|
|
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}"))
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
121
|
-
self.
|
|
122
|
-
self.
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
156
|
-
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
|
-
|
|
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 =
|
|
217
|
-
self._preview_scale =
|
|
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
|
-
|
|
243
|
-
# normalize if integer
|
|
244
|
-
if
|
|
245
|
-
m
|
|
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
|
-
|
|
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))
|
|
874
|
+
self._start_stretch_job("preview")
|
|
875
|
+
|
|
369
876
|
|
|
370
877
|
def _do_apply(self):
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
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()
|
|
1085
|
+
if obj is self.preview_scroll.viewport():
|
|
523
1086
|
if ev.type() == QEvent.Type.MouseButtonPress:
|
|
524
|
-
if ev.
|
|
1087
|
+
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
|
|
525
1088
|
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)
|
|
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.
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|