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