Split an image into Low Frequency (smooth tones, gradients, nebulosity) and High Frequency (fine details, edges, stars, noise) components, process each independently, and recombine. CUDA-accelerated end-to-end when a GPU is available, with a clean fallback to CPU on systems without one.
The idea is older than digital photography. Blur the image and you've got the low-frequency component; subtract that blur from the original and you've got the high-frequency component. Everything that makes an image look “sharp” or “noisy” lives in the HF layer; everything that makes it look “smooth” or “milky” lives in the LF layer. Decoupling them lets you sharpen details without touching nebular smoothness, denoise small-scale noise without softening gradients, or boost structure at a specific spatial scale with a wavelet pass — moves that would be destructive if applied to the composite.
Three blur kernels for the LF generation:
K = 2 ⌊r/2⌋
+ 1). Good when you want HF to carry small-scale noise
but not star cores or sharp galaxy edges.The slider maps non-linearly from 1–100 to a radius in 0.1–100 pixels, in three regimes for fine control at small radii:
The upshot:
Range 0–100%, default 50%. Controls the bilateral filter's edge-sensitivity sigma:
Runs the chosen blur and the subtraction. The two previews on the right update when it finishes. The arrow button next to Apply is Undo Split — it restores the LF/HF from before the most recent split, with a history up to the capacity of your memory. Useful for comparing two radius settings quickly.
Everything in this group operates on the HF buffer only. The LF is never touched by any enhancement. All three enhancements can be combined in one Apply pass, or applied in several passes with Undo in between.
Simple multiplicative gain on HF. Range 0.10–3.00, default 1.00. The checkbox enables or disables it:
Values below 1 soften; above 1 sharpen. Because HF is signed and roughly centred on zero, scaling it symmetrically brightens dark and bright details equally — which is what you want for detail enhancement.
Multi-scale enhancement using a db2 (Daubechies-2) wavelet decomposition. Two parameters:
cH, cV, cD) at every
level simultaneously.The decomposition and reconstruction use the
periodization mode. The level is auto-clamped to the
maximum supported by the image dimensions, so a very small image
with Level=5 won't crash. Wavelet sharpening runs on each colour
channel independently.
Why wavelet over simple scale? Scale lifts all HF content uniformly. Wavelet lifts detail at every spatial scale (as decomposed) but leaves the approximation untouched. The result is less halo-prone and more musically balanced than a plain HF multiplier.
Non-Local Means denoise applied to the HF layer only. Because HF is signed, the implementation shifts it by +0.5 to fit in [0,1], runs the denoise, then shifts back:
Strength range 0–5, default 3. Uses OpenCV's
fastNlMeansDenoising (grayscale) or
fastNlMeansDenoisingColored (RGB), block size 7, search
window 21.
Because the LF layer is untouched, this is a clean way to denoise without the usual “plasticky” look — nebular gradients are preserved exactly, and only fine-scale noise in HF gets smoothed.
Runs scale → wavelet → denoise in that order on the current HF, overwriting it. The Undo button next to it rolls back to the previous HF state, with a history for repeated applications.
Pick any currently open SASpro view as the source. The dialog remembers which document you loaded from so Combine → Update later writes back there, regardless of what's active now.
Opens the current LF or HF buffer as a new SASpro document titled “LF” or “HF”. The HF push preserves the signed values (it does not add the +0.5 display offset), so you can round-trip it back in without losing information. Useful for processing one layer through another tool and bringing it back.
Replaces the current LF or HF layer with an image from another open view. SASpro reconciles channels automatically — mono input stacks to RGB if the base is colour, RGB input averages to mono if the base is mono — and refuses size mismatches with a clear error. This is the other half of the round-trip workflow: push HF out, process it elsewhere, load it back.
Two paths for the final combine:
apply_edit if available (with
history + metadata recorded as “Frequency Separation
(Combine HF+LF)”). Falls back through several document
methods before giving up with a clear error.Both run the same math:
Toggle at the bottom of the left panel. When on, the HF preview hides and the LF panel shows the combined result instead. Lets you check the final look before committing, without having to apply. Combined pixmap is cached, so toggling it is instant after the first paint.
If the source document has an active mask, Combine blends the new result against the original source using the mask:
So the frequency-separated edit is confined to wherever the mask is active, and un-processed source pixels fill the rest. The mask is nearest-neighbour resampled if its dimensions don't match the target. Metadata records that the output was masked, with the mask ID and name preserved.
Two side-by-side scroll areas. HF left, LF right — or just the combined preview on the left if Preview Combined is on.
Scaled pixmaps are cached per panel at the current zoom, so panning is cheap — the rescale only happens when the zoom changes or the underlying buffer changes.
HF displayed with +0.5 offset. HF is a signed difference image centred near zero. To make it viewable, the preview adds 0.5 before converting to display. This affects the preview only; the actual HF buffer is signed and all math uses it as such.
GPU is only used when the image is at least 500,000 pixels (roughly 700×700) — below that, PCIe transfer overhead costs more than the compute saves. The checkbox lets you force CPU if you want to compare timings or if something CUDA-specific misbehaves.
CUDA warm-up on first show. Opening the tool runs a few dummy GPU operations to initialise the CUDA context, so the first real Apply doesn't suffer the usual multi-second first-call delay. The first HF denoise is special-cased to run synchronously on the main thread the first time, because OpenCV's CUDA NLM has a crash-on-background-thread bug the first time it's called in a process.
A few things this decomposition unlocks that are hard without it:
Everything in float32. HF stored signed, roughly in [−0.5, 0.5]. All processing runs in a QThread with a spinner on the main thread. CUDA path uses OpenCV's cv2.cuda API; CPU path uses regular OpenCV + NumPy. Wavelet transform via PyWavelets.
Core decomposition and recomposition:
When no HF enhancement has been applied, I' = I
exactly (the split is lossless up to floating-point round-off).
OpenCV's Gaussian kernel is sized relative to sigma:
(rounded up to the nearest odd integer). The CPU path lets OpenCV
pick K automatically by passing (0, 0)
for the kernel size; the CUDA path requires an explicit size.
Odd-integer kernel from the radius:
Minimum 1 (identity).
OpenCV's CUDA Gaussian and Bilateral filters have a maximum
kernel size of 31. For sigmas that would require a bigger kernel,
the implementation uses the Gaussian-stacking identity: applying
the same Gaussian blur N times with per-pass sigma
σ_pass is equivalent to a single blur of
sigma:
So for a target sigma, SASpro picks:
with σ_max = (31-1)/6 = 5.
Up to 25 passes are allowed; beyond that it falls back to the CPU
path. Bilateral and Median use the same multi-pass technique with
σ_pass = σ/√N and a per-pass
kernel chosen to stay under the CUDA cap, with a 10-pass ceiling
before falling back.
Why this works for Gaussian. The convolution
of two Gaussians with sigmas σ_1 and
σ_2 is a Gaussian with sigma
√(σ_1² + σ_2²). Repeating
N times with equal sigmas gives
σ_total = √N · σ_pass. The
identity is exact for Gaussian; it's an approximation for Bilateral
and Median but close enough in practice.
Tolerance slider percentage maps to the bilateral spatial sigma and colour sigma (both set equal in this implementation):
The filter diameter d is taken directly from the
radius slider.
Using PyWavelets' wavedec2 at the chosen level
with the db2 wavelet and periodization
mode. The decomposition expresses the image as an approximation
plus a sum of detail coefficient triplets at each scale:
where A_N is the lowest-resolution approximation and
c^H, c^V, c^D are horizontal, vertical, and diagonal
detail coefficients at each level k. The boost
multiplier scales all three detail planes at every level
uniformly:
The approximation A_N is left unchanged, so the
mean level of the HF is preserved. If the chosen Level exceeds
dwt_max_level for the image size, SASpro clamps it
silently.
OpenCV's NLM operates on 8-bit input, so the signed HF is shifted and scaled:
runs through fastNlMeansDenoising (grayscale) or
fastNlMeansDenoisingColored (RGB) at the user's
strength h, template window 7, search window 21:
The quantisation to 8-bit limits precision slightly (one 8-bit code value equals ±0.004 in HF space), which is below the noise floor for virtually every real astroimage.
With an active mask m in [0, 1]:
Mask is nearest-neighbour resampled if its dimensions don't match. Integer-type masks are rescaled to [0, 1] by their dtype max; float masks with max > 1 are rescaled by the maximum. The source image for the blend is read fresh from the document, so if someone edited it between load and Combine the blend uses the current version.
GPU is used only when the image has at least 500,000 pixels. The
threshold is a measured cross-over — smaller images spend
more time on PCIe transfer than they save on the GPU compute. On
first show, SASpro runs a few dummy upload/download/Gaussian passes
to initialise the CUDA context, so the first user-triggered
operation doesn't hit the usual multi-second JIT delay. HF denoise
is special-cased: the first time any CUDA NLM is run in a process,
it must run on the main thread, because the OpenCV CUDA NLM has a
reliable crash when first invoked from a QThread. SASpro tracks
this with a module-level _CUDA_DENOISE_INITIALIZED
flag.
Three layers of cache keep the preview snappy:
LF + HF, invalidated whenever either layer or
an HF enhancement is applied.Pan has no cache impact at all: only scroll bar values change, not the pixmap.
_blend_with_active_mask, and the UI around all of it
are Frank's work for SASpro.