setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
|
@@ -14,6 +14,65 @@ try:
|
|
|
14
14
|
except Exception:
|
|
15
15
|
pywt = None
|
|
16
16
|
|
|
17
|
+
# CUDA detection - check if OpenCV was built with CUDA support
|
|
18
|
+
_CUDA_AVAILABLE = False
|
|
19
|
+
_CUDA_DEVICE_NAME = "N/A"
|
|
20
|
+
_CUDA_INITIALIZED = False
|
|
21
|
+
_CUDA_DENOISE_INITIALIZED = False # Track if NLM denoise has been run once
|
|
22
|
+
|
|
23
|
+
def _warmup_cuda():
|
|
24
|
+
"""Initialize CUDA context and key functions with dummy operations to avoid first-call crashes."""
|
|
25
|
+
global _CUDA_INITIALIZED
|
|
26
|
+
if _CUDA_INITIALIZED or not _CUDA_AVAILABLE:
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
# Basic CUDA context initialization
|
|
30
|
+
dummy = np.zeros((64, 64, 3), dtype=np.uint8)
|
|
31
|
+
gpu_mat = cv2.cuda_GpuMat()
|
|
32
|
+
gpu_mat.upload(dummy)
|
|
33
|
+
_ = gpu_mat.download()
|
|
34
|
+
|
|
35
|
+
# Warm up Gaussian filter
|
|
36
|
+
try:
|
|
37
|
+
gauss_filter = cv2.cuda.createGaussianFilter(cv2.CV_8UC3, -1, (5, 5), 1.0)
|
|
38
|
+
gpu_mat.upload(dummy)
|
|
39
|
+
gpu_result = gauss_filter.apply(gpu_mat)
|
|
40
|
+
_ = gpu_result.download()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Note: We skip NLM warm-up here because the first real denoise
|
|
45
|
+
# must run on the main thread to properly initialize CUDA for that operation.
|
|
46
|
+
# The warm-up for basic CUDA ops (upload/download, Gaussian) is still done above.
|
|
47
|
+
|
|
48
|
+
# Synchronize to ensure all GPU operations complete
|
|
49
|
+
try:
|
|
50
|
+
cv2.cuda.Stream.Null().waitForCompletion()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
_CUDA_INITIALIZED = True
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
if cv2 is not None:
|
|
60
|
+
_cuda_count = cv2.cuda.getCudaEnabledDeviceCount()
|
|
61
|
+
if _cuda_count > 0:
|
|
62
|
+
_CUDA_AVAILABLE = True
|
|
63
|
+
try:
|
|
64
|
+
_device_info = cv2.cuda.DeviceInfo(cv2.cuda.getDevice())
|
|
65
|
+
_CUDA_DEVICE_NAME = _device_info.name()
|
|
66
|
+
except Exception:
|
|
67
|
+
_CUDA_DEVICE_NAME = f"{_cuda_count} device(s)"
|
|
68
|
+
# Warm up CUDA immediately (basic ops only, NLM done on first use)
|
|
69
|
+
_warmup_cuda()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Minimum image size (pixels) to benefit from CUDA - smaller images have too much transfer overhead
|
|
74
|
+
_CUDA_MIN_PIXELS = 500_000 # ~700x700 or larger
|
|
75
|
+
|
|
17
76
|
from PyQt6.QtCore import (
|
|
18
77
|
Qt, QSize, QPoint, QEvent, QThread, pyqtSignal, QTimer
|
|
19
78
|
)
|
|
@@ -35,55 +94,210 @@ class FrequencySeperationThread(QThread):
|
|
|
35
94
|
separation_done = pyqtSignal(np.ndarray, np.ndarray)
|
|
36
95
|
error_signal = pyqtSignal(str)
|
|
37
96
|
|
|
38
|
-
def __init__(self, image: np.ndarray, method='Gaussian', radius=10.0, tolerance=50,
|
|
97
|
+
def __init__(self, image: np.ndarray, method='Gaussian', radius=10.0, tolerance=50,
|
|
98
|
+
use_cuda=True, parent=None):
|
|
39
99
|
super().__init__(parent)
|
|
40
100
|
self.image = image.astype(np.float32, copy=False)
|
|
41
101
|
self.method = method
|
|
42
102
|
self.radius = float(radius)
|
|
43
103
|
self.tolerance = int(tolerance)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
# Use CUDA if requested, available, and image is large enough
|
|
105
|
+
h, w = self.image.shape[:2]
|
|
106
|
+
self.use_cuda = (use_cuda and _CUDA_AVAILABLE and (h * w >= _CUDA_MIN_PIXELS))
|
|
107
|
+
|
|
48
108
|
def run(self):
|
|
49
109
|
try:
|
|
50
|
-
if self.
|
|
51
|
-
|
|
52
|
-
raise RuntimeError("OpenCV (cv2) is required for color frequency separation.")
|
|
53
|
-
bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
|
|
110
|
+
if self.use_cuda:
|
|
111
|
+
low_rgb, high_rgb = self._run_cuda()
|
|
54
112
|
else:
|
|
55
|
-
|
|
113
|
+
low_rgb, high_rgb = self._run_cpu()
|
|
114
|
+
self.separation_done.emit(low_rgb.astype(np.float32), high_rgb.astype(np.float32))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self.error_signal.emit(str(e))
|
|
56
117
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
def _run_cpu(self):
|
|
119
|
+
"""Original CPU implementation."""
|
|
120
|
+
if cv2 is None:
|
|
121
|
+
raise RuntimeError("OpenCV (cv2) is required for frequency separation.")
|
|
122
|
+
|
|
123
|
+
if self.image.ndim == 3 and self.image.shape[2] == 3:
|
|
124
|
+
bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
|
|
125
|
+
else:
|
|
126
|
+
bgr = self.image.copy()
|
|
127
|
+
|
|
128
|
+
if self.method == 'Gaussian':
|
|
129
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
130
|
+
elif self.method == 'Median':
|
|
131
|
+
ksize = max(1, int(self.radius) // 2 * 2 + 1)
|
|
132
|
+
low_bgr = cv2.medianBlur(bgr, ksize)
|
|
133
|
+
elif self.method == 'Bilateral':
|
|
134
|
+
sigma = 50.0 * (self.tolerance / 100.0)
|
|
135
|
+
d = max(1, int(self.radius))
|
|
136
|
+
low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
|
|
137
|
+
else:
|
|
138
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
139
|
+
|
|
140
|
+
if low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
|
|
141
|
+
low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
|
|
142
|
+
else:
|
|
143
|
+
low_rgb = low_bgr
|
|
144
|
+
|
|
145
|
+
high_rgb = self.image - low_rgb
|
|
146
|
+
return low_rgb, high_rgb
|
|
147
|
+
|
|
148
|
+
def _run_cuda(self):
|
|
149
|
+
"""GPU-accelerated separation using CUDA."""
|
|
150
|
+
if cv2 is None:
|
|
151
|
+
raise RuntimeError("OpenCV (cv2) is required for frequency separation.")
|
|
152
|
+
|
|
153
|
+
# CUDA has a max kernel size of 31 (must be odd and <= 32)
|
|
154
|
+
# For Gaussian, kernel size is typically 6*sigma, so max sigma ~5
|
|
155
|
+
# For larger radii, we need to use iterative application or CPU fallback
|
|
156
|
+
CUDA_MAX_KSIZE = 31
|
|
157
|
+
|
|
158
|
+
# Prepare image in BGR format for OpenCV
|
|
159
|
+
if self.image.ndim == 3 and self.image.shape[2] == 3:
|
|
160
|
+
bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
|
|
161
|
+
else:
|
|
162
|
+
bgr = self.image.copy()
|
|
163
|
+
|
|
164
|
+
if self.method == 'Gaussian':
|
|
165
|
+
# Calculate ideal kernel size
|
|
166
|
+
ideal_ksize = int(self.radius * 6) | 1 # ensure odd
|
|
167
|
+
|
|
168
|
+
if ideal_ksize <= CUDA_MAX_KSIZE:
|
|
169
|
+
# Single-pass CUDA Gaussian
|
|
170
|
+
gpu_img = cv2.cuda_GpuMat()
|
|
171
|
+
gpu_img.upload(bgr)
|
|
172
|
+
gauss_filter = cv2.cuda.createGaussianFilter(
|
|
173
|
+
gpu_img.type(), -1, (ideal_ksize, ideal_ksize), self.radius
|
|
174
|
+
)
|
|
175
|
+
gpu_blurred = gauss_filter.apply(gpu_img)
|
|
176
|
+
low_bgr = gpu_blurred.download()
|
|
72
177
|
else:
|
|
73
|
-
# fallback
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
178
|
+
# For large radii: use iterative CUDA passes or CPU fallback
|
|
179
|
+
# Iterative Gaussian: applying sigma N times ≈ sigma * sqrt(N)
|
|
180
|
+
# So for target sigma, we need N passes of sigma/sqrt(N)
|
|
181
|
+
# We'll use multiple passes with max allowed kernel
|
|
182
|
+
|
|
183
|
+
# Calculate number of passes needed
|
|
184
|
+
max_sigma_per_pass = (CUDA_MAX_KSIZE - 1) / 6.0 # ~5.0
|
|
185
|
+
num_passes = max(1, int(np.ceil((self.radius / max_sigma_per_pass) ** 2)))
|
|
186
|
+
sigma_per_pass = self.radius / np.sqrt(num_passes)
|
|
187
|
+
ksize_per_pass = max(3, int(sigma_per_pass * 6) | 1)
|
|
188
|
+
ksize_per_pass = min(ksize_per_pass, CUDA_MAX_KSIZE)
|
|
189
|
+
|
|
190
|
+
if num_passes <= 25: # Allow up to 25 passes for radius up to ~25
|
|
191
|
+
gpu_img = cv2.cuda_GpuMat()
|
|
192
|
+
gpu_img.upload(bgr)
|
|
193
|
+
gauss_filter = cv2.cuda.createGaussianFilter(
|
|
194
|
+
gpu_img.type(), -1, (ksize_per_pass, ksize_per_pass), sigma_per_pass
|
|
195
|
+
)
|
|
196
|
+
for _ in range(num_passes):
|
|
197
|
+
gpu_img = gauss_filter.apply(gpu_img)
|
|
198
|
+
low_bgr = gpu_img.download()
|
|
199
|
+
else:
|
|
200
|
+
# Too many passes needed, fall back to CPU
|
|
201
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
202
|
+
|
|
203
|
+
elif self.method == 'Bilateral':
|
|
204
|
+
sigma = 50.0 * (self.tolerance / 100.0)
|
|
205
|
+
d = max(1, int(self.radius))
|
|
206
|
+
|
|
207
|
+
# CUDA bilateral requires 8-bit input
|
|
208
|
+
bgr_u8 = (np.clip(bgr, 0, 1) * 255).astype(np.uint8)
|
|
77
209
|
|
|
78
|
-
if
|
|
79
|
-
|
|
210
|
+
if d <= CUDA_MAX_KSIZE:
|
|
211
|
+
# Single-pass CUDA bilateral
|
|
212
|
+
gpu_u8 = cv2.cuda_GpuMat()
|
|
213
|
+
gpu_u8.upload(bgr_u8)
|
|
214
|
+
try:
|
|
215
|
+
gpu_blurred = cv2.cuda.bilateralFilter(gpu_u8, d, sigma, sigma)
|
|
216
|
+
low_bgr_u8 = gpu_blurred.download()
|
|
217
|
+
low_bgr = low_bgr_u8.astype(np.float32) / 255.0
|
|
218
|
+
except Exception:
|
|
219
|
+
low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
|
|
80
220
|
else:
|
|
81
|
-
|
|
221
|
+
# Iterative bilateral for large radius
|
|
222
|
+
# Use multiple passes with smaller d to approximate larger bilateral
|
|
223
|
+
num_passes = max(1, (d + CUDA_MAX_KSIZE - 1) // CUDA_MAX_KSIZE)
|
|
224
|
+
d_per_pass = min(CUDA_MAX_KSIZE, max(5, d // num_passes) | 1) # ensure odd
|
|
225
|
+
sigma_per_pass = sigma / np.sqrt(num_passes) # reduce sigma per pass
|
|
226
|
+
|
|
227
|
+
if num_passes <= 10: # Allow up to 10 passes
|
|
228
|
+
gpu_u8 = cv2.cuda_GpuMat()
|
|
229
|
+
gpu_u8.upload(bgr_u8)
|
|
230
|
+
try:
|
|
231
|
+
for _ in range(num_passes):
|
|
232
|
+
gpu_u8 = cv2.cuda.bilateralFilter(gpu_u8, d_per_pass, sigma_per_pass, sigma_per_pass)
|
|
233
|
+
low_bgr_u8 = gpu_u8.download()
|
|
234
|
+
low_bgr = low_bgr_u8.astype(np.float32) / 255.0
|
|
235
|
+
except Exception:
|
|
236
|
+
low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
|
|
237
|
+
else:
|
|
238
|
+
# Too many passes, fall back to CPU
|
|
239
|
+
low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
|
|
82
240
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
241
|
+
elif self.method == 'Median':
|
|
242
|
+
ksize = max(1, int(self.radius) // 2 * 2 + 1) # ensure odd
|
|
243
|
+
|
|
244
|
+
# Median requires 8-bit input
|
|
245
|
+
bgr_u8 = (np.clip(bgr, 0, 1) * 255).astype(np.uint8)
|
|
246
|
+
|
|
247
|
+
if ksize <= CUDA_MAX_KSIZE:
|
|
248
|
+
# Single-pass CUDA median
|
|
249
|
+
gpu_u8 = cv2.cuda_GpuMat()
|
|
250
|
+
gpu_u8.upload(bgr_u8)
|
|
251
|
+
try:
|
|
252
|
+
median_filter = cv2.cuda.createMedianFilter(gpu_u8.type(), ksize)
|
|
253
|
+
gpu_blurred = median_filter.apply(gpu_u8)
|
|
254
|
+
low_bgr_u8 = gpu_blurred.download()
|
|
255
|
+
low_bgr = low_bgr_u8.astype(np.float32) / 255.0
|
|
256
|
+
except (AttributeError, cv2.error):
|
|
257
|
+
low_bgr = cv2.medianBlur(bgr, ksize)
|
|
258
|
+
else:
|
|
259
|
+
# Iterative median for large radius
|
|
260
|
+
# Multiple passes with smaller kernel approximate larger kernel
|
|
261
|
+
num_passes = max(1, (ksize + CUDA_MAX_KSIZE - 1) // CUDA_MAX_KSIZE)
|
|
262
|
+
ksize_per_pass = min(CUDA_MAX_KSIZE, max(3, ksize // num_passes) | 1) # ensure odd
|
|
263
|
+
|
|
264
|
+
if num_passes <= 10: # Allow up to 10 passes for larger radii
|
|
265
|
+
gpu_u8 = cv2.cuda_GpuMat()
|
|
266
|
+
gpu_u8.upload(bgr_u8)
|
|
267
|
+
try:
|
|
268
|
+
median_filter = cv2.cuda.createMedianFilter(gpu_u8.type(), ksize_per_pass)
|
|
269
|
+
for _ in range(num_passes):
|
|
270
|
+
gpu_u8 = median_filter.apply(gpu_u8)
|
|
271
|
+
low_bgr_u8 = gpu_u8.download()
|
|
272
|
+
low_bgr = low_bgr_u8.astype(np.float32) / 255.0
|
|
273
|
+
except (AttributeError, cv2.error):
|
|
274
|
+
low_bgr = cv2.medianBlur(bgr, ksize)
|
|
275
|
+
else:
|
|
276
|
+
# Too many passes, fall back to CPU
|
|
277
|
+
low_bgr = cv2.medianBlur(bgr, ksize)
|
|
278
|
+
|
|
279
|
+
else:
|
|
280
|
+
# Fallback to Gaussian (same logic as above)
|
|
281
|
+
ideal_ksize = int(self.radius * 6) | 1
|
|
282
|
+
if ideal_ksize <= CUDA_MAX_KSIZE:
|
|
283
|
+
gpu_img = cv2.cuda_GpuMat()
|
|
284
|
+
gpu_img.upload(bgr)
|
|
285
|
+
gauss_filter = cv2.cuda.createGaussianFilter(
|
|
286
|
+
gpu_img.type(), -1, (ideal_ksize, ideal_ksize), self.radius
|
|
287
|
+
)
|
|
288
|
+
gpu_blurred = gauss_filter.apply(gpu_img)
|
|
289
|
+
low_bgr = gpu_blurred.download()
|
|
290
|
+
else:
|
|
291
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
292
|
+
|
|
293
|
+
# Convert back to RGB
|
|
294
|
+
if low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
|
|
295
|
+
low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
|
|
296
|
+
else:
|
|
297
|
+
low_rgb = low_bgr
|
|
298
|
+
|
|
299
|
+
high_rgb = self.image - low_rgb
|
|
300
|
+
return low_rgb, high_rgb
|
|
87
301
|
|
|
88
302
|
|
|
89
303
|
class HFEnhancementThread(QThread):
|
|
@@ -101,6 +315,7 @@ class HFEnhancementThread(QThread):
|
|
|
101
315
|
wavelet_name='db2',
|
|
102
316
|
enable_denoise=False,
|
|
103
317
|
denoise_strength=3.0,
|
|
318
|
+
use_cuda=True,
|
|
104
319
|
parent=None
|
|
105
320
|
):
|
|
106
321
|
super().__init__(parent)
|
|
@@ -113,28 +328,50 @@ class HFEnhancementThread(QThread):
|
|
|
113
328
|
self.wavelet_name = str(wavelet_name)
|
|
114
329
|
self.enable_denoise = bool(enable_denoise)
|
|
115
330
|
self.denoise_strength = float(denoise_strength)
|
|
331
|
+
# Use CUDA if requested, available, and image is large enough
|
|
332
|
+
h, w = self.hf_image.shape[:2]
|
|
333
|
+
self.use_cuda = (use_cuda and _CUDA_AVAILABLE and (h * w >= _CUDA_MIN_PIXELS))
|
|
116
334
|
|
|
117
335
|
def run(self):
|
|
118
336
|
try:
|
|
119
337
|
out = self.hf_image.copy()
|
|
120
338
|
|
|
121
339
|
if self.enable_scale:
|
|
122
|
-
|
|
340
|
+
if self.use_cuda and cv2 is not None:
|
|
341
|
+
out = self._scale_cuda(out, self.sharpen_scale)
|
|
342
|
+
else:
|
|
343
|
+
out *= self.sharpen_scale
|
|
123
344
|
|
|
124
345
|
if self.enable_wavelet:
|
|
125
346
|
if pywt is None:
|
|
126
347
|
raise RuntimeError("PyWavelets (pywt) is required for wavelet sharpening.")
|
|
348
|
+
# Note: PyWavelets is CPU-only, no CUDA support available
|
|
127
349
|
out = self._wavelet_sharpen(out, self.wavelet_name, self.wavelet_level, self.wavelet_boost)
|
|
128
350
|
|
|
129
351
|
if self.enable_denoise:
|
|
130
352
|
if cv2 is None:
|
|
131
353
|
raise RuntimeError("OpenCV (cv2) is required for HF denoise.")
|
|
132
|
-
|
|
354
|
+
if self.use_cuda:
|
|
355
|
+
out = self._denoise_hf_cuda(out, self.denoise_strength)
|
|
356
|
+
else:
|
|
357
|
+
out = self._denoise_hf_cpu(out, self.denoise_strength)
|
|
133
358
|
|
|
134
359
|
self.enhancement_done.emit(out.astype(np.float32))
|
|
135
360
|
except Exception as e:
|
|
136
361
|
self.error_signal.emit(str(e))
|
|
137
362
|
|
|
363
|
+
def _scale_cuda(self, img, scale):
|
|
364
|
+
"""GPU-accelerated scaling using CUDA."""
|
|
365
|
+
try:
|
|
366
|
+
gpu_img = cv2.cuda_GpuMat()
|
|
367
|
+
gpu_img.upload(img.astype(np.float32))
|
|
368
|
+
# Use multiply with scalar
|
|
369
|
+
gpu_scaled = cv2.cuda.multiply(gpu_img, scale)
|
|
370
|
+
return gpu_scaled.download()
|
|
371
|
+
except Exception:
|
|
372
|
+
# Fallback to CPU
|
|
373
|
+
return img * scale
|
|
374
|
+
|
|
138
375
|
def _wavelet_sharpen(self, img, wavelet='db2', level=2, boost=1.2):
|
|
139
376
|
if img.ndim == 3 and img.shape[2] == 3:
|
|
140
377
|
chs = []
|
|
@@ -145,19 +382,30 @@ class HFEnhancementThread(QThread):
|
|
|
145
382
|
return self._wavelet_sharpen_mono(img, wavelet, level, boost)
|
|
146
383
|
|
|
147
384
|
def _wavelet_sharpen_mono(self, mono, wavelet, level, boost):
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
385
|
+
h, w = mono.shape[:2]
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Let pywt determine the max level automatically if needed
|
|
389
|
+
max_level = pywt.dwt_max_level(min(h, w), wavelet)
|
|
390
|
+
safe_level = min(level, max_level) if max_level > 0 else level
|
|
391
|
+
|
|
392
|
+
coeffs = pywt.wavedec2(mono, wavelet=wavelet, level=safe_level, mode='periodization')
|
|
393
|
+
new_coeffs = [coeffs[0]]
|
|
394
|
+
for (cH, cV, cD) in coeffs[1:]:
|
|
395
|
+
new_coeffs.append((cH * boost, cV * boost, cD * boost))
|
|
396
|
+
rec = pywt.waverec2(new_coeffs, wavelet=wavelet, mode='periodization')
|
|
397
|
+
|
|
398
|
+
# shape guard
|
|
399
|
+
if rec.shape != mono.shape:
|
|
400
|
+
rec = rec[:h, :w]
|
|
401
|
+
|
|
402
|
+
return rec.astype(np.float32)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
# If wavelet processing fails, return original
|
|
405
|
+
return mono.astype(np.float32)
|
|
406
|
+
|
|
407
|
+
def _denoise_hf_cpu(self, hf, strength=3.0):
|
|
408
|
+
"""CPU-based NLM denoising."""
|
|
161
409
|
# Shift to [0..1], denoise, shift back.
|
|
162
410
|
if hf.ndim == 3 and hf.shape[2] == 3:
|
|
163
411
|
bgr = hf[..., ::-1] # RGB->BGR
|
|
@@ -172,6 +420,112 @@ class HFEnhancementThread(QThread):
|
|
|
172
420
|
den = cv2.fastNlMeansDenoising(u8, None, strength, 7, 21)
|
|
173
421
|
return den.astype(np.float32) / 255.0 - 0.5
|
|
174
422
|
|
|
423
|
+
def _denoise_hf_cuda(self, hf, strength=3.0):
|
|
424
|
+
"""GPU-accelerated NLM denoising using CUDA."""
|
|
425
|
+
# Check if CUDA denoising functions are available
|
|
426
|
+
has_cuda_denoise = False
|
|
427
|
+
has_cuda_denoise_color = False
|
|
428
|
+
|
|
429
|
+
if hasattr(cv2, 'cuda'):
|
|
430
|
+
has_cuda_denoise = hasattr(cv2.cuda, 'fastNlMeansDenoising')
|
|
431
|
+
has_cuda_denoise_color = hasattr(cv2.cuda, 'fastNlMeansDenoisingColored')
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
if hf.ndim == 3 and hf.shape[2] == 3:
|
|
436
|
+
if not has_cuda_denoise_color:
|
|
437
|
+
return self._denoise_hf_cpu(hf, strength)
|
|
438
|
+
bgr = hf[..., ::-1] # RGB->BGR
|
|
439
|
+
tmp = np.clip(bgr + 0.5, 0, 1)
|
|
440
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
441
|
+
|
|
442
|
+
# Ensure contiguous array
|
|
443
|
+
u8 = np.ascontiguousarray(u8)
|
|
444
|
+
|
|
445
|
+
# Upload to GPU - use direct upload method
|
|
446
|
+
gpu_src = cv2.cuda_GpuMat(u8.shape[0], u8.shape[1], cv2.CV_8UC3)
|
|
447
|
+
gpu_src.upload(u8)
|
|
448
|
+
|
|
449
|
+
# Create destination GpuMat with same size/type
|
|
450
|
+
gpu_dst = cv2.cuda_GpuMat(u8.shape[0], u8.shape[1], cv2.CV_8UC3)
|
|
451
|
+
|
|
452
|
+
# CUDA NLM denoising for color images
|
|
453
|
+
# API: fastNlMeansDenoisingColored(src, h_luminance, photo_render[, dst[, search_window[, block_size[, stream]]]]) -> dst
|
|
454
|
+
gpu_dst = cv2.cuda.fastNlMeansDenoisingColored(
|
|
455
|
+
gpu_src, # src
|
|
456
|
+
strength, # h_luminance
|
|
457
|
+
strength, # photo_render
|
|
458
|
+
None, # dst (None = auto-allocate)
|
|
459
|
+
21, # search_window
|
|
460
|
+
7 # block_size
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Ensure GPU operations complete before downloading
|
|
464
|
+
try:
|
|
465
|
+
cv2.cuda.Stream.Null().waitForCompletion()
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
# Download result and convert back to float RGB
|
|
470
|
+
den = gpu_dst.download()
|
|
471
|
+
|
|
472
|
+
# Make a complete copy to ensure no GPU memory references remain
|
|
473
|
+
den = np.array(den, dtype=np.uint8, copy=True)
|
|
474
|
+
f32 = den.astype(np.float32) / 255.0 - 0.5
|
|
475
|
+
result = np.array(f32[..., ::-1], dtype=np.float32, copy=True) # BGR to RGB
|
|
476
|
+
|
|
477
|
+
# Clean up GPU memory explicitly
|
|
478
|
+
del gpu_dst, gpu_src
|
|
479
|
+
|
|
480
|
+
return result
|
|
481
|
+
else:
|
|
482
|
+
if not has_cuda_denoise:
|
|
483
|
+
return self._denoise_hf_cpu(hf, strength)
|
|
484
|
+
tmp = np.clip(hf + 0.5, 0, 1)
|
|
485
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
486
|
+
|
|
487
|
+
# Ensure contiguous array
|
|
488
|
+
u8 = np.ascontiguousarray(u8)
|
|
489
|
+
|
|
490
|
+
# Upload to GPU
|
|
491
|
+
gpu_src = cv2.cuda_GpuMat(u8.shape[0], u8.shape[1], cv2.CV_8UC1)
|
|
492
|
+
gpu_src.upload(u8)
|
|
493
|
+
|
|
494
|
+
# Create destination GpuMat with same size/type
|
|
495
|
+
gpu_dst = cv2.cuda_GpuMat(u8.shape[0], u8.shape[1], cv2.CV_8UC1)
|
|
496
|
+
|
|
497
|
+
# CUDA NLM denoising for grayscale
|
|
498
|
+
# API: fastNlMeansDenoising(src, h[, dst[, search_window[, block_size[, stream]]]]) -> dst
|
|
499
|
+
gpu_dst = cv2.cuda.fastNlMeansDenoising(
|
|
500
|
+
gpu_src, # src
|
|
501
|
+
strength, # h
|
|
502
|
+
None, # dst (None = auto-allocate)
|
|
503
|
+
21, # search_window
|
|
504
|
+
7 # block_size
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Ensure GPU operations complete before downloading
|
|
508
|
+
try:
|
|
509
|
+
cv2.cuda.Stream.Null().waitForCompletion()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
# Download result and convert back to float
|
|
514
|
+
den = gpu_dst.download()
|
|
515
|
+
|
|
516
|
+
# Make a complete copy to ensure no GPU memory references remain
|
|
517
|
+
den = np.array(den, dtype=np.uint8, copy=True)
|
|
518
|
+
result = np.array(den.astype(np.float32) / 255.0 - 0.5, dtype=np.float32, copy=True)
|
|
519
|
+
|
|
520
|
+
# Clean up GPU memory explicitly
|
|
521
|
+
del gpu_dst, gpu_src
|
|
522
|
+
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
except Exception as e:
|
|
526
|
+
# Fallback to CPU if CUDA denoising fails
|
|
527
|
+
return self._denoise_hf_cpu(hf, strength)
|
|
528
|
+
|
|
175
529
|
|
|
176
530
|
# ---------------------------- Widget ----------------------------
|
|
177
531
|
|
|
@@ -202,6 +556,25 @@ class FrequencySeperationTab(QWidget):
|
|
|
202
556
|
self._last_pos: QPoint | None = None
|
|
203
557
|
self._sync_guard = False
|
|
204
558
|
self._hf_history: list[np.ndarray] = []
|
|
559
|
+
self._split_history: list[tuple[np.ndarray, np.ndarray]] = [] # (LF, HF) pairs
|
|
560
|
+
|
|
561
|
+
# Combined preview state
|
|
562
|
+
self._show_combined = False
|
|
563
|
+
self._combined_cache: np.ndarray | None = None
|
|
564
|
+
|
|
565
|
+
# Pixmap caching for performance
|
|
566
|
+
self._lf_pixmap_cache: QPixmap | None = None
|
|
567
|
+
self._hf_pixmap_cache: QPixmap | None = None
|
|
568
|
+
self._combined_pixmap_cache: QPixmap | None = None
|
|
569
|
+
self._lf_dirty = True
|
|
570
|
+
self._hf_dirty = True
|
|
571
|
+
self._combined_dirty = True
|
|
572
|
+
self._last_zoom = 1.0
|
|
573
|
+
# Scaled pixmap cache (avoid rescaling on pan)
|
|
574
|
+
self._lf_scaled_cache: QPixmap | None = None
|
|
575
|
+
self._hf_scaled_cache: QPixmap | None = None
|
|
576
|
+
self._combined_scaled_cache: QPixmap | None = None
|
|
577
|
+
self._scaled_zoom = 1.0 # zoom level at which scaled caches were created
|
|
205
578
|
|
|
206
579
|
# parameters
|
|
207
580
|
self.method = 'Gaussian'
|
|
@@ -215,17 +588,21 @@ class FrequencySeperationTab(QWidget):
|
|
|
215
588
|
self.enable_denoise = False
|
|
216
589
|
self.denoise_strength = 3.0
|
|
217
590
|
|
|
591
|
+
# GPU acceleration - default to GPU if available
|
|
592
|
+
self.use_gpu = _CUDA_AVAILABLE
|
|
593
|
+
|
|
218
594
|
self.proc_thread: FrequencySeperationThread | None = None
|
|
219
595
|
self.hf_thread: HFEnhancementThread | None = None
|
|
220
|
-
self.
|
|
596
|
+
self._source_doc = None # Track the source document separately
|
|
597
|
+
self._cuda_warmed_up = False
|
|
221
598
|
self._build_ui()
|
|
222
599
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
600
|
+
def showEvent(self, event):
|
|
601
|
+
"""Warm up CUDA on first show to avoid initialization delays during processing."""
|
|
602
|
+
super().showEvent(event)
|
|
603
|
+
if not self._cuda_warmed_up and self.use_gpu:
|
|
604
|
+
_warmup_cuda()
|
|
605
|
+
self._cuda_warmed_up = True
|
|
229
606
|
|
|
230
607
|
# ---------------- UI ----------------
|
|
231
608
|
def _build_ui(self):
|
|
@@ -236,13 +613,56 @@ class FrequencySeperationTab(QWidget):
|
|
|
236
613
|
left = QVBoxLayout()
|
|
237
614
|
left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(280)
|
|
238
615
|
|
|
239
|
-
self.fileLabel = QLabel("", self)
|
|
616
|
+
self.fileLabel = QLabel("(No image loaded)", self)
|
|
240
617
|
left.addWidget(self.fileLabel)
|
|
241
618
|
|
|
619
|
+
# Load Source button
|
|
620
|
+
self.btn_load_source = QPushButton(self.tr("Load Source Image"), self)
|
|
621
|
+
self.btn_load_source.setToolTip(
|
|
622
|
+
"Select an open view to use as the source image.\n"
|
|
623
|
+
"This image will be split into LF and HF components."
|
|
624
|
+
)
|
|
625
|
+
self.btn_load_source.clicked.connect(self._load_source_from_view)
|
|
626
|
+
left.addWidget(self.btn_load_source)
|
|
627
|
+
|
|
628
|
+
# GPU acceleration toggle
|
|
629
|
+
self.cb_use_gpu = QCheckBox(self.tr("Use GPU Acceleration"), self)
|
|
630
|
+
self.cb_use_gpu.setChecked(self.use_gpu)
|
|
631
|
+
self.cb_use_gpu.setEnabled(_CUDA_AVAILABLE)
|
|
632
|
+
if _CUDA_AVAILABLE:
|
|
633
|
+
self.cb_use_gpu.setToolTip(
|
|
634
|
+
f"Enable CUDA GPU acceleration for faster processing.\n"
|
|
635
|
+
f"GPU detected: {_CUDA_DEVICE_NAME}\n\n"
|
|
636
|
+
f"GPU-accelerated operations:\n"
|
|
637
|
+
f" • LF/HF Split: Gaussian, Bilateral, Median blur\n"
|
|
638
|
+
f" • HF Scale: Multiplication\n"
|
|
639
|
+
f" • HF Denoise: Non-Local Means\n\n"
|
|
640
|
+
f"CPU-only (no GPU support):\n"
|
|
641
|
+
f" • Wavelet Sharpening (PyWavelets library)\n\n"
|
|
642
|
+
f"Uncheck to force CPU-only processing."
|
|
643
|
+
)
|
|
644
|
+
else:
|
|
645
|
+
self.cb_use_gpu.setToolTip(
|
|
646
|
+
"GPU acceleration not available.\n"
|
|
647
|
+
"OpenCV was not built with CUDA support,\n"
|
|
648
|
+
"or no CUDA-capable GPU was detected.\n\n"
|
|
649
|
+
"To enable GPU: reinstall OpenCV with CUDA support."
|
|
650
|
+
)
|
|
651
|
+
self.cb_use_gpu.toggled.connect(self._on_gpu_toggled)
|
|
652
|
+
left.addWidget(self.cb_use_gpu)
|
|
653
|
+
|
|
654
|
+
left.addSpacing(10)
|
|
655
|
+
|
|
242
656
|
# Method
|
|
243
657
|
left.addWidget(QLabel(self.tr("Method:"), self))
|
|
244
658
|
self.method_combo = QComboBox(self)
|
|
245
659
|
self.method_combo.addItems(['Gaussian', 'Median', 'Bilateral'])
|
|
660
|
+
self.method_combo.setToolTip(
|
|
661
|
+
"Blur method for creating the Low Frequency layer:\n"
|
|
662
|
+
"- Gaussian: Smooth blur, best for general use\n"
|
|
663
|
+
"- Median: Preserves edges, good for noise reduction\n"
|
|
664
|
+
"- Bilateral: Edge-aware blur, protects fine boundaries"
|
|
665
|
+
)
|
|
246
666
|
self.method_combo.currentTextChanged.connect(self._on_method_changed)
|
|
247
667
|
left.addWidget(self.method_combo)
|
|
248
668
|
|
|
@@ -250,6 +670,12 @@ class FrequencySeperationTab(QWidget):
|
|
|
250
670
|
self.radius_label = QLabel("Radius: 10.00", self); left.addWidget(self.radius_label)
|
|
251
671
|
self.radius_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
252
672
|
self.radius_slider.setRange(1, 100); self.radius_slider.setValue(50)
|
|
673
|
+
self.radius_slider.setToolTip(
|
|
674
|
+
"Controls the frequency cutoff between LF and HF:\n"
|
|
675
|
+
"- Small radius: Only finest details go to HF (subtle sharpening)\n"
|
|
676
|
+
"- Large radius: More structure captured in HF (aggressive sharpening)\n"
|
|
677
|
+
"Larger values = smoother LF, more detail in HF"
|
|
678
|
+
)
|
|
253
679
|
self.radius_slider.valueChanged.connect(self._on_radius_changed)
|
|
254
680
|
left.addWidget(self.radius_slider)
|
|
255
681
|
|
|
@@ -257,47 +683,95 @@ class FrequencySeperationTab(QWidget):
|
|
|
257
683
|
self.tol_label = QLabel("Tolerance: 50%", self); left.addWidget(self.tol_label)
|
|
258
684
|
self.tol_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
259
685
|
self.tol_slider.setRange(0, 100); self.tol_slider.setValue(50)
|
|
686
|
+
self.tol_slider.setToolTip(
|
|
687
|
+
"Bilateral filter edge sensitivity (only for Bilateral method):\n"
|
|
688
|
+
"- Low: Strong edge preservation, less smoothing across edges\n"
|
|
689
|
+
"- High: More smoothing, edges less protected"
|
|
690
|
+
)
|
|
260
691
|
self.tol_slider.valueChanged.connect(self._on_tol_changed)
|
|
261
692
|
left.addWidget(self.tol_slider)
|
|
262
693
|
self._toggle_tol_enabled(False)
|
|
263
694
|
|
|
264
|
-
# Apply separation
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
695
|
+
# Apply separation with undo button
|
|
696
|
+
split_row = QHBoxLayout()
|
|
697
|
+
self.btn_apply_split = QPushButton(self.tr("Apply - Split HF & LF"), self)
|
|
698
|
+
self.btn_apply_split.setToolTip(
|
|
699
|
+
"Split the image into Low Frequency and High Frequency components.\n"
|
|
700
|
+
"LF = blurred image (smooth tones, gradients)\n"
|
|
701
|
+
"HF = original - LF (fine details, edges, stars)"
|
|
702
|
+
)
|
|
703
|
+
self.btn_apply_split.clicked.connect(self._apply_separation)
|
|
704
|
+
split_row.addWidget(self.btn_apply_split)
|
|
705
|
+
|
|
706
|
+
self.btn_undo_split = QToolButton(self)
|
|
707
|
+
self.btn_undo_split.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
|
|
708
|
+
self.btn_undo_split.setToolTip("Undo last split (restore previous LF/HF)")
|
|
709
|
+
self.btn_undo_split.setEnabled(False)
|
|
710
|
+
self.btn_undo_split.clicked.connect(self._undo_split)
|
|
711
|
+
split_row.addWidget(self.btn_undo_split)
|
|
712
|
+
left.addLayout(split_row)
|
|
268
713
|
|
|
269
714
|
left.addWidget(QLabel(self.tr("<b>HF Enhancements</b>"), self))
|
|
270
715
|
|
|
271
716
|
# Sharpen scale
|
|
272
717
|
self.cb_scale = QCheckBox(self.tr("Enable Sharpen Scale"), self)
|
|
273
|
-
self.cb_scale.setChecked(True)
|
|
718
|
+
self.cb_scale.setChecked(True)
|
|
719
|
+
self.cb_scale.setToolTip("Enable/disable simple multiplication of HF details")
|
|
720
|
+
left.addWidget(self.cb_scale)
|
|
274
721
|
self.scale_label = QLabel("Sharpen Scale: 1.00", self); left.addWidget(self.scale_label)
|
|
275
722
|
self.scale_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
276
723
|
self.scale_slider.setRange(10, 300); self.scale_slider.setValue(100)
|
|
724
|
+
self.scale_slider.setToolTip(
|
|
725
|
+
"Multiplier for High Frequency details:\n"
|
|
726
|
+
"- 1.0: No change\n"
|
|
727
|
+
"- < 1.0: Reduce detail intensity (soften)\n"
|
|
728
|
+
"- > 1.0: Amplify details (sharpen)\n"
|
|
729
|
+
"Simple and fast way to adjust overall sharpness"
|
|
730
|
+
)
|
|
277
731
|
self.scale_slider.valueChanged.connect(lambda v: self._update_scale(v))
|
|
278
732
|
left.addWidget(self.scale_slider)
|
|
279
733
|
|
|
280
734
|
# Wavelet
|
|
281
735
|
self.cb_wavelet = QCheckBox(self.tr("Enable Wavelet Sharpening"), self)
|
|
282
|
-
self.cb_wavelet.setChecked(True)
|
|
736
|
+
self.cb_wavelet.setChecked(True)
|
|
737
|
+
self.cb_wavelet.setToolTip("Enable/disable multi-scale wavelet enhancement")
|
|
738
|
+
left.addWidget(self.cb_wavelet)
|
|
283
739
|
self.wavelet_level_label = QLabel("Wavelet Level: 2", self); left.addWidget(self.wavelet_level_label)
|
|
284
740
|
self.wavelet_level_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
285
741
|
self.wavelet_level_slider.setRange(1, 5); self.wavelet_level_slider.setValue(2)
|
|
742
|
+
self.wavelet_level_slider.setToolTip("Wavelet decomposition levels (1-5): Higher = larger features affected")
|
|
286
743
|
self.wavelet_level_slider.valueChanged.connect(lambda v: self._update_wavelet_level(v))
|
|
287
744
|
left.addWidget(self.wavelet_level_slider)
|
|
288
745
|
|
|
289
746
|
self.wavelet_boost_label = QLabel("Wavelet Boost: 1.20", self); left.addWidget(self.wavelet_boost_label)
|
|
290
747
|
self.wavelet_boost_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
291
748
|
self.wavelet_boost_slider.setRange(50, 300); self.wavelet_boost_slider.setValue(120)
|
|
749
|
+
self.wavelet_boost_slider.setToolTip(
|
|
750
|
+
"Multiplier for wavelet detail coefficients:\n"
|
|
751
|
+
"- 1.0: No change\n"
|
|
752
|
+
"- > 1.0: Enhance details at each wavelet scale\n"
|
|
753
|
+
"Works with Wavelet Level to control multi-scale sharpening"
|
|
754
|
+
)
|
|
292
755
|
self.wavelet_boost_slider.valueChanged.connect(lambda v: self._update_wavelet_boost(v))
|
|
293
756
|
left.addWidget(self.wavelet_boost_slider)
|
|
294
757
|
|
|
295
758
|
# Denoise
|
|
296
759
|
self.cb_denoise = QCheckBox(self.tr("Enable HF Denoise"), self)
|
|
297
|
-
self.cb_denoise.setChecked(False)
|
|
760
|
+
self.cb_denoise.setChecked(False)
|
|
761
|
+
self.cb_denoise.setToolTip(
|
|
762
|
+
"Apply noise reduction to the HF layer only.\n"
|
|
763
|
+
"Reduces noise while preserving the smooth LF layer intact."
|
|
764
|
+
)
|
|
765
|
+
left.addWidget(self.cb_denoise)
|
|
298
766
|
self.denoise_label = QLabel("Denoise Strength: 3.00", self); left.addWidget(self.denoise_label)
|
|
299
767
|
self.denoise_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
300
768
|
self.denoise_slider.setRange(0, 50); self.denoise_slider.setValue(30) # 0..5.0 (we'll /10)
|
|
769
|
+
self.denoise_slider.setToolTip(
|
|
770
|
+
"Strength of Non-Local Means denoising on HF:\n"
|
|
771
|
+
"- Low: Subtle noise reduction, preserves fine detail\n"
|
|
772
|
+
"- High: Aggressive noise removal, may soften details\n"
|
|
773
|
+
"Only affects the HF component"
|
|
774
|
+
)
|
|
301
775
|
self.denoise_slider.valueChanged.connect(lambda v: self._update_denoise(v))
|
|
302
776
|
left.addWidget(self.denoise_slider)
|
|
303
777
|
|
|
@@ -305,12 +779,16 @@ class FrequencySeperationTab(QWidget):
|
|
|
305
779
|
row = QHBoxLayout()
|
|
306
780
|
self.btn_apply_hf = QPushButton(self.tr("Apply HF Enhancements"), self)
|
|
307
781
|
self.btn_apply_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton))
|
|
782
|
+
self.btn_apply_hf.setToolTip(
|
|
783
|
+
"Apply the enabled HF enhancements (scale, wavelet, denoise)\n"
|
|
784
|
+
"to the High Frequency layer. Can be applied multiple times."
|
|
785
|
+
)
|
|
308
786
|
self.btn_apply_hf.clicked.connect(self._apply_hf_enhancements)
|
|
309
787
|
row.addWidget(self.btn_apply_hf)
|
|
310
788
|
|
|
311
789
|
self.btn_undo_hf = QToolButton(self)
|
|
312
790
|
self.btn_undo_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
|
|
313
|
-
self.btn_undo_hf.setToolTip("Undo last HF enhancement")
|
|
791
|
+
self.btn_undo_hf.setToolTip("Undo last HF enhancement (restores previous HF state)")
|
|
314
792
|
self.btn_undo_hf.setEnabled(False)
|
|
315
793
|
self.btn_undo_hf.clicked.connect(self._undo_hf)
|
|
316
794
|
row.addWidget(self.btn_undo_hf)
|
|
@@ -318,36 +796,74 @@ class FrequencySeperationTab(QWidget):
|
|
|
318
796
|
|
|
319
797
|
# Push buttons
|
|
320
798
|
push_row = QHBoxLayout()
|
|
321
|
-
self.btn_push_lf = QPushButton(self.tr("Push LF"), self)
|
|
322
|
-
self.
|
|
799
|
+
self.btn_push_lf = QPushButton(self.tr("Push LF"), self)
|
|
800
|
+
self.btn_push_lf.setToolTip("Open the Low Frequency layer in a new view for separate editing")
|
|
801
|
+
self.btn_push_lf.clicked.connect(lambda: self._push_array(self.low_freq_image, "LF"))
|
|
802
|
+
self.btn_push_hf = QPushButton(self.tr("Push HF"), self)
|
|
803
|
+
self.btn_push_hf.setToolTip("Open the High Frequency layer in a new view for separate editing")
|
|
804
|
+
self.btn_push_hf.clicked.connect(lambda: self._push_array(self._hf_display_for_push(), "HF"))
|
|
323
805
|
push_row.addWidget(self.btn_push_lf); push_row.addWidget(self.btn_push_hf)
|
|
324
806
|
left.addLayout(push_row)
|
|
325
807
|
|
|
326
|
-
#load_row = QHBoxLayout()
|
|
327
|
-
#self.btn_load_hf = QPushButton("Load HF…", self)
|
|
328
|
-
#self.btn_load_hf.clicked.connect(self._load_hf_from_file)
|
|
329
|
-
#load_row.addWidget(self.btn_load_hf)
|
|
330
|
-
|
|
331
|
-
#self.btn_load_lf = QPushButton("Load LF…", self)
|
|
332
|
-
#self.btn_load_lf.clicked.connect(self._load_lf_from_file)
|
|
333
|
-
#load_row.addWidget(self.btn_load_lf)
|
|
334
|
-
|
|
335
|
-
#left.addLayout(load_row)
|
|
336
|
-
|
|
337
808
|
# --- Load from VIEW (active subwindow) ---
|
|
338
809
|
load_row = QHBoxLayout()
|
|
339
|
-
self.btn_load_hf_view = QPushButton("Load HF (View)", self)
|
|
340
810
|
self.btn_load_lf_view = QPushButton("Load LF (View)", self)
|
|
811
|
+
self.btn_load_lf_view.setToolTip(
|
|
812
|
+
"Replace the LF layer with an image from another open view.\n"
|
|
813
|
+
"Useful for loading an externally processed LF back in."
|
|
814
|
+
)
|
|
815
|
+
self.btn_load_hf_view = QPushButton("Load HF (View)", self)
|
|
816
|
+
self.btn_load_hf_view.setToolTip(
|
|
817
|
+
"Replace the HF layer with an image from another open view.\n"
|
|
818
|
+
"Useful for loading an externally processed HF back in."
|
|
819
|
+
)
|
|
341
820
|
self.btn_load_hf_view.clicked.connect(lambda: self._load_component_from_view("HF"))
|
|
342
821
|
self.btn_load_lf_view.clicked.connect(lambda: self._load_component_from_view("LF"))
|
|
343
|
-
load_row.addWidget(self.btn_load_lf_view)
|
|
822
|
+
load_row.addWidget(self.btn_load_lf_view)
|
|
344
823
|
load_row.addWidget(self.btn_load_hf_view)
|
|
345
824
|
|
|
346
825
|
left.addLayout(load_row)
|
|
347
826
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
827
|
+
# Combine output options
|
|
828
|
+
combine_row = QHBoxLayout()
|
|
829
|
+
self.btn_combine_update = QPushButton(self.tr("Combine → Update"), self)
|
|
830
|
+
self.btn_combine_update.setToolTip(
|
|
831
|
+
"Recombine LF + HF and apply back to the source document.\n"
|
|
832
|
+
"Result = clip(LF + HF, 0, 1)\n"
|
|
833
|
+
"If a mask is active, blends with original using the mask."
|
|
834
|
+
)
|
|
835
|
+
self.btn_combine_update.clicked.connect(self._combine_and_update_source)
|
|
836
|
+
combine_row.addWidget(self.btn_combine_update)
|
|
837
|
+
|
|
838
|
+
self.btn_combine_new = QPushButton(self.tr("Combine → New"), self)
|
|
839
|
+
self.btn_combine_new.setToolTip(
|
|
840
|
+
"Recombine LF + HF and open in a new view.\n"
|
|
841
|
+
"Result = clip(LF + HF, 0, 1)\n"
|
|
842
|
+
"Leaves the source document unchanged."
|
|
843
|
+
)
|
|
844
|
+
self.btn_combine_new.clicked.connect(self._combine_and_push_new)
|
|
845
|
+
combine_row.addWidget(self.btn_combine_new)
|
|
846
|
+
left.addLayout(combine_row)
|
|
847
|
+
|
|
848
|
+
# Combined preview toggle
|
|
849
|
+
preview_row = QHBoxLayout()
|
|
850
|
+
self.cb_preview_combined = QCheckBox(self.tr("Preview Combined"), self)
|
|
851
|
+
self.cb_preview_combined.setChecked(False)
|
|
852
|
+
self.cb_preview_combined.setToolTip(
|
|
853
|
+
"Preview the combined LF + HF result before applying.\n"
|
|
854
|
+
"Shows what the final image will look like."
|
|
855
|
+
)
|
|
856
|
+
self.cb_preview_combined.toggled.connect(self._on_preview_combined_toggled)
|
|
857
|
+
preview_row.addWidget(self.cb_preview_combined)
|
|
858
|
+
|
|
859
|
+
self.btn_reset_all = QPushButton(self.tr("Reset All"), self)
|
|
860
|
+
self.btn_reset_all.setToolTip(
|
|
861
|
+
"Reset all parameters to defaults and re-run separation.\n"
|
|
862
|
+
"Clears HF enhancement history and restores initial state."
|
|
863
|
+
)
|
|
864
|
+
self.btn_reset_all.clicked.connect(self._reset_to_defaults)
|
|
865
|
+
preview_row.addWidget(self.btn_reset_all)
|
|
866
|
+
left.addLayout(preview_row)
|
|
351
867
|
|
|
352
868
|
|
|
353
869
|
|
|
@@ -372,9 +888,9 @@ class FrequencySeperationTab(QWidget):
|
|
|
372
888
|
top_row = QHBoxLayout()
|
|
373
889
|
top_row.addStretch(1)
|
|
374
890
|
|
|
375
|
-
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
376
|
-
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
377
|
-
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to
|
|
891
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In (Mouse Wheel Up)")
|
|
892
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out (Mouse Wheel Down)")
|
|
893
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit image to preview area")
|
|
378
894
|
|
|
379
895
|
self.btn_zoom_in.clicked.connect(lambda: self._zoom_at_pair(1.25))
|
|
380
896
|
self.btn_zoom_out.clicked.connect(lambda: self._zoom_at_pair(0.8))
|
|
@@ -392,7 +908,9 @@ class FrequencySeperationTab(QWidget):
|
|
|
392
908
|
self.scrollLF = QScrollArea(self); self.scrollLF.setWidgetResizable(False); self.scrollLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
393
909
|
|
|
394
910
|
self.labelHF = QLabel("High Frequency", self); self.labelHF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
911
|
+
self.labelHF.setToolTip("High Frequency layer: fine details, edges, stars\n(displayed with +0.5 offset for visibility)")
|
|
395
912
|
self.labelLF = QLabel("Low Frequency", self); self.labelLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
913
|
+
self.labelLF.setToolTip("Low Frequency layer: smooth tones, gradients, nebulosity\nLeft-click and drag to pan (synced with HF)")
|
|
396
914
|
|
|
397
915
|
self.scrollHF.setWidget(self.labelHF)
|
|
398
916
|
self.scrollLF.setWidget(self.labelLF)
|
|
@@ -451,7 +969,7 @@ class FrequencySeperationTab(QWidget):
|
|
|
451
969
|
img = getattr(doc, "image", None) if doc is not None else None
|
|
452
970
|
md = getattr(doc, "metadata", {}) if doc is not None else {}
|
|
453
971
|
if img is not None:
|
|
454
|
-
self.set_image_from_doc(img, md)
|
|
972
|
+
self.set_image_from_doc(img, md, source_doc=doc)
|
|
455
973
|
return True
|
|
456
974
|
return False
|
|
457
975
|
|
|
@@ -517,7 +1035,7 @@ class FrequencySeperationTab(QWidget):
|
|
|
517
1035
|
|
|
518
1036
|
if self.image is None and self.low_freq_image is None and self.high_freq_image is None:
|
|
519
1037
|
# adopt this as the reference image (so future loads coerce to this)
|
|
520
|
-
self.set_image_from_doc(imgc, getattr(doc, "metadata", {}))
|
|
1038
|
+
self.set_image_from_doc(imgc, getattr(doc, "metadata", {}), source_doc=doc)
|
|
521
1039
|
|
|
522
1040
|
if target == "HF":
|
|
523
1041
|
self.high_freq_image = imgc.astype(np.float32, copy=False)
|
|
@@ -558,9 +1076,20 @@ class FrequencySeperationTab(QWidget):
|
|
|
558
1076
|
if dm is not None:
|
|
559
1077
|
for attr in ("documents", "all_documents", "_docs"):
|
|
560
1078
|
d = getattr(dm, attr, None)
|
|
561
|
-
if d:
|
|
562
|
-
|
|
563
|
-
|
|
1079
|
+
if d is not None:
|
|
1080
|
+
# If it's a method, call it; otherwise treat as iterable
|
|
1081
|
+
if callable(d):
|
|
1082
|
+
try:
|
|
1083
|
+
docs = list(d())
|
|
1084
|
+
break
|
|
1085
|
+
except Exception:
|
|
1086
|
+
continue
|
|
1087
|
+
else:
|
|
1088
|
+
try:
|
|
1089
|
+
docs = list(d)
|
|
1090
|
+
break
|
|
1091
|
+
except (TypeError, Exception):
|
|
1092
|
+
continue
|
|
564
1093
|
|
|
565
1094
|
# If no doc list, scan subwindows
|
|
566
1095
|
if not docs and mw is not None:
|
|
@@ -649,8 +1178,17 @@ class FrequencySeperationTab(QWidget):
|
|
|
649
1178
|
# Assign and update preview
|
|
650
1179
|
if which.upper() == "HF":
|
|
651
1180
|
self.high_freq_image = arr.astype(np.float32, copy=False)
|
|
1181
|
+
self._hf_dirty = True
|
|
1182
|
+
self._hf_pixmap_cache = None
|
|
1183
|
+
self._hf_scaled_cache = None
|
|
652
1184
|
else:
|
|
653
1185
|
self.low_freq_image = arr.astype(np.float32, copy=False)
|
|
1186
|
+
self._lf_dirty = True
|
|
1187
|
+
self._lf_pixmap_cache = None
|
|
1188
|
+
self._lf_scaled_cache = None
|
|
1189
|
+
|
|
1190
|
+
# Invalidate combined cache since a component changed
|
|
1191
|
+
self._invalidate_combined_cache()
|
|
654
1192
|
|
|
655
1193
|
# Warn on dimensional mismatch (combine needs same shape)
|
|
656
1194
|
if (self.low_freq_image is not None and self.high_freq_image is not None and
|
|
@@ -663,6 +1201,48 @@ class FrequencySeperationTab(QWidget):
|
|
|
663
1201
|
|
|
664
1202
|
self._update_previews()
|
|
665
1203
|
|
|
1204
|
+
def _load_source_from_view(self):
|
|
1205
|
+
"""Load source image from an open view/document."""
|
|
1206
|
+
doc = self._select_document_via_dropdown("Source")
|
|
1207
|
+
if not doc:
|
|
1208
|
+
return
|
|
1209
|
+
arr = self._image_from_doc(doc)
|
|
1210
|
+
if arr is None:
|
|
1211
|
+
return
|
|
1212
|
+
|
|
1213
|
+
# Store reference to source document for later use
|
|
1214
|
+
self._source_doc = doc
|
|
1215
|
+
|
|
1216
|
+
# Set as the main image and run separation
|
|
1217
|
+
self.image = arr.astype(np.float32, copy=False)
|
|
1218
|
+
md = getattr(doc, "metadata", {}) or {}
|
|
1219
|
+
self.filename = md.get("file_path", None)
|
|
1220
|
+
self.original_header = md.get("original_header", None)
|
|
1221
|
+
self.is_mono = bool(md.get("is_mono", False))
|
|
1222
|
+
|
|
1223
|
+
# Update label with document name
|
|
1224
|
+
doc_name = getattr(doc, "name", None) or os.path.basename(self.filename) if self.filename else "(from view)"
|
|
1225
|
+
self.fileLabel.setText(doc_name)
|
|
1226
|
+
|
|
1227
|
+
# Clear outputs and invalidate all caches
|
|
1228
|
+
self.low_freq_image = None
|
|
1229
|
+
self.high_freq_image = None
|
|
1230
|
+
self._lf_dirty = True
|
|
1231
|
+
self._hf_dirty = True
|
|
1232
|
+
self._lf_pixmap_cache = None
|
|
1233
|
+
self._hf_pixmap_cache = None
|
|
1234
|
+
self._lf_scaled_cache = None
|
|
1235
|
+
self._hf_scaled_cache = None
|
|
1236
|
+
self._invalidate_combined_cache()
|
|
1237
|
+
|
|
1238
|
+
# Clear history
|
|
1239
|
+
self._split_history.clear()
|
|
1240
|
+
self.btn_undo_split.setEnabled(False)
|
|
1241
|
+
self._hf_history.clear()
|
|
1242
|
+
self.btn_undo_hf.setEnabled(False)
|
|
1243
|
+
|
|
1244
|
+
# Run initial separation
|
|
1245
|
+
self._apply_separation()
|
|
666
1246
|
|
|
667
1247
|
def _ref_shape(self):
|
|
668
1248
|
"""
|
|
@@ -711,16 +1291,10 @@ class FrequencySeperationTab(QWidget):
|
|
|
711
1291
|
|
|
712
1292
|
# channel reconcile
|
|
713
1293
|
if rch == 1 and ch == 3:
|
|
714
|
-
# convert RGB→mono (
|
|
1294
|
+
# convert RGB→mono (luma or average; we’ll use average)
|
|
715
1295
|
a = a.mean(axis=2).astype(np.float32)
|
|
716
1296
|
elif rch == 3 and ch == 1:
|
|
717
|
-
|
|
718
|
-
# (H,W,1) -> (H,W,3) via broadcasted view if consumer allows,
|
|
719
|
-
# but usually downstream (like subtraction) handles broadcasting fine.
|
|
720
|
-
# If explicit physical layout is needed, we must check usage.
|
|
721
|
-
# Here: used for subtraction (OK) and preview (OK).
|
|
722
|
-
# We return a view using broadcast_to or striding tricks.
|
|
723
|
-
a = np.broadcast_to(a, (ah, aw, 3))
|
|
1297
|
+
a = np.repeat(a[..., None], 3, axis=2).astype(np.float32)
|
|
724
1298
|
|
|
725
1299
|
return a
|
|
726
1300
|
|
|
@@ -762,24 +1336,6 @@ class FrequencySeperationTab(QWidget):
|
|
|
762
1336
|
except Exception as e:
|
|
763
1337
|
QMessageBox.critical(self, "Load LF", f"Failed to load LF:\n{e}")
|
|
764
1338
|
|
|
765
|
-
|
|
766
|
-
# --- NEW: autoload exactly once when the dialog shows ---
|
|
767
|
-
def showEvent(self, e):
|
|
768
|
-
super().showEvent(e)
|
|
769
|
-
if not self._auto_loaded:
|
|
770
|
-
self._auto_loaded = True
|
|
771
|
-
# Strong preference order:
|
|
772
|
-
# (1) self.doc (injected at construction time)
|
|
773
|
-
# (2) active MDI doc (strict — no "last-created" fallback)
|
|
774
|
-
src_doc = self.doc or self._get_active_document(strict=True)
|
|
775
|
-
if src_doc is not None and getattr(src_doc, "image", None) is not None:
|
|
776
|
-
try:
|
|
777
|
-
self.set_image_from_doc(np.asarray(src_doc.image),
|
|
778
|
-
getattr(src_doc, "metadata", {}))
|
|
779
|
-
return
|
|
780
|
-
except Exception:
|
|
781
|
-
pass
|
|
782
|
-
|
|
783
1339
|
# --------------- helpers ---------------
|
|
784
1340
|
def _toggle_tol_enabled(self, on: bool):
|
|
785
1341
|
self.tol_slider.setEnabled(on)
|
|
@@ -813,19 +1369,33 @@ class FrequencySeperationTab(QWidget):
|
|
|
813
1369
|
self.denoise_label.setText(f"Denoise Strength: {self.denoise_strength:.2f}")
|
|
814
1370
|
|
|
815
1371
|
# --------------- image I/O hooks ---------------
|
|
816
|
-
def set_image_from_doc(self, image: np.ndarray, metadata: dict | None):
|
|
817
|
-
"""Call this from the main app when there
|
|
1372
|
+
def set_image_from_doc(self, image: np.ndarray, metadata: dict | None, source_doc=None):
|
|
1373
|
+
"""Call this from the main app when there's an active image; or adapt to your ImageManager signal."""
|
|
818
1374
|
if image is None:
|
|
819
1375
|
return
|
|
820
1376
|
self.image = image.astype(np.float32, copy=False)
|
|
1377
|
+
self._source_doc = source_doc # Track source for later update
|
|
821
1378
|
md = metadata or {}
|
|
822
1379
|
self.filename = md.get("file_path", None)
|
|
823
1380
|
self.original_header = md.get("original_header", None)
|
|
824
1381
|
self.is_mono = bool(md.get("is_mono", False))
|
|
825
|
-
|
|
826
|
-
|
|
1382
|
+
doc_name = getattr(source_doc, "name", None) if source_doc else None
|
|
1383
|
+
self.fileLabel.setText(doc_name or (os.path.basename(self.filename) if self.filename else "(from view)"))
|
|
1384
|
+
# clear outputs and invalidate all caches (base + scaled)
|
|
827
1385
|
self.low_freq_image = None
|
|
828
1386
|
self.high_freq_image = None
|
|
1387
|
+
self._lf_dirty = True
|
|
1388
|
+
self._hf_dirty = True
|
|
1389
|
+
self._lf_pixmap_cache = None
|
|
1390
|
+
self._hf_pixmap_cache = None
|
|
1391
|
+
self._lf_scaled_cache = None
|
|
1392
|
+
self._hf_scaled_cache = None
|
|
1393
|
+
self._invalidate_combined_cache()
|
|
1394
|
+
# Clear history
|
|
1395
|
+
self._split_history.clear()
|
|
1396
|
+
self.btn_undo_split.setEnabled(False)
|
|
1397
|
+
self._hf_history.clear()
|
|
1398
|
+
self.btn_undo_hf.setEnabled(False)
|
|
829
1399
|
self._apply_separation()
|
|
830
1400
|
|
|
831
1401
|
# --------------- controls handlers ---------------
|
|
@@ -841,18 +1411,28 @@ class FrequencySeperationTab(QWidget):
|
|
|
841
1411
|
self.tolerance = int(v)
|
|
842
1412
|
self.tol_label.setText(f"Tolerance: {self.tolerance}%")
|
|
843
1413
|
|
|
1414
|
+
def _on_gpu_toggled(self, checked: bool):
|
|
1415
|
+
self.use_gpu = checked
|
|
1416
|
+
|
|
844
1417
|
# --------------- processing ---------------
|
|
845
1418
|
def _apply_separation(self):
|
|
846
1419
|
if self.image is None:
|
|
847
1420
|
QMessageBox.warning(self, "No Image", "Load or select an image first.")
|
|
848
1421
|
return
|
|
1422
|
+
|
|
1423
|
+
# Save current state for undo (if we have existing LF/HF)
|
|
1424
|
+
if self.low_freq_image is not None and self.high_freq_image is not None:
|
|
1425
|
+
self._split_history.append((self.low_freq_image.copy(), self.high_freq_image.copy()))
|
|
1426
|
+
self.btn_undo_split.setEnabled(True)
|
|
1427
|
+
|
|
849
1428
|
self._show_spinner(True)
|
|
850
1429
|
|
|
851
1430
|
if self.proc_thread and self.proc_thread.isRunning():
|
|
852
1431
|
self.proc_thread.quit(); self.proc_thread.wait()
|
|
853
1432
|
|
|
854
1433
|
self.proc_thread = FrequencySeperationThread(
|
|
855
|
-
image=self.image, method=self.method, radius=self.radius, tolerance=self.tolerance
|
|
1434
|
+
image=self.image, method=self.method, radius=self.radius, tolerance=self.tolerance,
|
|
1435
|
+
use_cuda=self.use_gpu
|
|
856
1436
|
)
|
|
857
1437
|
self.proc_thread.separation_done.connect(self._on_sep_done)
|
|
858
1438
|
self.proc_thread.error_signal.connect(self._on_sep_error)
|
|
@@ -862,6 +1442,15 @@ class FrequencySeperationTab(QWidget):
|
|
|
862
1442
|
self._show_spinner(False)
|
|
863
1443
|
self.low_freq_image = lf.astype(np.float32)
|
|
864
1444
|
self.high_freq_image = hf.astype(np.float32)
|
|
1445
|
+
# Invalidate all caches (base + scaled)
|
|
1446
|
+
self._lf_dirty = True
|
|
1447
|
+
self._hf_dirty = True
|
|
1448
|
+
self._combined_dirty = True
|
|
1449
|
+
self._lf_pixmap_cache = None
|
|
1450
|
+
self._hf_pixmap_cache = None
|
|
1451
|
+
self._lf_scaled_cache = None
|
|
1452
|
+
self._hf_scaled_cache = None
|
|
1453
|
+
self._invalidate_combined_cache()
|
|
865
1454
|
self._update_previews()
|
|
866
1455
|
|
|
867
1456
|
def _on_sep_error(self, msg: str):
|
|
@@ -869,13 +1458,24 @@ class FrequencySeperationTab(QWidget):
|
|
|
869
1458
|
QMessageBox.critical(self, "Frequency Separation", msg)
|
|
870
1459
|
|
|
871
1460
|
def _apply_hf_enhancements(self):
|
|
1461
|
+
global _CUDA_DENOISE_INITIALIZED
|
|
1462
|
+
|
|
872
1463
|
if self.high_freq_image is None:
|
|
873
1464
|
QMessageBox.information(self, "HF", "No HF image to enhance.")
|
|
874
1465
|
return
|
|
1466
|
+
|
|
875
1467
|
# history for undo
|
|
876
1468
|
self._hf_history.append(self.high_freq_image.copy())
|
|
877
1469
|
self.btn_undo_hf.setEnabled(True)
|
|
878
1470
|
|
|
1471
|
+
# If CUDA denoise requested and this is the first time, run synchronously on main thread
|
|
1472
|
+
# to initialize CUDA NLM properly (avoids crash when running in background thread first time)
|
|
1473
|
+
if self.use_gpu and self.cb_denoise.isChecked() and not _CUDA_DENOISE_INITIALIZED:
|
|
1474
|
+
self._apply_hf_sync()
|
|
1475
|
+
_CUDA_DENOISE_INITIALIZED = True
|
|
1476
|
+
return
|
|
1477
|
+
|
|
1478
|
+
# Normal async path
|
|
879
1479
|
self._show_spinner(True)
|
|
880
1480
|
if self.hf_thread and self.hf_thread.isRunning():
|
|
881
1481
|
self.hf_thread.quit(); self.hf_thread.wait()
|
|
@@ -888,15 +1488,106 @@ class FrequencySeperationTab(QWidget):
|
|
|
888
1488
|
wavelet_level=self.wavelet_level,
|
|
889
1489
|
wavelet_boost=self.wavelet_boost,
|
|
890
1490
|
enable_denoise=self.cb_denoise.isChecked(),
|
|
891
|
-
denoise_strength=self.denoise_strength
|
|
1491
|
+
denoise_strength=self.denoise_strength,
|
|
1492
|
+
use_cuda=self.use_gpu
|
|
892
1493
|
)
|
|
893
1494
|
self.hf_thread.enhancement_done.connect(self._on_hf_done)
|
|
894
1495
|
self.hf_thread.error_signal.connect(self._on_hf_error)
|
|
895
1496
|
self.hf_thread.start()
|
|
896
1497
|
|
|
1498
|
+
def _apply_hf_sync(self):
|
|
1499
|
+
"""Run HF enhancements synchronously on main thread (used for first CUDA denoise)."""
|
|
1500
|
+
from PyQt6.QtCore import Qt
|
|
1501
|
+
self.setCursor(Qt.CursorShape.WaitCursor)
|
|
1502
|
+
try:
|
|
1503
|
+
out = self.high_freq_image.copy()
|
|
1504
|
+
|
|
1505
|
+
# Scale
|
|
1506
|
+
if self.cb_scale.isChecked():
|
|
1507
|
+
out *= self.sharpen_scale
|
|
1508
|
+
|
|
1509
|
+
# Wavelet (CPU only)
|
|
1510
|
+
if self.cb_wavelet.isChecked() and pywt is not None:
|
|
1511
|
+
out = self._wavelet_sharpen_sync(out)
|
|
1512
|
+
|
|
1513
|
+
# Denoise with CUDA
|
|
1514
|
+
if self.cb_denoise.isChecked() and cv2 is not None:
|
|
1515
|
+
out = self._denoise_sync(out)
|
|
1516
|
+
|
|
1517
|
+
# Update result
|
|
1518
|
+
self.high_freq_image = np.ascontiguousarray(out.astype(np.float32))
|
|
1519
|
+
self._hf_dirty = True
|
|
1520
|
+
self._hf_pixmap_cache = None
|
|
1521
|
+
self._hf_scaled_cache = None
|
|
1522
|
+
self._invalidate_combined_cache()
|
|
1523
|
+
self._update_previews()
|
|
1524
|
+
except Exception as e:
|
|
1525
|
+
QMessageBox.critical(self, "HF Enhancement Error", str(e))
|
|
1526
|
+
finally:
|
|
1527
|
+
self.unsetCursor()
|
|
1528
|
+
|
|
1529
|
+
def _wavelet_sharpen_sync(self, img):
|
|
1530
|
+
"""Synchronous wavelet sharpening."""
|
|
1531
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
1532
|
+
chs = []
|
|
1533
|
+
for c in range(3):
|
|
1534
|
+
mono = img[..., c]
|
|
1535
|
+
h, w = mono.shape[:2]
|
|
1536
|
+
max_level = pywt.dwt_max_level(min(h, w), 'db2')
|
|
1537
|
+
safe_level = min(self.wavelet_level, max_level) if max_level > 0 else self.wavelet_level
|
|
1538
|
+
coeffs = pywt.wavedec2(mono, wavelet='db2', level=safe_level, mode='periodization')
|
|
1539
|
+
new_coeffs = [coeffs[0]]
|
|
1540
|
+
for (cH, cV, cD) in coeffs[1:]:
|
|
1541
|
+
new_coeffs.append((cH * self.wavelet_boost, cV * self.wavelet_boost, cD * self.wavelet_boost))
|
|
1542
|
+
rec = pywt.waverec2(new_coeffs, wavelet='db2', mode='periodization')
|
|
1543
|
+
if rec.shape != mono.shape:
|
|
1544
|
+
rec = rec[:h, :w]
|
|
1545
|
+
chs.append(rec.astype(np.float32))
|
|
1546
|
+
return np.stack(chs, axis=-1)
|
|
1547
|
+
return img
|
|
1548
|
+
|
|
1549
|
+
def _denoise_sync(self, hf):
|
|
1550
|
+
"""Synchronous CUDA denoise on main thread."""
|
|
1551
|
+
strength = self.denoise_strength
|
|
1552
|
+
|
|
1553
|
+
if hf.ndim == 3 and hf.shape[2] == 3:
|
|
1554
|
+
bgr = hf[..., ::-1]
|
|
1555
|
+
tmp = np.clip(bgr + 0.5, 0, 1)
|
|
1556
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
1557
|
+
u8 = np.ascontiguousarray(u8)
|
|
1558
|
+
|
|
1559
|
+
gpu_src = cv2.cuda_GpuMat()
|
|
1560
|
+
gpu_src.upload(u8)
|
|
1561
|
+
|
|
1562
|
+
gpu_dst = cv2.cuda.fastNlMeansDenoisingColored(
|
|
1563
|
+
gpu_src, strength, strength, None, 21, 7
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
cv2.cuda.Stream.Null().waitForCompletion()
|
|
1567
|
+
den = gpu_dst.download()
|
|
1568
|
+
|
|
1569
|
+
den = np.array(den, dtype=np.uint8, copy=True)
|
|
1570
|
+
f32 = den.astype(np.float32) / 255.0 - 0.5
|
|
1571
|
+
result = np.array(f32[..., ::-1], dtype=np.float32, copy=True)
|
|
1572
|
+
|
|
1573
|
+
del gpu_dst, gpu_src
|
|
1574
|
+
return result
|
|
1575
|
+
else:
|
|
1576
|
+
# Grayscale - use CPU for simplicity
|
|
1577
|
+
tmp = np.clip(hf + 0.5, 0, 1)
|
|
1578
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
1579
|
+
den = cv2.fastNlMeansDenoising(u8, None, strength, 7, 21)
|
|
1580
|
+
return den.astype(np.float32) / 255.0 - 0.5
|
|
1581
|
+
|
|
897
1582
|
def _on_hf_done(self, new_hf: np.ndarray):
|
|
898
1583
|
self._show_spinner(False)
|
|
899
|
-
|
|
1584
|
+
# Ensure the array is valid and contiguous
|
|
1585
|
+
self.high_freq_image = np.ascontiguousarray(new_hf.astype(np.float32))
|
|
1586
|
+
# Invalidate HF and combined caches (base + scaled)
|
|
1587
|
+
self._hf_dirty = True
|
|
1588
|
+
self._hf_pixmap_cache = None
|
|
1589
|
+
self._hf_scaled_cache = None
|
|
1590
|
+
self._invalidate_combined_cache()
|
|
900
1591
|
self._update_previews()
|
|
901
1592
|
|
|
902
1593
|
def _on_hf_error(self, msg: str):
|
|
@@ -908,8 +1599,122 @@ class FrequencySeperationTab(QWidget):
|
|
|
908
1599
|
return
|
|
909
1600
|
self.high_freq_image = self._hf_history.pop()
|
|
910
1601
|
self.btn_undo_hf.setEnabled(bool(self._hf_history))
|
|
1602
|
+
self._hf_dirty = True
|
|
1603
|
+
self._hf_pixmap_cache = None
|
|
1604
|
+
self._hf_scaled_cache = None
|
|
1605
|
+
self._invalidate_combined_cache()
|
|
1606
|
+
self._update_previews()
|
|
1607
|
+
|
|
1608
|
+
def _undo_split(self):
|
|
1609
|
+
"""Restore previous LF/HF separation state."""
|
|
1610
|
+
if not self._split_history:
|
|
1611
|
+
return
|
|
1612
|
+
lf, hf = self._split_history.pop()
|
|
1613
|
+
self.low_freq_image = lf
|
|
1614
|
+
self.high_freq_image = hf
|
|
1615
|
+
self.btn_undo_split.setEnabled(bool(self._split_history))
|
|
1616
|
+
# Invalidate caches and update previews
|
|
1617
|
+
self._lf_dirty = True
|
|
1618
|
+
self._hf_dirty = True
|
|
1619
|
+
self._lf_pixmap_cache = None
|
|
1620
|
+
self._hf_pixmap_cache = None
|
|
1621
|
+
self._lf_scaled_cache = None
|
|
1622
|
+
self._hf_scaled_cache = None
|
|
1623
|
+
self._invalidate_combined_cache()
|
|
1624
|
+
self._update_previews()
|
|
1625
|
+
|
|
1626
|
+
# --------------- combined preview ---------------
|
|
1627
|
+
def _on_preview_combined_toggled(self, checked: bool):
|
|
1628
|
+
self._show_combined = checked
|
|
1629
|
+
if checked:
|
|
1630
|
+
self._invalidate_combined_cache()
|
|
911
1631
|
self._update_previews()
|
|
912
1632
|
|
|
1633
|
+
def _reset_to_defaults(self):
|
|
1634
|
+
"""Reset all parameters to defaults and re-run separation."""
|
|
1635
|
+
if self.image is None:
|
|
1636
|
+
return
|
|
1637
|
+
|
|
1638
|
+
# Reset parameters to defaults
|
|
1639
|
+
self.method = 'Gaussian'
|
|
1640
|
+
self.radius = 10.0
|
|
1641
|
+
self.tolerance = 50
|
|
1642
|
+
self.sharpen_scale = 1.0
|
|
1643
|
+
self.wavelet_level = 2
|
|
1644
|
+
self.wavelet_boost = 1.2
|
|
1645
|
+
self.denoise_strength = 3.0
|
|
1646
|
+
|
|
1647
|
+
# Update UI controls to match defaults (block signals to avoid re-triggering)
|
|
1648
|
+
self.method_combo.blockSignals(True)
|
|
1649
|
+
self.method_combo.setCurrentText('Gaussian')
|
|
1650
|
+
self.method_combo.blockSignals(False)
|
|
1651
|
+
self._toggle_tol_enabled(False)
|
|
1652
|
+
|
|
1653
|
+
self.radius_slider.blockSignals(True)
|
|
1654
|
+
self.radius_slider.setValue(50) # maps to 10.0
|
|
1655
|
+
self.radius_slider.blockSignals(False)
|
|
1656
|
+
self.radius_label.setText("Radius: 10.00")
|
|
1657
|
+
|
|
1658
|
+
self.tol_slider.blockSignals(True)
|
|
1659
|
+
self.tol_slider.setValue(50)
|
|
1660
|
+
self.tol_slider.blockSignals(False)
|
|
1661
|
+
self.tol_label.setText("Tolerance: 50%")
|
|
1662
|
+
|
|
1663
|
+
self.cb_scale.setChecked(True)
|
|
1664
|
+
self.scale_slider.blockSignals(True)
|
|
1665
|
+
self.scale_slider.setValue(100)
|
|
1666
|
+
self.scale_slider.blockSignals(False)
|
|
1667
|
+
self.scale_label.setText("Sharpen Scale: 1.00")
|
|
1668
|
+
|
|
1669
|
+
self.cb_wavelet.setChecked(True)
|
|
1670
|
+
self.wavelet_level_slider.blockSignals(True)
|
|
1671
|
+
self.wavelet_level_slider.setValue(2)
|
|
1672
|
+
self.wavelet_level_slider.blockSignals(False)
|
|
1673
|
+
self.wavelet_level_label.setText("Wavelet Level: 2")
|
|
1674
|
+
|
|
1675
|
+
self.wavelet_boost_slider.blockSignals(True)
|
|
1676
|
+
self.wavelet_boost_slider.setValue(120)
|
|
1677
|
+
self.wavelet_boost_slider.blockSignals(False)
|
|
1678
|
+
self.wavelet_boost_label.setText("Wavelet Boost: 1.20")
|
|
1679
|
+
|
|
1680
|
+
self.cb_denoise.setChecked(False)
|
|
1681
|
+
self.denoise_slider.blockSignals(True)
|
|
1682
|
+
self.denoise_slider.setValue(30)
|
|
1683
|
+
self.denoise_slider.blockSignals(False)
|
|
1684
|
+
self.denoise_label.setText("Denoise Strength: 3.00")
|
|
1685
|
+
|
|
1686
|
+
# Clear HF enhancement history
|
|
1687
|
+
self._hf_history.clear()
|
|
1688
|
+
self.btn_undo_hf.setEnabled(False)
|
|
1689
|
+
|
|
1690
|
+
# Clear split history
|
|
1691
|
+
self._split_history.clear()
|
|
1692
|
+
self.btn_undo_split.setEnabled(False)
|
|
1693
|
+
|
|
1694
|
+
# Uncheck preview combined
|
|
1695
|
+
self.cb_preview_combined.setChecked(False)
|
|
1696
|
+
|
|
1697
|
+
# Re-run separation with default parameters
|
|
1698
|
+
self._apply_separation()
|
|
1699
|
+
|
|
1700
|
+
def _invalidate_combined_cache(self):
|
|
1701
|
+
self._combined_cache = None
|
|
1702
|
+
self._combined_pixmap_cache = None
|
|
1703
|
+
self._combined_scaled_cache = None
|
|
1704
|
+
self._combined_dirty = True
|
|
1705
|
+
|
|
1706
|
+
def _get_combined_image(self) -> np.ndarray | None:
|
|
1707
|
+
"""Compute or return cached combined image."""
|
|
1708
|
+
if self.low_freq_image is None or self.high_freq_image is None:
|
|
1709
|
+
return None
|
|
1710
|
+
# Only recompute if cache is None (invalidated)
|
|
1711
|
+
if self._combined_cache is None:
|
|
1712
|
+
# Use np.add with out parameter to avoid intermediate allocation
|
|
1713
|
+
self._combined_cache = np.empty_like(self.low_freq_image)
|
|
1714
|
+
np.add(self.low_freq_image, self.high_freq_image, out=self._combined_cache)
|
|
1715
|
+
np.clip(self._combined_cache, 0.0, 1.0, out=self._combined_cache)
|
|
1716
|
+
return self._combined_cache
|
|
1717
|
+
|
|
913
1718
|
# --------------- spinner ---------------
|
|
914
1719
|
def _show_spinner(self, on: bool):
|
|
915
1720
|
if on:
|
|
@@ -921,37 +1726,154 @@ class FrequencySeperationTab(QWidget):
|
|
|
921
1726
|
|
|
922
1727
|
# --------------- preview rendering ---------------
|
|
923
1728
|
def _numpy_to_qpix(self, arr: np.ndarray) -> QPixmap:
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1729
|
+
"""
|
|
1730
|
+
Convert numpy array to QPixmap.
|
|
1731
|
+
Args:
|
|
1732
|
+
arr: Input array (float32, [0-1] or signed for HF)
|
|
1733
|
+
"""
|
|
1734
|
+
if arr is None:
|
|
1735
|
+
return QPixmap()
|
|
1736
|
+
|
|
1737
|
+
# Make a copy to avoid issues with non-contiguous or GPU memory
|
|
1738
|
+
arr = np.array(arr, dtype=np.float32, copy=True)
|
|
1739
|
+
|
|
1740
|
+
# Clip to valid range and convert to uint8
|
|
1741
|
+
clipped = np.clip(arr, 0, 1)
|
|
1742
|
+
|
|
1743
|
+
if clipped.ndim == 2:
|
|
1744
|
+
# Mono: stack to RGB
|
|
1745
|
+
u8 = (clipped * 255).astype(np.uint8)
|
|
1746
|
+
u8 = np.stack([u8, u8, u8], axis=-1)
|
|
1747
|
+
elif clipped.ndim == 3 and clipped.shape[2] == 3:
|
|
1748
|
+
# RGB
|
|
1749
|
+
u8 = (clipped * 255).astype(np.uint8)
|
|
1750
|
+
elif clipped.ndim == 3 and clipped.shape[2] == 1:
|
|
1751
|
+
# Single channel 3D -> squeeze and stack
|
|
1752
|
+
u8 = (clipped[:, :, 0] * 255).astype(np.uint8)
|
|
1753
|
+
u8 = np.stack([u8, u8, u8], axis=-1)
|
|
1754
|
+
else:
|
|
1755
|
+
return QPixmap()
|
|
1756
|
+
|
|
1757
|
+
# Ensure contiguous memory layout
|
|
1758
|
+
u8 = np.ascontiguousarray(u8)
|
|
1759
|
+
|
|
1760
|
+
h, w = u8.shape[:2]
|
|
1761
|
+
qimg = QImage(u8.data, w, h, w * 3, QImage.Format.Format_RGB888)
|
|
930
1762
|
return QPixmap.fromImage(qimg.copy())
|
|
931
1763
|
|
|
1764
|
+
def _get_base_pixmap(self, which: str) -> QPixmap | None:
|
|
1765
|
+
"""Get or create cached base pixmap for LF, HF, or Combined."""
|
|
1766
|
+
if which == 'lf':
|
|
1767
|
+
if self.low_freq_image is None:
|
|
1768
|
+
return None
|
|
1769
|
+
if self._lf_dirty or self._lf_pixmap_cache is None:
|
|
1770
|
+
self._lf_pixmap_cache = self._numpy_to_qpix(self.low_freq_image)
|
|
1771
|
+
self._lf_dirty = False
|
|
1772
|
+
return self._lf_pixmap_cache
|
|
1773
|
+
|
|
1774
|
+
elif which == 'hf':
|
|
1775
|
+
if self.high_freq_image is None:
|
|
1776
|
+
return None
|
|
1777
|
+
if self._hf_dirty or self._hf_pixmap_cache is None:
|
|
1778
|
+
# HF needs +0.5 offset for visualization
|
|
1779
|
+
disp = self.high_freq_image + 0.5
|
|
1780
|
+
self._hf_pixmap_cache = self._numpy_to_qpix(disp)
|
|
1781
|
+
self._hf_dirty = False
|
|
1782
|
+
return self._hf_pixmap_cache
|
|
1783
|
+
|
|
1784
|
+
elif which == 'combined':
|
|
1785
|
+
combined = self._get_combined_image()
|
|
1786
|
+
if combined is None:
|
|
1787
|
+
return None
|
|
1788
|
+
# Regenerate pixmap if cache is None (invalidated)
|
|
1789
|
+
if self._combined_pixmap_cache is None:
|
|
1790
|
+
self._combined_pixmap_cache = self._numpy_to_qpix(combined)
|
|
1791
|
+
self._combined_dirty = False
|
|
1792
|
+
return self._combined_pixmap_cache
|
|
1793
|
+
|
|
1794
|
+
return None
|
|
1795
|
+
|
|
1796
|
+
def _get_scaled_pixmap(self, which: str) -> QPixmap | None:
|
|
1797
|
+
"""
|
|
1798
|
+
Get scaled pixmap with caching. Only rescales if zoom changed or cache is invalidated.
|
|
1799
|
+
which: 'lf', 'hf', or 'combined'
|
|
1800
|
+
"""
|
|
1801
|
+
base_pm = self._get_base_pixmap(which)
|
|
1802
|
+
if base_pm is None:
|
|
1803
|
+
return None
|
|
1804
|
+
|
|
1805
|
+
zoom_changed = abs(self._scaled_zoom - self.zoom_factor) > 1e-6
|
|
1806
|
+
|
|
1807
|
+
if which == 'lf':
|
|
1808
|
+
if self._lf_scaled_cache is None or zoom_changed:
|
|
1809
|
+
self._lf_scaled_cache = self._do_scale(base_pm)
|
|
1810
|
+
return self._lf_scaled_cache
|
|
1811
|
+
elif which == 'hf':
|
|
1812
|
+
if self._hf_scaled_cache is None or zoom_changed:
|
|
1813
|
+
self._hf_scaled_cache = self._do_scale(base_pm)
|
|
1814
|
+
return self._hf_scaled_cache
|
|
1815
|
+
elif which == 'combined':
|
|
1816
|
+
if self._combined_scaled_cache is None or zoom_changed:
|
|
1817
|
+
self._combined_scaled_cache = self._do_scale(base_pm)
|
|
1818
|
+
return self._combined_scaled_cache
|
|
1819
|
+
return None
|
|
1820
|
+
|
|
1821
|
+
def _do_scale(self, pm: QPixmap) -> QPixmap:
|
|
1822
|
+
"""Perform the actual scaling operation."""
|
|
1823
|
+
if self.zoom_factor == 1.0:
|
|
1824
|
+
return pm
|
|
1825
|
+
return pm.scaled(
|
|
1826
|
+
pm.size() * self.zoom_factor,
|
|
1827
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1828
|
+
Qt.TransformationMode.SmoothTransformation
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
def _invalidate_scaled_caches(self):
|
|
1832
|
+
"""Invalidate all scaled pixmap caches (call when zoom changes)."""
|
|
1833
|
+
self._lf_scaled_cache = None
|
|
1834
|
+
self._hf_scaled_cache = None
|
|
1835
|
+
self._combined_scaled_cache = None
|
|
1836
|
+
|
|
932
1837
|
def _update_previews(self):
|
|
933
|
-
|
|
934
|
-
if
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
self.labelHF.setPixmap(scaled)
|
|
952
|
-
self.labelHF.resize(scaled.size())
|
|
1838
|
+
zoom_changed = abs(self._scaled_zoom - self.zoom_factor) > 1e-6
|
|
1839
|
+
if zoom_changed:
|
|
1840
|
+
self._invalidate_scaled_caches()
|
|
1841
|
+
self._scaled_zoom = self.zoom_factor
|
|
1842
|
+
|
|
1843
|
+
if self._show_combined:
|
|
1844
|
+
# Combined preview mode: hide HF panel, show combined in LF panel only
|
|
1845
|
+
# Always recompute combined since it's a transient preview
|
|
1846
|
+
self.scrollHF.hide()
|
|
1847
|
+
combined = self._get_combined_image()
|
|
1848
|
+
if combined is not None:
|
|
1849
|
+
pm = self._numpy_to_qpix(combined)
|
|
1850
|
+
scaled = self._do_scale(pm)
|
|
1851
|
+
self.labelLF.setPixmap(scaled)
|
|
1852
|
+
self.labelLF.resize(scaled.size())
|
|
1853
|
+
else:
|
|
1854
|
+
self.labelLF.setText("Combined (no data)")
|
|
1855
|
+
self.labelLF.resize(self.labelLF.sizeHint())
|
|
953
1856
|
else:
|
|
954
|
-
|
|
1857
|
+
# Normal mode: show both panels with LF and HF separate
|
|
1858
|
+
self.scrollHF.show()
|
|
1859
|
+
|
|
1860
|
+
# LF
|
|
1861
|
+
lf_scaled = self._get_scaled_pixmap('lf')
|
|
1862
|
+
if lf_scaled is not None:
|
|
1863
|
+
self.labelLF.setPixmap(lf_scaled)
|
|
1864
|
+
self.labelLF.resize(lf_scaled.size())
|
|
1865
|
+
else:
|
|
1866
|
+
self.labelLF.setText("Low Frequency")
|
|
1867
|
+
self.labelLF.resize(self.labelLF.sizeHint())
|
|
1868
|
+
|
|
1869
|
+
# HF
|
|
1870
|
+
hf_scaled = self._get_scaled_pixmap('hf')
|
|
1871
|
+
if hf_scaled is not None:
|
|
1872
|
+
self.labelHF.setPixmap(hf_scaled)
|
|
1873
|
+
self.labelHF.resize(hf_scaled.size())
|
|
1874
|
+
else:
|
|
1875
|
+
self.labelHF.setText("High Frequency")
|
|
1876
|
+
self.labelHF.resize(self.labelHF.sizeHint())
|
|
955
1877
|
|
|
956
1878
|
# center if smaller than viewport
|
|
957
1879
|
QTimer.singleShot(0, self._center_if_fit)
|
|
@@ -1084,15 +2006,15 @@ class FrequencySeperationTab(QWidget):
|
|
|
1084
2006
|
# keep signed HF; app stack supports float32 arrays
|
|
1085
2007
|
return self.high_freq_image.astype(np.float32, copy=False)
|
|
1086
2008
|
|
|
1087
|
-
def
|
|
2009
|
+
def _get_combined_result(self) -> tuple[np.ndarray, dict] | None:
|
|
2010
|
+
"""Compute combined image and build metadata. Returns (blended_image, metadata) or None."""
|
|
1088
2011
|
if self.low_freq_image is None or self.high_freq_image is None:
|
|
1089
2012
|
QMessageBox.information(self, "Combine", "LF or HF missing.")
|
|
1090
|
-
return
|
|
2013
|
+
return None
|
|
1091
2014
|
|
|
1092
2015
|
combined = np.clip(self.low_freq_image + self.high_freq_image, 0.0, 1.0).astype(np.float32)
|
|
1093
|
-
step_name = "Frequency Separation (Combine HF+LF)"
|
|
1094
2016
|
|
|
1095
|
-
#
|
|
2017
|
+
# Blend with active mask (if any)
|
|
1096
2018
|
blended, mid, mname, masked = self._blend_with_active_mask(combined)
|
|
1097
2019
|
|
|
1098
2020
|
# Build metadata
|
|
@@ -1108,71 +2030,100 @@ class FrequencySeperationTab(QWidget):
|
|
|
1108
2030
|
"mask_name": mname,
|
|
1109
2031
|
"mask_blend": "m*out + (1-m)*src",
|
|
1110
2032
|
})
|
|
2033
|
+
return blended, md
|
|
2034
|
+
|
|
2035
|
+
def _combine_and_update_source(self):
|
|
2036
|
+
"""Combine LF+HF and apply back to the source document."""
|
|
2037
|
+
result = self._get_combined_result()
|
|
2038
|
+
if result is None:
|
|
2039
|
+
return
|
|
2040
|
+
blended, md = result
|
|
2041
|
+
step_name = "Frequency Separation (Combine HF+LF)"
|
|
2042
|
+
|
|
2043
|
+
# Apply to the source document we loaded from
|
|
2044
|
+
if self._source_doc is not None:
|
|
2045
|
+
try:
|
|
2046
|
+
if hasattr(self._source_doc, 'apply_edit'):
|
|
2047
|
+
self._source_doc.apply_edit(blended, metadata=md, step_name=step_name)
|
|
2048
|
+
elif hasattr(self._source_doc, 'set_image'):
|
|
2049
|
+
self._source_doc.set_image(blended, md)
|
|
2050
|
+
else:
|
|
2051
|
+
raise RuntimeError("Source document has no known update method")
|
|
2052
|
+
return
|
|
2053
|
+
except Exception as e:
|
|
2054
|
+
QMessageBox.critical(self, "Apply Failed", f"Could not apply to source document:\n{e}")
|
|
2055
|
+
return
|
|
1111
2056
|
|
|
1112
|
-
#
|
|
2057
|
+
# Fallback: try the injected doc
|
|
1113
2058
|
if isinstance(self.doc, ImageDocument):
|
|
1114
2059
|
try:
|
|
1115
2060
|
self.doc.apply_edit(blended, metadata=md, step_name=step_name)
|
|
1116
2061
|
except Exception as e:
|
|
1117
|
-
QMessageBox.critical(self, "Apply Failed", f"Could not apply to
|
|
2062
|
+
QMessageBox.critical(self, "Apply Failed", f"Could not apply to document:\n{e}")
|
|
1118
2063
|
return
|
|
1119
2064
|
|
|
1120
|
-
#
|
|
2065
|
+
# Last fallback: push to active via DocManager
|
|
1121
2066
|
self._push_to_active(blended, step_name, extra_md=md)
|
|
1122
2067
|
|
|
2068
|
+
def _combine_and_push_new(self):
|
|
2069
|
+
"""Combine LF+HF and push to a new view."""
|
|
2070
|
+
result = self._get_combined_result()
|
|
2071
|
+
if result is None:
|
|
2072
|
+
return
|
|
2073
|
+
blended, md = result
|
|
2074
|
+
self._push_array(blended, "Combined")
|
|
2075
|
+
|
|
1123
2076
|
# --------------- event filter (wheel + drag pan + sync) ---------------
|
|
1124
2077
|
def eventFilter(self, obj, ev):
|
|
1125
|
-
# --------
|
|
2078
|
+
# -------- Mouse Wheel Zoom --------
|
|
1126
2079
|
if ev.type() == QEvent.Type.Wheel:
|
|
1127
2080
|
targets = {self.scrollHF.viewport(), self.labelHF,
|
|
1128
2081
|
self.scrollLF.viewport(), self.labelLF}
|
|
1129
2082
|
if obj in targets:
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
dy = ev.
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
# Not Ctrl: let the scroll area do normal scrolling
|
|
1175
|
-
return False
|
|
2083
|
+
try:
|
|
2084
|
+
dy = ev.pixelDelta().y()
|
|
2085
|
+
if dy == 0:
|
|
2086
|
+
dy = ev.angleDelta().y()
|
|
2087
|
+
if dy == 0:
|
|
2088
|
+
return False
|
|
2089
|
+
factor = 1.25 if dy > 0 else 0.8
|
|
2090
|
+
|
|
2091
|
+
# Anchor positions (robust mapping child→viewport)
|
|
2092
|
+
if obj is self.labelHF:
|
|
2093
|
+
anchor_hf = self.labelHF.mapTo(
|
|
2094
|
+
self.scrollHF.viewport(), ev.position().toPoint()
|
|
2095
|
+
)
|
|
2096
|
+
anchor_lf = QPoint(
|
|
2097
|
+
self.scrollLF.viewport().width() // 2,
|
|
2098
|
+
self.scrollLF.viewport().height() // 2
|
|
2099
|
+
)
|
|
2100
|
+
elif obj is self.scrollHF.viewport():
|
|
2101
|
+
anchor_hf = ev.position().toPoint()
|
|
2102
|
+
anchor_lf = QPoint(
|
|
2103
|
+
self.scrollLF.viewport().width() // 2,
|
|
2104
|
+
self.scrollLF.viewport().height() // 2
|
|
2105
|
+
)
|
|
2106
|
+
elif obj is self.labelLF:
|
|
2107
|
+
anchor_lf = self.labelLF.mapTo(
|
|
2108
|
+
self.scrollLF.viewport(), ev.position().toPoint()
|
|
2109
|
+
)
|
|
2110
|
+
anchor_hf = QPoint(
|
|
2111
|
+
self.scrollHF.viewport().width() // 2,
|
|
2112
|
+
self.scrollHF.viewport().height() // 2
|
|
2113
|
+
)
|
|
2114
|
+
else: # obj is self.scrollLF.viewport()
|
|
2115
|
+
anchor_lf = ev.position().toPoint()
|
|
2116
|
+
anchor_hf = QPoint(
|
|
2117
|
+
self.scrollHF.viewport().width() // 2,
|
|
2118
|
+
self.scrollHF.viewport().height() // 2
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
self._zoom_at_pair(factor, anchor_hf, anchor_lf)
|
|
2122
|
+
except Exception:
|
|
2123
|
+
# If anything goes weird (trackpad/gesture edge cases), center-zoom safely
|
|
2124
|
+
self._zoom_at_pair(1.25 if (ev.angleDelta().y() if hasattr(ev, "angleDelta") else 1) > 0 else 0.8)
|
|
2125
|
+
ev.accept()
|
|
2126
|
+
return True
|
|
1176
2127
|
|
|
1177
2128
|
# -------- Drag-pan inside each viewport (sync the other) --------
|
|
1178
2129
|
if obj in (self.scrollHF.viewport(), self.scrollLF.viewport()):
|
|
@@ -1349,4 +2300,4 @@ class SelectViewDialog(QDialog):
|
|
|
1349
2300
|
|
|
1350
2301
|
def selected_doc(self):
|
|
1351
2302
|
idx = self.combo.currentIndex()
|
|
1352
|
-
return self._items[idx][1] if 0 <= idx < len(self._items) else None
|
|
2303
|
+
return self._items[idx][1] if 0 <= idx < len(self._items) else None
|