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.

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {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, 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)
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.image.ndim == 3 and self.image.shape[2] == 3:
48
- if cv2 is None:
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
- 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))
53
117
 
54
- if self.method == 'Gaussian':
55
- if cv2 is None:
56
- raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
57
- low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
58
- elif self.method == 'Median':
59
- if cv2 is None:
60
- raise RuntimeError("OpenCV (cv2) is required for median blur.")
61
- ksize = max(1, int(self.radius) // 2 * 2 + 1)
62
- low_bgr = cv2.medianBlur(bgr, ksize)
63
- elif self.method == 'Bilateral':
64
- if cv2 is None:
65
- raise RuntimeError("OpenCV (cv2) is required for bilateral filter.")
66
- sigma = 50.0 * (self.tolerance / 100.0)
67
- d = max(1, int(self.radius))
68
- 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()
69
177
  else:
70
- # fallback
71
- if cv2 is None:
72
- raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
73
- 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))
74
206
 
75
- if low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
76
- low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
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
- 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)
79
240
 
80
- high_rgb = self.image - low_rgb # keep signed HF
81
- self.separation_done.emit(low_rgb.astype(np.float32), high_rgb.astype(np.float32))
82
- except Exception as e:
83
- 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
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
- 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
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
- 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)
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
- coeffs = pywt.wavedec2(mono, wavelet=wavelet, level=level, mode='periodization')
146
- new_coeffs = [coeffs[0]]
147
- for (cH, cV, cD) in coeffs[1:]:
148
- new_coeffs.append((cH * boost, cV * boost, cD * boost))
149
- rec = pywt.waverec2(new_coeffs, wavelet=wavelet, mode='periodization')
150
-
151
- # shape guard
152
- if rec.shape != mono.shape:
153
- h, w = mono.shape[:2]
154
- rec = rec[:h, :w]
155
- return rec
156
-
157
- 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."""
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._auto_loaded = False
596
+ self._source_doc = None # Track the source document separately
597
+ self._cuda_warmed_up = False
218
598
  self._build_ui()
219
599
 
220
- if self.doc is not None and getattr(self.doc, "image", None) is not None:
221
- # Preload immediately; avoids any focus/MDI ambiguity
222
- self.set_image_from_doc(np.asarray(self.doc.image),
223
- getattr(self.doc, "metadata", {}))
224
- self._auto_loaded = True
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
- btn_apply = QPushButton(self.tr("Apply - Split HF & LF"), self)
263
- btn_apply.clicked.connect(self._apply_separation)
264
- 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)
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); 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)
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); 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)
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); 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)
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); self.btn_push_lf.clicked.connect(lambda: self._push_array(self.low_freq_image, "LF"))
319
- 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"))
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
- btn_combine_push = QPushButton(self.tr("Combine HF+LF -> Push"), self)
346
- btn_combine_push.clicked.connect(self._combine_and_push)
347
- 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)
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 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")
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
- docs = list(d)
560
- 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
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 (use weighted luma for consistency, or mean if desired. Original was mean)
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
- # Broadcast mono to 3 channels without copying
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 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."""
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
- self.fileLabel.setText(os.path.basename(self.filename) if self.filename else "(from view)")
823
- # 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)
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
- 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()
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
- a = np.clip(arr, 0, 1)
922
- if a.ndim == 2:
923
- a = np.stack([a]*3, axis=-1)
924
- u8 = (a * 255).astype(np.uint8)
925
- h, w, ch = u8.shape
926
- 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)
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
- # LF
931
- if self.low_freq_image is not None:
932
- pm = self._numpy_to_qpix(self.low_freq_image)
933
- scaled = pm.scaled(pm.size() * self.zoom_factor,
934
- Qt.AspectRatioMode.KeepAspectRatio,
935
- Qt.TransformationMode.SmoothTransformation)
936
- self.labelLF.setPixmap(scaled)
937
- self.labelLF.resize(scaled.size())
938
- else:
939
- self.labelLF.setText("Low Frequency"); self.labelLF.resize(self.labelLF.sizeHint())
940
-
941
- # HF (offset +0.5 for view)
942
- if self.high_freq_image is not None:
943
- disp = np.clip(self.high_freq_image + 0.5, 0, 1)
944
- pm = self._numpy_to_qpix(disp)
945
- scaled = pm.scaled(pm.size() * self.zoom_factor,
946
- Qt.AspectRatioMode.KeepAspectRatio,
947
- Qt.TransformationMode.SmoothTransformation)
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
- 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())
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 _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."""
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
- # Blend with active mask (if any)
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
- # Prefer applying to the injected ImageDocument
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 active document:\n{e}")
2062
+ QMessageBox.critical(self, "Apply Failed", f"Could not apply to document:\n{e}")
1115
2063
  return
1116
2064
 
1117
- # Fallback: push to active via DocManager (still pre-blended)
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
- # -------- Ctrl+Wheel Zoom (safe) --------
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
- # Only zoom when Ctrl is held; otherwise let normal scrolling work
1128
- if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1129
- try:
1130
- dy = ev.pixelDelta().y()
1131
- if dy == 0:
1132
- dy = ev.angleDelta().y()
1133
- factor = 1.25 if dy > 0 else 0.8
1134
-
1135
- # Anchor positions (robust mapping child→viewport)
1136
- if obj is self.labelHF:
1137
- anchor_hf = self.labelHF.mapTo(
1138
- self.scrollHF.viewport(), ev.position().toPoint()
1139
- )
1140
- anchor_lf = QPoint(
1141
- self.scrollLF.viewport().width() // 2,
1142
- self.scrollLF.viewport().height() // 2
1143
- )
1144
- elif obj is self.scrollHF.viewport():
1145
- anchor_hf = ev.position().toPoint()
1146
- anchor_lf = QPoint(
1147
- self.scrollLF.viewport().width() // 2,
1148
- self.scrollLF.viewport().height() // 2
1149
- )
1150
- elif obj is self.labelLF:
1151
- anchor_lf = self.labelLF.mapTo(
1152
- self.scrollLF.viewport(), ev.position().toPoint()
1153
- )
1154
- anchor_hf = QPoint(
1155
- self.scrollHF.viewport().width() // 2,
1156
- self.scrollHF.viewport().height() // 2
1157
- )
1158
- else: # obj is self.scrollLF.viewport()
1159
- anchor_lf = ev.position().toPoint()
1160
- anchor_hf = QPoint(
1161
- self.scrollHF.viewport().width() // 2,
1162
- self.scrollHF.viewport().height() // 2
1163
- )
1164
-
1165
- self._zoom_at_pair(factor, anchor_hf, anchor_lf)
1166
- except Exception:
1167
- # If anything goes weird (trackpad/gesture edge cases), center-zoom safely
1168
- self._zoom_at_pair(1.25 if (ev.angleDelta().y() if hasattr(ev, "angleDelta") else 1) > 0 else 0.8)
1169
- ev.accept()
1170
- return True
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