setiastrosuitepro 1.6.2__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/rotatearbitrary.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +114 -37
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +548 -275
- 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 +134 -8
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +246 -16
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +519 -289
- setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
- 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 +748 -255
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +97 -11
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +83 -21
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +253 -49
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1610 -574
- setiastro/saspro/star_alignment.py +522 -453
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +743 -128
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +1588 -516
- setiastro/saspro/translations/ar_translations.py +915 -684
- setiastro/saspro/translations/de_translations.py +442 -463
- setiastro/saspro/translations/es_translations.py +277 -47
- setiastro/saspro/translations/fr_translations.py +279 -47
- setiastro/saspro/translations/hi_translations.py +253 -21
- setiastro/saspro/translations/integrate_translations.py +3 -2
- setiastro/saspro/translations/it_translations.py +1211 -161
- setiastro/saspro/translations/ja_translations.py +3340 -3107
- setiastro/saspro/translations/pt_translations.py +3315 -3337
- setiastro/saspro/translations/ru_translations.py +351 -117
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +15902 -138
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14428 -133
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +11503 -7821
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +11168 -7812
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14733 -135
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +14347 -7821
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14860 -137
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +14904 -137
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11766 -168
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15115 -135
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +11206 -6729
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +282 -56
- setiastro/saspro/translations/uk_translations.py +264 -35
- setiastro/saspro/translations/zh_translations.py +282 -47
- setiastro/saspro/view_bundle.py +17 -17
- 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/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +133 -57
- setiastro/saspro/widgets/spinboxes.py +28 -13
- setiastro/saspro/wimi.py +92 -721
- setiastro/saspro/wims.py +46 -36
- setiastro/saspro/window_shelf.py +2 -2
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.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,52 +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)
|
|
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))
|
|
44
107
|
|
|
45
108
|
def run(self):
|
|
46
109
|
try:
|
|
47
|
-
if self.
|
|
48
|
-
|
|
49
|
-
raise RuntimeError("OpenCV (cv2) is required for color frequency separation.")
|
|
50
|
-
bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
|
|
110
|
+
if self.use_cuda:
|
|
111
|
+
low_rgb, high_rgb = self._run_cuda()
|
|
51
112
|
else:
|
|
52
|
-
|
|
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))
|
|
53
117
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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()
|
|
69
177
|
else:
|
|
70
|
-
# fallback
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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))
|
|
74
206
|
|
|
75
|
-
|
|
76
|
-
|
|
207
|
+
# CUDA bilateral requires 8-bit input
|
|
208
|
+
bgr_u8 = (np.clip(bgr, 0, 1) * 255).astype(np.uint8)
|
|
209
|
+
|
|
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)
|
|
77
220
|
else:
|
|
78
|
-
|
|
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)
|
|
79
240
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
301
|
|
|
85
302
|
|
|
86
303
|
class HFEnhancementThread(QThread):
|
|
@@ -98,6 +315,7 @@ class HFEnhancementThread(QThread):
|
|
|
98
315
|
wavelet_name='db2',
|
|
99
316
|
enable_denoise=False,
|
|
100
317
|
denoise_strength=3.0,
|
|
318
|
+
use_cuda=True,
|
|
101
319
|
parent=None
|
|
102
320
|
):
|
|
103
321
|
super().__init__(parent)
|
|
@@ -110,28 +328,50 @@ class HFEnhancementThread(QThread):
|
|
|
110
328
|
self.wavelet_name = str(wavelet_name)
|
|
111
329
|
self.enable_denoise = bool(enable_denoise)
|
|
112
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))
|
|
113
334
|
|
|
114
335
|
def run(self):
|
|
115
336
|
try:
|
|
116
337
|
out = self.hf_image.copy()
|
|
117
338
|
|
|
118
339
|
if self.enable_scale:
|
|
119
|
-
|
|
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
|
|
120
344
|
|
|
121
345
|
if self.enable_wavelet:
|
|
122
346
|
if pywt is None:
|
|
123
347
|
raise RuntimeError("PyWavelets (pywt) is required for wavelet sharpening.")
|
|
348
|
+
# Note: PyWavelets is CPU-only, no CUDA support available
|
|
124
349
|
out = self._wavelet_sharpen(out, self.wavelet_name, self.wavelet_level, self.wavelet_boost)
|
|
125
350
|
|
|
126
351
|
if self.enable_denoise:
|
|
127
352
|
if cv2 is None:
|
|
128
353
|
raise RuntimeError("OpenCV (cv2) is required for HF denoise.")
|
|
129
|
-
|
|
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)
|
|
130
358
|
|
|
131
359
|
self.enhancement_done.emit(out.astype(np.float32))
|
|
132
360
|
except Exception as e:
|
|
133
361
|
self.error_signal.emit(str(e))
|
|
134
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
|
+
|
|
135
375
|
def _wavelet_sharpen(self, img, wavelet='db2', level=2, boost=1.2):
|
|
136
376
|
if img.ndim == 3 and img.shape[2] == 3:
|
|
137
377
|
chs = []
|
|
@@ -142,19 +382,30 @@ class HFEnhancementThread(QThread):
|
|
|
142
382
|
return self._wavelet_sharpen_mono(img, wavelet, level, boost)
|
|
143
383
|
|
|
144
384
|
def _wavelet_sharpen_mono(self, mono, wavelet, level, boost):
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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."""
|
|
158
409
|
# Shift to [0..1], denoise, shift back.
|
|
159
410
|
if hf.ndim == 3 and hf.shape[2] == 3:
|
|
160
411
|
bgr = hf[..., ::-1] # RGB->BGR
|
|
@@ -169,6 +420,112 @@ class HFEnhancementThread(QThread):
|
|
|
169
420
|
den = cv2.fastNlMeansDenoising(u8, None, strength, 7, 21)
|
|
170
421
|
return den.astype(np.float32) / 255.0 - 0.5
|
|
171
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
|
+
|
|
172
529
|
|
|
173
530
|
# ---------------------------- Widget ----------------------------
|
|
174
531
|
|
|
@@ -199,6 +556,25 @@ class FrequencySeperationTab(QWidget):
|
|
|
199
556
|
self._last_pos: QPoint | None = None
|
|
200
557
|
self._sync_guard = False
|
|
201
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
|
|
202
578
|
|
|
203
579
|
# parameters
|
|
204
580
|
self.method = 'Gaussian'
|
|
@@ -212,17 +588,21 @@ class FrequencySeperationTab(QWidget):
|
|
|
212
588
|
self.enable_denoise = False
|
|
213
589
|
self.denoise_strength = 3.0
|
|
214
590
|
|
|
591
|
+
# GPU acceleration - default to GPU if available
|
|
592
|
+
self.use_gpu = _CUDA_AVAILABLE
|
|
593
|
+
|
|
215
594
|
self.proc_thread: FrequencySeperationThread | None = None
|
|
216
595
|
self.hf_thread: HFEnhancementThread | None = None
|
|
217
|
-
self.
|
|
596
|
+
self._source_doc = None # Track the source document separately
|
|
597
|
+
self._cuda_warmed_up = False
|
|
218
598
|
self._build_ui()
|
|
219
599
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
226
606
|
|
|
227
607
|
# ---------------- UI ----------------
|
|
228
608
|
def _build_ui(self):
|
|
@@ -233,13 +613,56 @@ class FrequencySeperationTab(QWidget):
|
|
|
233
613
|
left = QVBoxLayout()
|
|
234
614
|
left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(280)
|
|
235
615
|
|
|
236
|
-
self.fileLabel = QLabel("", self)
|
|
616
|
+
self.fileLabel = QLabel("(No image loaded)", self)
|
|
237
617
|
left.addWidget(self.fileLabel)
|
|
238
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
|
+
|
|
239
656
|
# Method
|
|
240
657
|
left.addWidget(QLabel(self.tr("Method:"), self))
|
|
241
658
|
self.method_combo = QComboBox(self)
|
|
242
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
|
+
)
|
|
243
666
|
self.method_combo.currentTextChanged.connect(self._on_method_changed)
|
|
244
667
|
left.addWidget(self.method_combo)
|
|
245
668
|
|
|
@@ -247,6 +670,12 @@ class FrequencySeperationTab(QWidget):
|
|
|
247
670
|
self.radius_label = QLabel("Radius: 10.00", self); left.addWidget(self.radius_label)
|
|
248
671
|
self.radius_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
249
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
|
+
)
|
|
250
679
|
self.radius_slider.valueChanged.connect(self._on_radius_changed)
|
|
251
680
|
left.addWidget(self.radius_slider)
|
|
252
681
|
|
|
@@ -254,47 +683,95 @@ class FrequencySeperationTab(QWidget):
|
|
|
254
683
|
self.tol_label = QLabel("Tolerance: 50%", self); left.addWidget(self.tol_label)
|
|
255
684
|
self.tol_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
256
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
|
+
)
|
|
257
691
|
self.tol_slider.valueChanged.connect(self._on_tol_changed)
|
|
258
692
|
left.addWidget(self.tol_slider)
|
|
259
693
|
self._toggle_tol_enabled(False)
|
|
260
694
|
|
|
261
|
-
# Apply separation
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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)
|
|
265
713
|
|
|
266
714
|
left.addWidget(QLabel(self.tr("<b>HF Enhancements</b>"), self))
|
|
267
715
|
|
|
268
716
|
# Sharpen scale
|
|
269
717
|
self.cb_scale = QCheckBox(self.tr("Enable Sharpen Scale"), self)
|
|
270
|
-
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)
|
|
271
721
|
self.scale_label = QLabel("Sharpen Scale: 1.00", self); left.addWidget(self.scale_label)
|
|
272
722
|
self.scale_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
273
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
|
+
)
|
|
274
731
|
self.scale_slider.valueChanged.connect(lambda v: self._update_scale(v))
|
|
275
732
|
left.addWidget(self.scale_slider)
|
|
276
733
|
|
|
277
734
|
# Wavelet
|
|
278
735
|
self.cb_wavelet = QCheckBox(self.tr("Enable Wavelet Sharpening"), self)
|
|
279
|
-
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)
|
|
280
739
|
self.wavelet_level_label = QLabel("Wavelet Level: 2", self); left.addWidget(self.wavelet_level_label)
|
|
281
740
|
self.wavelet_level_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
282
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")
|
|
283
743
|
self.wavelet_level_slider.valueChanged.connect(lambda v: self._update_wavelet_level(v))
|
|
284
744
|
left.addWidget(self.wavelet_level_slider)
|
|
285
745
|
|
|
286
746
|
self.wavelet_boost_label = QLabel("Wavelet Boost: 1.20", self); left.addWidget(self.wavelet_boost_label)
|
|
287
747
|
self.wavelet_boost_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
288
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
|
+
)
|
|
289
755
|
self.wavelet_boost_slider.valueChanged.connect(lambda v: self._update_wavelet_boost(v))
|
|
290
756
|
left.addWidget(self.wavelet_boost_slider)
|
|
291
757
|
|
|
292
758
|
# Denoise
|
|
293
759
|
self.cb_denoise = QCheckBox(self.tr("Enable HF Denoise"), self)
|
|
294
|
-
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)
|
|
295
766
|
self.denoise_label = QLabel("Denoise Strength: 3.00", self); left.addWidget(self.denoise_label)
|
|
296
767
|
self.denoise_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
297
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
|
+
)
|
|
298
775
|
self.denoise_slider.valueChanged.connect(lambda v: self._update_denoise(v))
|
|
299
776
|
left.addWidget(self.denoise_slider)
|
|
300
777
|
|
|
@@ -302,12 +779,16 @@ class FrequencySeperationTab(QWidget):
|
|
|
302
779
|
row = QHBoxLayout()
|
|
303
780
|
self.btn_apply_hf = QPushButton(self.tr("Apply HF Enhancements"), self)
|
|
304
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
|
+
)
|
|
305
786
|
self.btn_apply_hf.clicked.connect(self._apply_hf_enhancements)
|
|
306
787
|
row.addWidget(self.btn_apply_hf)
|
|
307
788
|
|
|
308
789
|
self.btn_undo_hf = QToolButton(self)
|
|
309
790
|
self.btn_undo_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
|
|
310
|
-
self.btn_undo_hf.setToolTip("Undo last HF enhancement")
|
|
791
|
+
self.btn_undo_hf.setToolTip("Undo last HF enhancement (restores previous HF state)")
|
|
311
792
|
self.btn_undo_hf.setEnabled(False)
|
|
312
793
|
self.btn_undo_hf.clicked.connect(self._undo_hf)
|
|
313
794
|
row.addWidget(self.btn_undo_hf)
|
|
@@ -315,36 +796,74 @@ class FrequencySeperationTab(QWidget):
|
|
|
315
796
|
|
|
316
797
|
# Push buttons
|
|
317
798
|
push_row = QHBoxLayout()
|
|
318
|
-
self.btn_push_lf = QPushButton(self.tr("Push LF"), self)
|
|
319
|
-
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"))
|
|
320
805
|
push_row.addWidget(self.btn_push_lf); push_row.addWidget(self.btn_push_hf)
|
|
321
806
|
left.addLayout(push_row)
|
|
322
807
|
|
|
323
|
-
#load_row = QHBoxLayout()
|
|
324
|
-
#self.btn_load_hf = QPushButton("Load HF…", self)
|
|
325
|
-
#self.btn_load_hf.clicked.connect(self._load_hf_from_file)
|
|
326
|
-
#load_row.addWidget(self.btn_load_hf)
|
|
327
|
-
|
|
328
|
-
#self.btn_load_lf = QPushButton("Load LF…", self)
|
|
329
|
-
#self.btn_load_lf.clicked.connect(self._load_lf_from_file)
|
|
330
|
-
#load_row.addWidget(self.btn_load_lf)
|
|
331
|
-
|
|
332
|
-
#left.addLayout(load_row)
|
|
333
|
-
|
|
334
808
|
# --- Load from VIEW (active subwindow) ---
|
|
335
809
|
load_row = QHBoxLayout()
|
|
336
|
-
self.btn_load_hf_view = QPushButton("Load HF (View)", self)
|
|
337
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
|
+
)
|
|
338
820
|
self.btn_load_hf_view.clicked.connect(lambda: self._load_component_from_view("HF"))
|
|
339
821
|
self.btn_load_lf_view.clicked.connect(lambda: self._load_component_from_view("LF"))
|
|
340
|
-
load_row.addWidget(self.btn_load_lf_view)
|
|
822
|
+
load_row.addWidget(self.btn_load_lf_view)
|
|
341
823
|
load_row.addWidget(self.btn_load_hf_view)
|
|
342
824
|
|
|
343
825
|
left.addLayout(load_row)
|
|
344
826
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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)
|
|
348
867
|
|
|
349
868
|
|
|
350
869
|
|
|
@@ -369,9 +888,9 @@ class FrequencySeperationTab(QWidget):
|
|
|
369
888
|
top_row = QHBoxLayout()
|
|
370
889
|
top_row.addStretch(1)
|
|
371
890
|
|
|
372
|
-
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
373
|
-
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
374
|
-
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")
|
|
375
894
|
|
|
376
895
|
self.btn_zoom_in.clicked.connect(lambda: self._zoom_at_pair(1.25))
|
|
377
896
|
self.btn_zoom_out.clicked.connect(lambda: self._zoom_at_pair(0.8))
|
|
@@ -389,7 +908,9 @@ class FrequencySeperationTab(QWidget):
|
|
|
389
908
|
self.scrollLF = QScrollArea(self); self.scrollLF.setWidgetResizable(False); self.scrollLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
390
909
|
|
|
391
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)")
|
|
392
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)")
|
|
393
914
|
|
|
394
915
|
self.scrollHF.setWidget(self.labelHF)
|
|
395
916
|
self.scrollLF.setWidget(self.labelLF)
|
|
@@ -448,7 +969,7 @@ class FrequencySeperationTab(QWidget):
|
|
|
448
969
|
img = getattr(doc, "image", None) if doc is not None else None
|
|
449
970
|
md = getattr(doc, "metadata", {}) if doc is not None else {}
|
|
450
971
|
if img is not None:
|
|
451
|
-
self.set_image_from_doc(img, md)
|
|
972
|
+
self.set_image_from_doc(img, md, source_doc=doc)
|
|
452
973
|
return True
|
|
453
974
|
return False
|
|
454
975
|
|
|
@@ -514,7 +1035,7 @@ class FrequencySeperationTab(QWidget):
|
|
|
514
1035
|
|
|
515
1036
|
if self.image is None and self.low_freq_image is None and self.high_freq_image is None:
|
|
516
1037
|
# adopt this as the reference image (so future loads coerce to this)
|
|
517
|
-
self.set_image_from_doc(imgc, getattr(doc, "metadata", {}))
|
|
1038
|
+
self.set_image_from_doc(imgc, getattr(doc, "metadata", {}), source_doc=doc)
|
|
518
1039
|
|
|
519
1040
|
if target == "HF":
|
|
520
1041
|
self.high_freq_image = imgc.astype(np.float32, copy=False)
|
|
@@ -555,9 +1076,20 @@ class FrequencySeperationTab(QWidget):
|
|
|
555
1076
|
if dm is not None:
|
|
556
1077
|
for attr in ("documents", "all_documents", "_docs"):
|
|
557
1078
|
d = getattr(dm, attr, None)
|
|
558
|
-
if d:
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
561
1093
|
|
|
562
1094
|
# If no doc list, scan subwindows
|
|
563
1095
|
if not docs and mw is not None:
|
|
@@ -646,8 +1178,17 @@ class FrequencySeperationTab(QWidget):
|
|
|
646
1178
|
# Assign and update preview
|
|
647
1179
|
if which.upper() == "HF":
|
|
648
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
|
|
649
1184
|
else:
|
|
650
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()
|
|
651
1192
|
|
|
652
1193
|
# Warn on dimensional mismatch (combine needs same shape)
|
|
653
1194
|
if (self.low_freq_image is not None and self.high_freq_image is not None and
|
|
@@ -660,6 +1201,48 @@ class FrequencySeperationTab(QWidget):
|
|
|
660
1201
|
|
|
661
1202
|
self._update_previews()
|
|
662
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()
|
|
663
1246
|
|
|
664
1247
|
def _ref_shape(self):
|
|
665
1248
|
"""
|
|
@@ -708,16 +1291,10 @@ class FrequencySeperationTab(QWidget):
|
|
|
708
1291
|
|
|
709
1292
|
# channel reconcile
|
|
710
1293
|
if rch == 1 and ch == 3:
|
|
711
|
-
# convert RGB→mono (
|
|
1294
|
+
# convert RGB→mono (luma or average; we’ll use average)
|
|
712
1295
|
a = a.mean(axis=2).astype(np.float32)
|
|
713
1296
|
elif rch == 3 and ch == 1:
|
|
714
|
-
|
|
715
|
-
# (H,W,1) -> (H,W,3) via broadcasted view if consumer allows,
|
|
716
|
-
# but usually downstream (like subtraction) handles broadcasting fine.
|
|
717
|
-
# If explicit physical layout is needed, we must check usage.
|
|
718
|
-
# Here: used for subtraction (OK) and preview (OK).
|
|
719
|
-
# We return a view using broadcast_to or striding tricks.
|
|
720
|
-
a = np.broadcast_to(a, (ah, aw, 3))
|
|
1297
|
+
a = np.repeat(a[..., None], 3, axis=2).astype(np.float32)
|
|
721
1298
|
|
|
722
1299
|
return a
|
|
723
1300
|
|
|
@@ -759,24 +1336,6 @@ class FrequencySeperationTab(QWidget):
|
|
|
759
1336
|
except Exception as e:
|
|
760
1337
|
QMessageBox.critical(self, "Load LF", f"Failed to load LF:\n{e}")
|
|
761
1338
|
|
|
762
|
-
|
|
763
|
-
# --- NEW: autoload exactly once when the dialog shows ---
|
|
764
|
-
def showEvent(self, e):
|
|
765
|
-
super().showEvent(e)
|
|
766
|
-
if not self._auto_loaded:
|
|
767
|
-
self._auto_loaded = True
|
|
768
|
-
# Strong preference order:
|
|
769
|
-
# (1) self.doc (injected at construction time)
|
|
770
|
-
# (2) active MDI doc (strict — no "last-created" fallback)
|
|
771
|
-
src_doc = self.doc or self._get_active_document(strict=True)
|
|
772
|
-
if src_doc is not None and getattr(src_doc, "image", None) is not None:
|
|
773
|
-
try:
|
|
774
|
-
self.set_image_from_doc(np.asarray(src_doc.image),
|
|
775
|
-
getattr(src_doc, "metadata", {}))
|
|
776
|
-
return
|
|
777
|
-
except Exception:
|
|
778
|
-
pass
|
|
779
|
-
|
|
780
1339
|
# --------------- helpers ---------------
|
|
781
1340
|
def _toggle_tol_enabled(self, on: bool):
|
|
782
1341
|
self.tol_slider.setEnabled(on)
|
|
@@ -810,19 +1369,33 @@ class FrequencySeperationTab(QWidget):
|
|
|
810
1369
|
self.denoise_label.setText(f"Denoise Strength: {self.denoise_strength:.2f}")
|
|
811
1370
|
|
|
812
1371
|
# --------------- image I/O hooks ---------------
|
|
813
|
-
def set_image_from_doc(self, image: np.ndarray, metadata: dict | None):
|
|
814
|
-
"""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."""
|
|
815
1374
|
if image is None:
|
|
816
1375
|
return
|
|
817
1376
|
self.image = image.astype(np.float32, copy=False)
|
|
1377
|
+
self._source_doc = source_doc # Track source for later update
|
|
818
1378
|
md = metadata or {}
|
|
819
1379
|
self.filename = md.get("file_path", None)
|
|
820
1380
|
self.original_header = md.get("original_header", None)
|
|
821
1381
|
self.is_mono = bool(md.get("is_mono", False))
|
|
822
|
-
|
|
823
|
-
|
|
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)
|
|
824
1385
|
self.low_freq_image = None
|
|
825
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)
|
|
826
1399
|
self._apply_separation()
|
|
827
1400
|
|
|
828
1401
|
# --------------- controls handlers ---------------
|
|
@@ -838,18 +1411,28 @@ class FrequencySeperationTab(QWidget):
|
|
|
838
1411
|
self.tolerance = int(v)
|
|
839
1412
|
self.tol_label.setText(f"Tolerance: {self.tolerance}%")
|
|
840
1413
|
|
|
1414
|
+
def _on_gpu_toggled(self, checked: bool):
|
|
1415
|
+
self.use_gpu = checked
|
|
1416
|
+
|
|
841
1417
|
# --------------- processing ---------------
|
|
842
1418
|
def _apply_separation(self):
|
|
843
1419
|
if self.image is None:
|
|
844
1420
|
QMessageBox.warning(self, "No Image", "Load or select an image first.")
|
|
845
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
|
+
|
|
846
1428
|
self._show_spinner(True)
|
|
847
1429
|
|
|
848
1430
|
if self.proc_thread and self.proc_thread.isRunning():
|
|
849
1431
|
self.proc_thread.quit(); self.proc_thread.wait()
|
|
850
1432
|
|
|
851
1433
|
self.proc_thread = FrequencySeperationThread(
|
|
852
|
-
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
|
|
853
1436
|
)
|
|
854
1437
|
self.proc_thread.separation_done.connect(self._on_sep_done)
|
|
855
1438
|
self.proc_thread.error_signal.connect(self._on_sep_error)
|
|
@@ -859,6 +1442,15 @@ class FrequencySeperationTab(QWidget):
|
|
|
859
1442
|
self._show_spinner(False)
|
|
860
1443
|
self.low_freq_image = lf.astype(np.float32)
|
|
861
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()
|
|
862
1454
|
self._update_previews()
|
|
863
1455
|
|
|
864
1456
|
def _on_sep_error(self, msg: str):
|
|
@@ -866,13 +1458,24 @@ class FrequencySeperationTab(QWidget):
|
|
|
866
1458
|
QMessageBox.critical(self, "Frequency Separation", msg)
|
|
867
1459
|
|
|
868
1460
|
def _apply_hf_enhancements(self):
|
|
1461
|
+
global _CUDA_DENOISE_INITIALIZED
|
|
1462
|
+
|
|
869
1463
|
if self.high_freq_image is None:
|
|
870
1464
|
QMessageBox.information(self, "HF", "No HF image to enhance.")
|
|
871
1465
|
return
|
|
1466
|
+
|
|
872
1467
|
# history for undo
|
|
873
1468
|
self._hf_history.append(self.high_freq_image.copy())
|
|
874
1469
|
self.btn_undo_hf.setEnabled(True)
|
|
875
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
|
|
876
1479
|
self._show_spinner(True)
|
|
877
1480
|
if self.hf_thread and self.hf_thread.isRunning():
|
|
878
1481
|
self.hf_thread.quit(); self.hf_thread.wait()
|
|
@@ -885,15 +1488,106 @@ class FrequencySeperationTab(QWidget):
|
|
|
885
1488
|
wavelet_level=self.wavelet_level,
|
|
886
1489
|
wavelet_boost=self.wavelet_boost,
|
|
887
1490
|
enable_denoise=self.cb_denoise.isChecked(),
|
|
888
|
-
denoise_strength=self.denoise_strength
|
|
1491
|
+
denoise_strength=self.denoise_strength,
|
|
1492
|
+
use_cuda=self.use_gpu
|
|
889
1493
|
)
|
|
890
1494
|
self.hf_thread.enhancement_done.connect(self._on_hf_done)
|
|
891
1495
|
self.hf_thread.error_signal.connect(self._on_hf_error)
|
|
892
1496
|
self.hf_thread.start()
|
|
893
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
|
+
|
|
894
1582
|
def _on_hf_done(self, new_hf: np.ndarray):
|
|
895
1583
|
self._show_spinner(False)
|
|
896
|
-
|
|
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()
|
|
897
1591
|
self._update_previews()
|
|
898
1592
|
|
|
899
1593
|
def _on_hf_error(self, msg: str):
|
|
@@ -905,8 +1599,122 @@ class FrequencySeperationTab(QWidget):
|
|
|
905
1599
|
return
|
|
906
1600
|
self.high_freq_image = self._hf_history.pop()
|
|
907
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()
|
|
908
1624
|
self._update_previews()
|
|
909
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()
|
|
1631
|
+
self._update_previews()
|
|
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
|
+
|
|
910
1718
|
# --------------- spinner ---------------
|
|
911
1719
|
def _show_spinner(self, on: bool):
|
|
912
1720
|
if on:
|
|
@@ -918,37 +1726,154 @@ class FrequencySeperationTab(QWidget):
|
|
|
918
1726
|
|
|
919
1727
|
# --------------- preview rendering ---------------
|
|
920
1728
|
def _numpy_to_qpix(self, arr: np.ndarray) -> QPixmap:
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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)
|
|
927
1762
|
return QPixmap.fromImage(qimg.copy())
|
|
928
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
|
+
|
|
929
1837
|
def _update_previews(self):
|
|
930
|
-
|
|
931
|
-
if
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
self.labelHF.setPixmap(scaled)
|
|
949
|
-
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())
|
|
950
1856
|
else:
|
|
951
|
-
|
|
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())
|
|
952
1877
|
|
|
953
1878
|
# center if smaller than viewport
|
|
954
1879
|
QTimer.singleShot(0, self._center_if_fit)
|
|
@@ -1081,15 +2006,15 @@ class FrequencySeperationTab(QWidget):
|
|
|
1081
2006
|
# keep signed HF; app stack supports float32 arrays
|
|
1082
2007
|
return self.high_freq_image.astype(np.float32, copy=False)
|
|
1083
2008
|
|
|
1084
|
-
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."""
|
|
1085
2011
|
if self.low_freq_image is None or self.high_freq_image is None:
|
|
1086
2012
|
QMessageBox.information(self, "Combine", "LF or HF missing.")
|
|
1087
|
-
return
|
|
2013
|
+
return None
|
|
1088
2014
|
|
|
1089
2015
|
combined = np.clip(self.low_freq_image + self.high_freq_image, 0.0, 1.0).astype(np.float32)
|
|
1090
|
-
step_name = "Frequency Separation (Combine HF+LF)"
|
|
1091
2016
|
|
|
1092
|
-
#
|
|
2017
|
+
# Blend with active mask (if any)
|
|
1093
2018
|
blended, mid, mname, masked = self._blend_with_active_mask(combined)
|
|
1094
2019
|
|
|
1095
2020
|
# Build metadata
|
|
@@ -1105,71 +2030,100 @@ class FrequencySeperationTab(QWidget):
|
|
|
1105
2030
|
"mask_name": mname,
|
|
1106
2031
|
"mask_blend": "m*out + (1-m)*src",
|
|
1107
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
|
|
1108
2056
|
|
|
1109
|
-
#
|
|
2057
|
+
# Fallback: try the injected doc
|
|
1110
2058
|
if isinstance(self.doc, ImageDocument):
|
|
1111
2059
|
try:
|
|
1112
2060
|
self.doc.apply_edit(blended, metadata=md, step_name=step_name)
|
|
1113
2061
|
except Exception as e:
|
|
1114
|
-
QMessageBox.critical(self, "Apply Failed", f"Could not apply to
|
|
2062
|
+
QMessageBox.critical(self, "Apply Failed", f"Could not apply to document:\n{e}")
|
|
1115
2063
|
return
|
|
1116
2064
|
|
|
1117
|
-
#
|
|
2065
|
+
# Last fallback: push to active via DocManager
|
|
1118
2066
|
self._push_to_active(blended, step_name, extra_md=md)
|
|
1119
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
|
+
|
|
1120
2076
|
# --------------- event filter (wheel + drag pan + sync) ---------------
|
|
1121
2077
|
def eventFilter(self, obj, ev):
|
|
1122
|
-
# --------
|
|
2078
|
+
# -------- Mouse Wheel Zoom --------
|
|
1123
2079
|
if ev.type() == QEvent.Type.Wheel:
|
|
1124
2080
|
targets = {self.scrollHF.viewport(), self.labelHF,
|
|
1125
2081
|
self.scrollLF.viewport(), self.labelLF}
|
|
1126
2082
|
if obj in targets:
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
dy = ev.
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
# Not Ctrl: let the scroll area do normal scrolling
|
|
1172
|
-
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
|
|
1173
2127
|
|
|
1174
2128
|
# -------- Drag-pan inside each viewport (sync the other) --------
|
|
1175
2129
|
if obj in (self.scrollHF.viewport(), self.scrollLF.viewport()):
|
|
@@ -1346,4 +2300,4 @@ class SelectViewDialog(QDialog):
|
|
|
1346
2300
|
|
|
1347
2301
|
def selected_doc(self):
|
|
1348
2302
|
idx = self.combo.currentIndex()
|
|
1349
|
-
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
|