setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/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/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 +20 -1
- 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 +108 -40
- 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 +13 -7
- 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 +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 +429 -228
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
- 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/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 +67 -47
- 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 -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/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +160 -29
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1331 -484
- 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 +743 -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 +84 -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.12.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.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,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)
|
|
@@ -109,47 +321,326 @@ class StatisticalStretchDialog(QDialog):
|
|
|
109
321
|
self.btn_apply = QPushButton(self.tr("Apply"))
|
|
110
322
|
self.btn_close = QPushButton(self.tr("Close"))
|
|
111
323
|
|
|
112
|
-
self.
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
115
|
-
|
|
116
|
-
|
|
324
|
+
self.btn_clipstats = QPushButton(self.tr("Clip stats"))
|
|
325
|
+
self.lbl_clipstats = QLabel("")
|
|
326
|
+
self.lbl_clipstats.setWordWrap(True)
|
|
327
|
+
self.lbl_clipstats.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
328
|
+
self.lbl_clipstats.setMinimumHeight(38)
|
|
329
|
+
self.lbl_clipstats.setFrameShape(QLabel.Shape.StyledPanel)
|
|
330
|
+
self.lbl_clipstats.setFrameShadow(QLabel.Shadow.Sunken)
|
|
331
|
+
self.lbl_clipstats.setContentsMargins(6, 4, 6, 4)
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
# Layout
|
|
335
|
+
# ------------------------------------------------------------------
|
|
117
336
|
form = QFormLayout()
|
|
118
337
|
form.addRow(self.tr("Target median:"), self.spin_target)
|
|
119
338
|
form.addRow("", self.chk_linked)
|
|
339
|
+
form.addRow("", self.row_bp)
|
|
340
|
+
form.addRow("", self.chk_no_black_clip)
|
|
341
|
+
form.addRow("", self.chk_hdr)
|
|
342
|
+
form.addRow("", self.hdr_row)
|
|
343
|
+
form.addRow("", self.luma_row)
|
|
344
|
+
form.addRow("", self.luma_blend_row)
|
|
120
345
|
form.addRow("", self.chk_normalize)
|
|
121
346
|
form.addRow("", self.chk_curves)
|
|
122
347
|
form.addRow("", self.curves_row)
|
|
123
348
|
|
|
124
349
|
left = QVBoxLayout()
|
|
125
350
|
left.addLayout(form)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
351
|
+
|
|
352
|
+
btn_row = QHBoxLayout()
|
|
353
|
+
btn_row.addWidget(self.btn_preview)
|
|
354
|
+
btn_row.addWidget(self.btn_apply)
|
|
355
|
+
btn_row.addWidget(self.btn_clipstats)
|
|
356
|
+
btn_row.addStretch(1)
|
|
357
|
+
left.addLayout(btn_row)
|
|
358
|
+
|
|
359
|
+
left.addWidget(self.lbl_clipstats)
|
|
131
360
|
left.addStretch(1)
|
|
132
361
|
|
|
362
|
+
right = QVBoxLayout()
|
|
363
|
+
right.addLayout(zoom_row)
|
|
364
|
+
right.addWidget(self.preview_scroll, 1)
|
|
365
|
+
|
|
133
366
|
main = QHBoxLayout(self)
|
|
134
367
|
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
368
|
main.addLayout(right, 1)
|
|
141
369
|
|
|
370
|
+
# ------------------------------------------------------------------
|
|
371
|
+
# Behavior / wiring
|
|
372
|
+
# ------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
# Blackpoint slider -> label + debounced clip stats
|
|
375
|
+
def _on_bp_changed(v: int):
|
|
376
|
+
self.lbl_bp.setText(f"{v/100:.2f}")
|
|
377
|
+
self._schedule_clip_stats()
|
|
378
|
+
|
|
379
|
+
self.sld_bp.valueChanged.connect(_on_bp_changed)
|
|
380
|
+
|
|
381
|
+
# No-black-clip toggles blackpoint UI + triggers stats
|
|
382
|
+
def _on_no_black_clip_toggled(on: bool):
|
|
383
|
+
self.row_bp.setEnabled(not on)
|
|
384
|
+
self._schedule_clip_stats()
|
|
385
|
+
|
|
386
|
+
self.chk_no_black_clip.toggled.connect(_on_no_black_clip_toggled)
|
|
387
|
+
_on_no_black_clip_toggled(self.chk_no_black_clip.isChecked())
|
|
388
|
+
|
|
389
|
+
# Curves
|
|
390
|
+
self.chk_curves.toggled.connect(self.curves_row.setEnabled)
|
|
391
|
+
self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
|
|
392
|
+
|
|
393
|
+
# HDR enable toggles HDR row
|
|
394
|
+
self.chk_hdr.toggled.connect(self.hdr_row.setEnabled)
|
|
395
|
+
self.sld_hdr_amt.valueChanged.connect(lambda v: self.lbl_hdr_amt.setText(f"{v/100:.2f}"))
|
|
396
|
+
self.sld_hdr_knee.valueChanged.connect(lambda v: self.lbl_hdr_knee.setText(f"{v/100:.2f}"))
|
|
397
|
+
self.sld_hdr_knee.sliderPressed.connect(lambda: setattr(self, "_hdr_knee_user_locked", True))
|
|
398
|
+
|
|
399
|
+
# Auto-suggest HDR knee from target (unless user locked)
|
|
400
|
+
def _suggest_hdr_knee_from_target():
|
|
401
|
+
if getattr(self, "_hdr_knee_user_locked", False):
|
|
402
|
+
return
|
|
403
|
+
t = float(self.spin_target.value())
|
|
404
|
+
knee = float(np.clip(t + 0.10, 0.10, 0.95))
|
|
405
|
+
self.sld_hdr_knee.blockSignals(True)
|
|
406
|
+
self.sld_hdr_knee.setValue(int(round(knee * 100)))
|
|
407
|
+
self.sld_hdr_knee.blockSignals(False)
|
|
408
|
+
self.lbl_hdr_knee.setText(f"{knee:.2f}")
|
|
409
|
+
|
|
410
|
+
self.spin_target.valueChanged.connect(_suggest_hdr_knee_from_target)
|
|
411
|
+
|
|
412
|
+
# Luma-only: enables dropdown, disables "linked channels"
|
|
413
|
+
self.chk_luma_only.toggled.connect(self.cmb_luma.setEnabled)
|
|
414
|
+
|
|
415
|
+
def _on_luma_only_toggled(on: bool):
|
|
416
|
+
self.chk_linked.setEnabled(not on)
|
|
417
|
+
|
|
418
|
+
self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
|
|
419
|
+
|
|
420
|
+
# Zoom buttons
|
|
142
421
|
self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
|
|
143
422
|
self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
|
|
144
423
|
self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
|
|
145
424
|
self.btn_zoom_fit.clicked.connect(self._fit_preview)
|
|
146
425
|
|
|
147
|
-
|
|
148
|
-
self.
|
|
426
|
+
# Main buttons
|
|
427
|
+
self.btn_preview.clicked.connect(self._do_preview)
|
|
428
|
+
self.btn_apply.clicked.connect(self._do_apply)
|
|
429
|
+
self.btn_close.clicked.connect(self.close)
|
|
430
|
+
self.btn_clipstats.clicked.connect(self._do_clip_stats)
|
|
431
|
+
|
|
432
|
+
# Debounced clip stats timer
|
|
433
|
+
self._clip_timer = QTimer(self)
|
|
434
|
+
self._clip_timer.setSingleShot(True)
|
|
435
|
+
self._clip_timer.setInterval(500)
|
|
436
|
+
self._clip_timer.timeout.connect(self._do_clip_stats)
|
|
437
|
+
|
|
438
|
+
# Initialize UI state
|
|
439
|
+
_suggest_hdr_knee_from_target()
|
|
440
|
+
self.sld_luma_blend.valueChanged.connect(
|
|
441
|
+
lambda v: self.lbl_luma_blend.setText(f"{v/100:.2f}")
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _on_luma_only_toggled(on: bool):
|
|
445
|
+
self.chk_linked.setEnabled(not on)
|
|
446
|
+
self.luma_blend_row.setEnabled(on)
|
|
447
|
+
|
|
448
|
+
self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
|
|
449
|
+
_on_luma_only_toggled(self.chk_luma_only.isChecked())
|
|
149
450
|
|
|
451
|
+
# Initial preview + clip stats
|
|
150
452
|
self._populate_initial_preview()
|
|
151
453
|
|
|
454
|
+
|
|
152
455
|
# ----- helpers -----
|
|
456
|
+
def _show_busy(self, title: str, text: str):
|
|
457
|
+
# Avoid stacking dialogs
|
|
458
|
+
self._hide_busy()
|
|
459
|
+
|
|
460
|
+
dlg = QProgressDialog(text, None, 0, 0, self)
|
|
461
|
+
dlg.setWindowTitle(title)
|
|
462
|
+
dlg.setWindowModality(Qt.WindowModality.WindowModal) # blocks only this tool window
|
|
463
|
+
dlg.setMinimumDuration(0)
|
|
464
|
+
dlg.setValue(0)
|
|
465
|
+
dlg.setCancelButton(None) # no cancel button (keeps it simple)
|
|
466
|
+
dlg.setAutoClose(False)
|
|
467
|
+
dlg.setAutoReset(False)
|
|
468
|
+
dlg.setFixedWidth(320)
|
|
469
|
+
dlg.show()
|
|
470
|
+
|
|
471
|
+
# Ensure it paints before heavy work starts
|
|
472
|
+
QApplication.processEvents()
|
|
473
|
+
self._busy = dlg
|
|
474
|
+
|
|
475
|
+
def _hide_busy(self):
|
|
476
|
+
try:
|
|
477
|
+
if getattr(self, "_busy", None) is not None:
|
|
478
|
+
self._busy.close()
|
|
479
|
+
self._busy.deleteLater()
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
self._busy = None
|
|
483
|
+
|
|
484
|
+
def _set_controls_enabled(self, enabled: bool):
|
|
485
|
+
try:
|
|
486
|
+
self.btn_preview.setEnabled(enabled)
|
|
487
|
+
self.btn_apply.setEnabled(enabled)
|
|
488
|
+
if getattr(self, "btn_clipstats", None) is not None:
|
|
489
|
+
self.btn_clipstats.setEnabled(enabled)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def _clip_mode_label(self, imgf: np.ndarray) -> str:
|
|
494
|
+
# Mono image
|
|
495
|
+
if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
|
|
496
|
+
return self.tr("Mono")
|
|
497
|
+
|
|
498
|
+
# RGB image
|
|
499
|
+
luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
|
|
500
|
+
if luma_only:
|
|
501
|
+
return self.tr("Luma-only (L ≤ bp)")
|
|
502
|
+
|
|
503
|
+
linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
|
|
504
|
+
if linked:
|
|
505
|
+
return self.tr("Linked (L ≤ bp)")
|
|
506
|
+
|
|
507
|
+
return self.tr("Unlinked (any channel ≤ bp)")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _do_clip_stats(self):
|
|
511
|
+
imgf = self._get_source_float()
|
|
512
|
+
if imgf is None or imgf.size == 0:
|
|
513
|
+
self.lbl_clipstats.setText(self.tr("No image loaded."))
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
sig = float(self.sld_bp.value()) / 100.0
|
|
517
|
+
no_black_clip = bool(self.chk_no_black_clip.isChecked())
|
|
518
|
+
|
|
519
|
+
# Modes that affect how we count / threshold
|
|
520
|
+
luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
|
|
521
|
+
linked = bool(getattr(self, "chk_linked", None) and self.chk_linked.isChecked())
|
|
522
|
+
|
|
523
|
+
# Outputs we’ll fill
|
|
524
|
+
bp = None # float threshold (mono / L-based modes)
|
|
525
|
+
bp3 = None # per-channel thresholds (unlinked RGB)
|
|
526
|
+
clipped = None # [H,W] bool
|
|
527
|
+
|
|
528
|
+
# --- Compute blackpoint threshold(s) exactly like stretch.py ---
|
|
529
|
+
if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
|
|
530
|
+
mono = imgf.squeeze().astype(np.float32, copy=False)
|
|
531
|
+
if no_black_clip:
|
|
532
|
+
bp = float(mono.min())
|
|
533
|
+
else:
|
|
534
|
+
bp, _ = _compute_blackpoint_sigma(mono, sig)
|
|
535
|
+
|
|
536
|
+
clipped = (mono <= bp)
|
|
537
|
+
|
|
538
|
+
else:
|
|
539
|
+
rgb = imgf.astype(np.float32, copy=False)
|
|
540
|
+
|
|
541
|
+
if luma_only or linked:
|
|
542
|
+
# One threshold for the pixel: use luminance proxy
|
|
543
|
+
L = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
|
|
544
|
+
if no_black_clip:
|
|
545
|
+
bp = float(L.min())
|
|
546
|
+
else:
|
|
547
|
+
bp, _ = _compute_blackpoint_sigma(L, sig)
|
|
548
|
+
|
|
549
|
+
clipped = (L <= bp)
|
|
550
|
+
|
|
551
|
+
else:
|
|
552
|
+
# Unlinked: per-channel thresholds
|
|
553
|
+
if no_black_clip:
|
|
554
|
+
bp3 = np.array(
|
|
555
|
+
[float(rgb[..., 0].min()),
|
|
556
|
+
float(rgb[..., 1].min()),
|
|
557
|
+
float(rgb[..., 2].min())],
|
|
558
|
+
dtype=np.float32
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
bp3 = _compute_blackpoint_sigma_per_channel(rgb, sig).astype(np.float32, copy=False)
|
|
562
|
+
|
|
563
|
+
# Pixel considered clipped if ANY channel would clip
|
|
564
|
+
clipped = np.any(rgb <= bp3.reshape((1, 1, 3)), axis=2)
|
|
565
|
+
|
|
566
|
+
# --- Count pixels (NOT rgb elements) ---
|
|
567
|
+
clipped_count = int(np.count_nonzero(clipped))
|
|
568
|
+
total = int(clipped.size)
|
|
569
|
+
pct = 100.0 * clipped_count / max(1, total)
|
|
570
|
+
|
|
571
|
+
# --- Optional masked-area stats ---
|
|
572
|
+
masked_note = ""
|
|
573
|
+
m = self._active_mask_array()
|
|
574
|
+
if m is not None:
|
|
575
|
+
affected = (m > 0.01)
|
|
576
|
+
aff_total = int(np.count_nonzero(affected))
|
|
577
|
+
aff_clip = int(np.count_nonzero(clipped & affected))
|
|
578
|
+
aff_pct = 100.0 * aff_clip / max(1, aff_total)
|
|
579
|
+
masked_note = self.tr(f" | masked area: {aff_clip:,}/{aff_total:,} ({aff_pct:.4f}%)")
|
|
580
|
+
|
|
581
|
+
mode_lbl = self._clip_mode_label(imgf)
|
|
582
|
+
|
|
583
|
+
# --- No-black-clip message (must be mode-aware) ---
|
|
584
|
+
if no_black_clip:
|
|
585
|
+
if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
|
|
586
|
+
bp_text = self.tr(f"min={float(bp):.6f}")
|
|
587
|
+
else:
|
|
588
|
+
if luma_only or linked:
|
|
589
|
+
bp_text = self.tr(f"L min={float(bp):.6f}")
|
|
590
|
+
else:
|
|
591
|
+
# bp3 exists here
|
|
592
|
+
bp_text = self.tr(
|
|
593
|
+
f"R min={float(bp3[0]):.6f}, G min={float(bp3[1]):.6f}, B min={float(bp3[2]):.6f}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
self.lbl_clipstats.setText(
|
|
597
|
+
self.tr(f"Black clipping disabled ({mode_lbl}). Threshold={bp_text}: "
|
|
598
|
+
f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
|
|
599
|
+
)
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
# --- Normal message: show correct threshold(s) ---
|
|
603
|
+
if (imgf.ndim == 3 and imgf.shape[2] == 3) and not (luma_only or linked):
|
|
604
|
+
# Unlinked RGB: show per-channel thresholds
|
|
605
|
+
bp_disp = self.tr(
|
|
606
|
+
f"R={float(bp3[0]):.6f}, G={float(bp3[1]):.6f}, B={float(bp3[2]):.6f}"
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
# Mono or L-based: single threshold
|
|
610
|
+
bp_disp = self.tr(f"{float(bp):.6f}")
|
|
611
|
+
|
|
612
|
+
self.lbl_clipstats.setText(
|
|
613
|
+
self.tr(f"Black clip ({mode_lbl}) @ {bp_disp}: "
|
|
614
|
+
f"{clipped_count:,}/{total:,} pixels ({pct:.4f}%)") + masked_note
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _start_stretch_job(self, mode: str):
|
|
619
|
+
"""
|
|
620
|
+
mode: 'preview' or 'apply'
|
|
621
|
+
"""
|
|
622
|
+
if getattr(self, "_job_running", False):
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
self._job_running = True
|
|
626
|
+
self._job_mode = mode
|
|
627
|
+
|
|
628
|
+
self._set_controls_enabled(False)
|
|
629
|
+
self._show_busy("Statistical Stretch", "Processing…")
|
|
630
|
+
|
|
631
|
+
self._thread = QThread(self._main)
|
|
632
|
+
self._worker = _StretchWorker(self)
|
|
633
|
+
self._worker.moveToThread(self._thread)
|
|
634
|
+
|
|
635
|
+
self._thread.started.connect(self._worker.run)
|
|
636
|
+
self._worker.finished.connect(self._on_stretch_done)
|
|
637
|
+
self._worker.finished.connect(self._thread.quit)
|
|
638
|
+
self._worker.finished.connect(self._worker.deleteLater)
|
|
639
|
+
self._thread.finished.connect(self._thread.deleteLater)
|
|
640
|
+
|
|
641
|
+
self._thread.start()
|
|
642
|
+
|
|
643
|
+
|
|
153
644
|
def _get_source_float(self) -> np.ndarray:
|
|
154
645
|
"""
|
|
155
646
|
Return a float32 array scaled into ~[0..1] for stretching.
|
|
@@ -203,12 +694,43 @@ class StatisticalStretchDialog(QDialog):
|
|
|
203
694
|
self._update_preview_scaled()
|
|
204
695
|
|
|
205
696
|
def _zoom_by(self, factor: float):
|
|
206
|
-
|
|
697
|
+
vp = self.preview_scroll.viewport()
|
|
698
|
+
center = vp.rect().center()
|
|
699
|
+
self._zoom_at(factor, center)
|
|
700
|
+
|
|
701
|
+
def _zoom_at(self, factor: float, vp_pos):
|
|
702
|
+
"""Zoom keeping the image point under vp_pos (viewport coords) stationary."""
|
|
703
|
+
if self._preview_qimg is None:
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
old_scale = float(self._preview_scale)
|
|
707
|
+
|
|
708
|
+
# Content coords (in scaled-image pixels) currently under the mouse
|
|
709
|
+
hsb = self.preview_scroll.horizontalScrollBar()
|
|
710
|
+
vsb = self.preview_scroll.verticalScrollBar()
|
|
711
|
+
cx = hsb.value() + int(vp_pos.x())
|
|
712
|
+
cy = vsb.value() + int(vp_pos.y())
|
|
713
|
+
|
|
714
|
+
# Convert to image-space coords (unscaled)
|
|
715
|
+
ix = cx / old_scale
|
|
716
|
+
iy = cy / old_scale
|
|
717
|
+
|
|
718
|
+
# Apply zoom
|
|
207
719
|
self._fit_mode = False
|
|
208
|
-
new_scale =
|
|
209
|
-
self._preview_scale =
|
|
720
|
+
new_scale = max(0.05, min(old_scale * float(factor), 8.0))
|
|
721
|
+
self._preview_scale = new_scale
|
|
722
|
+
|
|
723
|
+
# Rebuild pixmap/label size
|
|
210
724
|
self._update_preview_scaled()
|
|
211
725
|
|
|
726
|
+
# New content coords for same image-space point
|
|
727
|
+
ncx = int(ix * new_scale)
|
|
728
|
+
ncy = int(iy * new_scale)
|
|
729
|
+
|
|
730
|
+
# Set scrollbars so that point stays under the mouse
|
|
731
|
+
hsb.setValue(ncx - int(vp_pos.x()))
|
|
732
|
+
vsb.setValue(ncy - int(vp_pos.y()))
|
|
733
|
+
|
|
212
734
|
|
|
213
735
|
# --- MASK helpers ----------------------------------------------------
|
|
214
736
|
def _active_mask_array(self) -> np.ndarray | None:
|
|
@@ -231,10 +753,13 @@ class StatisticalStretchDialog(QDialog):
|
|
|
231
753
|
elif m.ndim == 3: # RGB/whatever → luminance
|
|
232
754
|
m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
|
|
233
755
|
|
|
234
|
-
|
|
235
|
-
# normalize if integer
|
|
236
|
-
if
|
|
237
|
-
m
|
|
756
|
+
orig = m
|
|
757
|
+
# normalize if integer
|
|
758
|
+
if orig.dtype.kind in "ui":
|
|
759
|
+
m = orig.astype(np.float32) / float(np.iinfo(orig.dtype).max)
|
|
760
|
+
else:
|
|
761
|
+
m = orig.astype(np.float32, copy=False)
|
|
762
|
+
|
|
238
763
|
m = np.clip(m, 0.0, 1.0)
|
|
239
764
|
|
|
240
765
|
th, tw = self.doc.image.shape[:2]
|
|
@@ -265,6 +790,14 @@ class StatisticalStretchDialog(QDialog):
|
|
|
265
790
|
imgf = self._get_source_float()
|
|
266
791
|
if imgf is None:
|
|
267
792
|
return None
|
|
793
|
+
blackpoint_sigma = float(self.sld_bp.value()) / 100.0
|
|
794
|
+
hdr_on = bool(self.chk_hdr.isChecked())
|
|
795
|
+
hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
|
|
796
|
+
hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
|
|
797
|
+
luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
|
|
798
|
+
luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
|
|
799
|
+
no_black_clip = bool(self.chk_no_black_clip.isChecked())
|
|
800
|
+
luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
|
|
268
801
|
|
|
269
802
|
target = float(self.spin_target.value())
|
|
270
803
|
linked = bool(self.chk_linked.isChecked())
|
|
@@ -279,6 +812,11 @@ class StatisticalStretchDialog(QDialog):
|
|
|
279
812
|
normalize=normalize,
|
|
280
813
|
apply_curves=apply_curves,
|
|
281
814
|
curves_boost=curves_boost,
|
|
815
|
+
blackpoint_sigma=blackpoint_sigma,
|
|
816
|
+
no_black_clip=no_black_clip,
|
|
817
|
+
hdr_compress=hdr_on,
|
|
818
|
+
hdr_amount=hdr_amount,
|
|
819
|
+
hdr_knee=hdr_knee,
|
|
282
820
|
)
|
|
283
821
|
else:
|
|
284
822
|
out = stretch_color_image(
|
|
@@ -288,6 +826,14 @@ class StatisticalStretchDialog(QDialog):
|
|
|
288
826
|
normalize=normalize,
|
|
289
827
|
apply_curves=apply_curves,
|
|
290
828
|
curves_boost=curves_boost,
|
|
829
|
+
blackpoint_sigma=blackpoint_sigma,
|
|
830
|
+
no_black_clip=no_black_clip,
|
|
831
|
+
hdr_compress=hdr_on,
|
|
832
|
+
hdr_amount=hdr_amount,
|
|
833
|
+
hdr_knee=hdr_knee,
|
|
834
|
+
luma_only=luma_only,
|
|
835
|
+
luma_mode=luma_mode,
|
|
836
|
+
luma_blend=luma_blend, # <-- NEW
|
|
291
837
|
)
|
|
292
838
|
|
|
293
839
|
# ✅ If a mask is active, blend stretched result with original
|
|
@@ -341,6 +887,10 @@ class StatisticalStretchDialog(QDialog):
|
|
|
341
887
|
return
|
|
342
888
|
self.doc = doc
|
|
343
889
|
self._populate_initial_preview()
|
|
890
|
+
try:
|
|
891
|
+
self._schedule_clip_stats()
|
|
892
|
+
except Exception:
|
|
893
|
+
pass
|
|
344
894
|
|
|
345
895
|
# ----- slots -----
|
|
346
896
|
def _populate_initial_preview(self):
|
|
@@ -348,56 +898,68 @@ class StatisticalStretchDialog(QDialog):
|
|
|
348
898
|
src = self._get_source_float()
|
|
349
899
|
if src is not None:
|
|
350
900
|
self._set_preview_pixmap(np.clip(src, 0, 1))
|
|
901
|
+
try:
|
|
902
|
+
self.lbl_clipstats.setText(self.tr("Calculating clip stats…"))
|
|
903
|
+
except Exception:
|
|
904
|
+
pass
|
|
905
|
+
try:
|
|
906
|
+
self._schedule_clip_stats()
|
|
907
|
+
except Exception:
|
|
908
|
+
pass
|
|
909
|
+
|
|
351
910
|
|
|
352
911
|
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))
|
|
912
|
+
self._start_stretch_job("preview")
|
|
913
|
+
|
|
361
914
|
|
|
362
915
|
def _do_apply(self):
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
916
|
+
self._start_stretch_job("apply")
|
|
917
|
+
|
|
918
|
+
def _apply_out_to_doc(self, out: np.ndarray):
|
|
919
|
+
# Preserve mono vs color shape
|
|
920
|
+
if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
|
|
921
|
+
out = out[..., 0]
|
|
368
922
|
|
|
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
|
-
|
|
923
|
+
# --- Gather current UI state ------------------------------------
|
|
924
|
+
target = float(self.spin_target.value())
|
|
925
|
+
linked = bool(self.chk_linked.isChecked())
|
|
926
|
+
normalize = bool(self.chk_normalize.isChecked())
|
|
927
|
+
apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
|
|
928
|
+
curves_boost = float(self.sld_curves.value()) / 100.0 if getattr(self, "sld_curves", None) is not None else 0.0
|
|
929
|
+
blackpoint_sigma = float(self.sld_bp.value()) / 100.0
|
|
930
|
+
hdr_on = bool(self.chk_hdr.isChecked())
|
|
931
|
+
hdr_amount = float(self.sld_hdr_amt.value()) / 100.0
|
|
932
|
+
hdr_knee = float(self.sld_hdr_knee.value()) / 100.0
|
|
933
|
+
luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
|
|
934
|
+
luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
|
|
935
|
+
no_black_clip = bool(self.chk_no_black_clip.isChecked())
|
|
936
|
+
luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
|
|
937
|
+
|
|
938
|
+
parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
|
|
939
|
+
if normalize:
|
|
940
|
+
parts.append("norm")
|
|
941
|
+
if apply_curves:
|
|
942
|
+
parts.append(f"curves={curves_boost:.2f}")
|
|
943
|
+
if self._active_mask_array() is not None:
|
|
944
|
+
parts.append("masked")
|
|
945
|
+
parts.append(f"bpσ={blackpoint_sigma:.2f}")
|
|
946
|
+
if hdr_on and hdr_amount > 0:
|
|
947
|
+
parts.append(f"hdr={hdr_amount:.2f}@{hdr_knee:.2f}")
|
|
948
|
+
if luma_only:
|
|
949
|
+
parts.append(f"luma={luma_mode}")
|
|
950
|
+
parts.append(f"blend={luma_blend:.2f}")
|
|
951
|
+
if no_black_clip:
|
|
952
|
+
parts.append("no_black_clip")
|
|
953
|
+
|
|
954
|
+
step_name = f"Statistical Stretch ({', '.join(parts)})"
|
|
955
|
+
self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
|
|
956
|
+
|
|
957
|
+
# Turn off display stretch on the active view, if any
|
|
958
|
+
mw = self.parent()
|
|
959
|
+
if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
|
|
960
|
+
view = mw.mdi.activeSubWindow().widget()
|
|
961
|
+
if getattr(view, "autostretch_enabled", False):
|
|
962
|
+
view.set_autostretch(False)
|
|
401
963
|
|
|
402
964
|
# Existing logging, now using the same values as above
|
|
403
965
|
if hasattr(mw, "_log"):
|
|
@@ -406,6 +968,10 @@ class StatisticalStretchDialog(QDialog):
|
|
|
406
968
|
mw._log(
|
|
407
969
|
"Applied Statistical Stretch "
|
|
408
970
|
f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
|
|
971
|
+
f"bp_sigma={blackpoint_sigma:.2f}, "
|
|
972
|
+
f"hdr={'ON' if hdr_on else 'OFF'}"
|
|
973
|
+
f"{', amt='+str(round(hdr_amount,2))+' knee='+str(round(hdr_knee,2)) if hdr_on else ''}, "
|
|
974
|
+
f"luma={'ON' if luma_only else 'OFF'}{', mode='+luma_mode if luma_only else ''}, "
|
|
409
975
|
f"curves={'ON' if curves_on else 'OFF'}"
|
|
410
976
|
f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
|
|
411
977
|
f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
|
|
@@ -419,6 +985,14 @@ class StatisticalStretchDialog(QDialog):
|
|
|
419
985
|
"normalize": normalize,
|
|
420
986
|
"apply_curves": apply_curves,
|
|
421
987
|
"curves_boost": curves_boost,
|
|
988
|
+
"blackpoint_sigma": blackpoint_sigma,
|
|
989
|
+
"no_black_clip": no_black_clip,
|
|
990
|
+
"hdr_compress": hdr_on,
|
|
991
|
+
"hdr_amount": hdr_amount,
|
|
992
|
+
"hdr_knee": hdr_knee,
|
|
993
|
+
"luma_only": luma_only,
|
|
994
|
+
"luma_mode": luma_mode,
|
|
995
|
+
"luma_blend": luma_blend,
|
|
422
996
|
}
|
|
423
997
|
|
|
424
998
|
# ✅ Remember this as the last headless-style command
|
|
@@ -446,13 +1020,8 @@ class StatisticalStretchDialog(QDialog):
|
|
|
446
1020
|
# optional debug
|
|
447
1021
|
print("Statistical Stretch: replay recording suppressed for this apply()")
|
|
448
1022
|
|
|
449
|
-
|
|
450
|
-
# Update the document reference to reflect the now-active document
|
|
451
|
-
self._refresh_document_from_active()
|
|
452
|
-
|
|
1023
|
+
self.close()
|
|
453
1024
|
|
|
454
|
-
except Exception as e:
|
|
455
|
-
QMessageBox.critical(self, "Apply failed", str(e))
|
|
456
1025
|
|
|
457
1026
|
def _refresh_document_from_active(self):
|
|
458
1027
|
"""
|
|
@@ -471,6 +1040,59 @@ class StatisticalStretchDialog(QDialog):
|
|
|
471
1040
|
except Exception:
|
|
472
1041
|
pass
|
|
473
1042
|
|
|
1043
|
+
@pyqtSlot(object, str)
|
|
1044
|
+
def _on_stretch_done(self, out, err: str):
|
|
1045
|
+
# dialog might be closing; guard
|
|
1046
|
+
if sip.isdeleted(self):
|
|
1047
|
+
return
|
|
1048
|
+
|
|
1049
|
+
self._hide_busy()
|
|
1050
|
+
self._set_controls_enabled(True)
|
|
1051
|
+
self._job_running = False
|
|
1052
|
+
|
|
1053
|
+
if err:
|
|
1054
|
+
QMessageBox.warning(self, "Stretch failed", err)
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
if out is None:
|
|
1058
|
+
QMessageBox.information(self, "No image", "No image is loaded in the active document.")
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
if getattr(self, "_job_mode", "") == "preview":
|
|
1062
|
+
self._set_preview_pixmap(out)
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
# apply mode: reuse your existing apply logic, but using `out` we already computed
|
|
1066
|
+
self._apply_out_to_doc(out)
|
|
1067
|
+
|
|
1068
|
+
if getattr(self, "_pending_close", False):
|
|
1069
|
+
self._pending_close = False
|
|
1070
|
+
self.close()
|
|
1071
|
+
|
|
1072
|
+
def closeEvent(self, ev):
|
|
1073
|
+
# If a job is running, DO NOT close (WA_DeleteOnClose would delete the QThread)
|
|
1074
|
+
if getattr(self, "_job_running", False):
|
|
1075
|
+
self._pending_close = True
|
|
1076
|
+
try:
|
|
1077
|
+
self._hide_busy()
|
|
1078
|
+
except Exception:
|
|
1079
|
+
pass
|
|
1080
|
+
try:
|
|
1081
|
+
self.hide()
|
|
1082
|
+
except Exception:
|
|
1083
|
+
pass
|
|
1084
|
+
ev.ignore()
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
# disconnect follow behavior
|
|
1088
|
+
try:
|
|
1089
|
+
if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
|
|
1090
|
+
self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
1091
|
+
except Exception:
|
|
1092
|
+
pass
|
|
1093
|
+
|
|
1094
|
+
super().closeEvent(ev)
|
|
1095
|
+
|
|
474
1096
|
|
|
475
1097
|
def _update_preview_scaled(self):
|
|
476
1098
|
if self._preview_qimg is None:
|
|
@@ -493,30 +1115,24 @@ class StatisticalStretchDialog(QDialog):
|
|
|
493
1115
|
|
|
494
1116
|
def eventFilter(self, obj, ev):
|
|
495
1117
|
# Ctrl+wheel zoom
|
|
496
|
-
if ev.type() == QEvent.Type.Wheel and
|
|
1118
|
+
if ev.type() == QEvent.Type.Wheel and obj is self.preview_scroll.viewport():
|
|
497
1119
|
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()
|
|
1120
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else (1/1.25)
|
|
1121
|
+
self._zoom_at(factor, ev.position())
|
|
502
1122
|
return True
|
|
503
1123
|
return False
|
|
504
1124
|
|
|
505
1125
|
# Click+drag pan (left or middle mouse)
|
|
506
|
-
if obj is self.preview_scroll.viewport()
|
|
1126
|
+
if obj is self.preview_scroll.viewport():
|
|
507
1127
|
if ev.type() == QEvent.Type.MouseButtonPress:
|
|
508
|
-
if ev.
|
|
1128
|
+
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
|
|
509
1129
|
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)
|
|
1130
|
+
self._pan_last = ev.globalPosition().toPoint()
|
|
1131
|
+
self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
516
1132
|
return True
|
|
517
1133
|
|
|
518
1134
|
elif ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
519
|
-
pos = ev.
|
|
1135
|
+
pos = ev.globalPosition().toPoint()
|
|
520
1136
|
delta = pos - self._pan_last
|
|
521
1137
|
self._pan_last = pos
|
|
522
1138
|
|
|
@@ -527,12 +1143,11 @@ class StatisticalStretchDialog(QDialog):
|
|
|
527
1143
|
return True
|
|
528
1144
|
|
|
529
1145
|
elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return True
|
|
1146
|
+
if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
|
|
1147
|
+
self._panning = False
|
|
1148
|
+
self._pan_last = None
|
|
1149
|
+
self.preview_scroll.viewport().unsetCursor()
|
|
1150
|
+
return True
|
|
536
1151
|
|
|
537
1152
|
return super().eventFilter(obj, ev)
|
|
538
1153
|
|