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