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.

Files changed (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {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, parent=None):
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
- try:
45
- self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
46
- except Exception:
47
- pass # older PyQt6 versions
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.image.ndim == 3 and self.image.shape[2] == 3:
51
- if cv2 is None:
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
- bgr = self.image.copy()
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
- if self.method == 'Gaussian':
58
- if cv2 is None:
59
- raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
60
- low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
61
- elif self.method == 'Median':
62
- if cv2 is None:
63
- raise RuntimeError("OpenCV (cv2) is required for median blur.")
64
- ksize = max(1, int(self.radius) // 2 * 2 + 1)
65
- low_bgr = cv2.medianBlur(bgr, ksize)
66
- elif self.method == 'Bilateral':
67
- if cv2 is None:
68
- raise RuntimeError("OpenCV (cv2) is required for bilateral filter.")
69
- sigma = 50.0 * (self.tolerance / 100.0)
70
- d = max(1, int(self.radius))
71
- low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
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
- if cv2 is None:
75
- raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
76
- low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
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 low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
79
- low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
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
- low_rgb = low_bgr
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
- high_rgb = self.image - low_rgb # keep signed HF
84
- self.separation_done.emit(low_rgb.astype(np.float32), high_rgb.astype(np.float32))
85
- except Exception as e:
86
- self.error_signal.emit(str(e))
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
- out *= self.sharpen_scale
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
- out = self._denoise_hf(out, self.denoise_strength)
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
- coeffs = pywt.wavedec2(mono, wavelet=wavelet, level=level, mode='periodization')
149
- new_coeffs = [coeffs[0]]
150
- for (cH, cV, cD) in coeffs[1:]:
151
- new_coeffs.append((cH * boost, cV * boost, cD * boost))
152
- rec = pywt.waverec2(new_coeffs, wavelet=wavelet, mode='periodization')
153
-
154
- # shape guard
155
- if rec.shape != mono.shape:
156
- h, w = mono.shape[:2]
157
- rec = rec[:h, :w]
158
- return rec
159
-
160
- def _denoise_hf(self, hf, strength=3.0):
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._auto_loaded = False
596
+ self._source_doc = None # Track the source document separately
597
+ self._cuda_warmed_up = False
221
598
  self._build_ui()
222
599
 
223
- if self.doc is not None and getattr(self.doc, "image", None) is not None:
224
- # Preload immediately; avoids any focus/MDI ambiguity
225
- self.set_image_from_doc(np.asarray(self.doc.image),
226
- getattr(self.doc, "metadata", {}))
227
- self._auto_loaded = True
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
- btn_apply = QPushButton(self.tr("Apply - Split HF & LF"), self)
266
- btn_apply.clicked.connect(self._apply_separation)
267
- left.addWidget(btn_apply)
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); left.addWidget(self.cb_scale)
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); left.addWidget(self.cb_wavelet)
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); left.addWidget(self.cb_denoise)
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); self.btn_push_lf.clicked.connect(lambda: self._push_array(self.low_freq_image, "LF"))
322
- self.btn_push_hf = QPushButton(self.tr("Push HF"), self); self.btn_push_hf.clicked.connect(lambda: self._push_array(self._hf_display_for_push(), "HF"))
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
- btn_combine_push = QPushButton(self.tr("Combine HF+LF -> Push"), self)
349
- btn_combine_push.clicked.connect(self._combine_and_push)
350
- left.addWidget(btn_combine_push)
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 Preview")
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
- docs = list(d)
563
- break
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 (use weighted luma for consistency, or mean if desired. Original was mean)
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
- # Broadcast mono to 3 channels without copying
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 theres an active image; or adapt to your ImageManager signal."""
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
- self.fileLabel.setText(os.path.basename(self.filename) if self.filename else "(from view)")
826
- # clear outputs
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
- self.high_freq_image = new_hf.astype(np.float32)
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
- a = np.clip(arr, 0, 1)
925
- if a.ndim == 2:
926
- a = np.stack([a]*3, axis=-1)
927
- u8 = (a * 255).astype(np.uint8)
928
- h, w, ch = u8.shape
929
- qimg = QImage(u8.data, w, h, w*ch, QImage.Format.Format_RGB888)
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
- # LF
934
- if self.low_freq_image is not None:
935
- pm = self._numpy_to_qpix(self.low_freq_image)
936
- scaled = pm.scaled(pm.size() * self.zoom_factor,
937
- Qt.AspectRatioMode.KeepAspectRatio,
938
- Qt.TransformationMode.SmoothTransformation)
939
- self.labelLF.setPixmap(scaled)
940
- self.labelLF.resize(scaled.size())
941
- else:
942
- self.labelLF.setText("Low Frequency"); self.labelLF.resize(self.labelLF.sizeHint())
943
-
944
- # HF (offset +0.5 for view)
945
- if self.high_freq_image is not None:
946
- disp = np.clip(self.high_freq_image + 0.5, 0, 1)
947
- pm = self._numpy_to_qpix(disp)
948
- scaled = pm.scaled(pm.size() * self.zoom_factor,
949
- Qt.AspectRatioMode.KeepAspectRatio,
950
- Qt.TransformationMode.SmoothTransformation)
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
- self.labelHF.setText("High Frequency"); self.labelHF.resize(self.labelHF.sizeHint())
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 _combine_and_push(self):
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
- # Blend with active mask (if any)
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
- # Prefer applying to the injected ImageDocument
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 active document:\n{e}")
2062
+ QMessageBox.critical(self, "Apply Failed", f"Could not apply to document:\n{e}")
1118
2063
  return
1119
2064
 
1120
- # Fallback: push to active via DocManager (still pre-blended)
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
- # -------- Ctrl+Wheel Zoom (safe) --------
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
- # Only zoom when Ctrl is held; otherwise let normal scrolling work
1131
- if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1132
- try:
1133
- dy = ev.pixelDelta().y()
1134
- if dy == 0:
1135
- dy = ev.angleDelta().y()
1136
- factor = 1.25 if dy > 0 else 0.8
1137
-
1138
- # Anchor positions (robust mapping child→viewport)
1139
- if obj is self.labelHF:
1140
- anchor_hf = self.labelHF.mapTo(
1141
- self.scrollHF.viewport(), ev.position().toPoint()
1142
- )
1143
- anchor_lf = QPoint(
1144
- self.scrollLF.viewport().width() // 2,
1145
- self.scrollLF.viewport().height() // 2
1146
- )
1147
- elif obj is self.scrollHF.viewport():
1148
- anchor_hf = ev.position().toPoint()
1149
- anchor_lf = QPoint(
1150
- self.scrollLF.viewport().width() // 2,
1151
- self.scrollLF.viewport().height() // 2
1152
- )
1153
- elif obj is self.labelLF:
1154
- anchor_lf = self.labelLF.mapTo(
1155
- self.scrollLF.viewport(), ev.position().toPoint()
1156
- )
1157
- anchor_hf = QPoint(
1158
- self.scrollHF.viewport().width() // 2,
1159
- self.scrollHF.viewport().height() // 2
1160
- )
1161
- else: # obj is self.scrollLF.viewport()
1162
- anchor_lf = ev.position().toPoint()
1163
- anchor_hf = QPoint(
1164
- self.scrollHF.viewport().width() // 2,
1165
- self.scrollHF.viewport().height() // 2
1166
- )
1167
-
1168
- self._zoom_at_pair(factor, anchor_hf, anchor_lf)
1169
- except Exception:
1170
- # If anything goes weird (trackpad/gesture edge cases), center-zoom safely
1171
- self._zoom_at_pair(1.25 if (ev.angleDelta().y() if hasattr(ev, "angleDelta") else 1) > 0 else 0.8)
1172
- ev.accept()
1173
- return True
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