From 9df4747b1e9922b590d429e7a7a143a99aaa9c08 Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Tue, 30 Dec 2025 20:31:06 -0800 Subject: [PATCH 01/15] feat(WASM): add bilateral filter --- src/components/WasmImageProcessor.jsx | 15 ++- src/hooks/useWasmWorker.js | 5 +- src/wasm/modules/image/include/image_utils.h | 3 + .../modules/image/src/bilateral_filter.cpp | 103 ++++++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/wasm/modules/image/src/bilateral_filter.cpp diff --git a/src/components/WasmImageProcessor.jsx b/src/components/WasmImageProcessor.jsx index 715456a1d..3fffba2c3 100644 --- a/src/components/WasmImageProcessor.jsx +++ b/src/components/WasmImageProcessor.jsx @@ -13,7 +13,7 @@ const WasmImageProcessor = () => { const inputId = useId(); const inputRef = useRef(null); - const { gaussianBlur, blackThreshold, kmeans, mergeSmallRegionsInPlace } = useWasmWorker(); + const { bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace } = useWasmWorker(); const [originalSrc, setOriginalSrc] = useState(null); const [fileData, setFileData] = useState(null); @@ -81,12 +81,19 @@ const WasmImageProcessor = () => { const { width, height } = fileData; step(20); - const blurred = await gaussianBlur(fileData); + // NOTE: Gaussian blur destroys the sharp outlines first, preventing the Bilateral filter from detecting and preserving them + // const blurred = await gaussianBlur(fileData); + + const imgBilateralFiltered = await bilateralFilter({ + image: fileData.pixels, + width, + height, + }); step(45); const thresholded = await blackThreshold({ ...fileData, - pixels: blurred, + pixels: imgBilateralFiltered, num_colors: 8, }); @@ -139,7 +146,7 @@ const WasmImageProcessor = () => { step(0); }, 800); } - }, [fileData, gaussianBlur, blackThreshold, kmeans, mergeSmallRegionsInPlace, navigate, step]); + }, [fileData, bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace, navigate, step]); /* Memo'd UI fragments */ const EmptyState = useMemo( diff --git a/src/hooks/useWasmWorker.js b/src/hooks/useWasmWorker.js index 690f2aff5..252e8b793 100644 --- a/src/hooks/useWasmWorker.js +++ b/src/hooks/useWasmWorker.js @@ -35,6 +35,9 @@ export function useWasmWorker() { const gaussianBlur = async ({ pixels, width, height, sigma_pixels = width * 0.005 }) => { return (await call('gaussian_blur_fft', { pixels, width, height, sigma_pixels }, ['pixels'])).output.pixels; }; + const bilateralFilter = async ({ image, width, height, sigma_spatial = 3.0, sigma_range = 50.0 }) => { + return (await call('bilateral_filter', { image, width, height, sigma_spatial, sigma_range }, ['image'])).output.image; + }; const blackThreshold = async ({ pixels, width, height, num_colors }) => { return (await call('black_threshold_image', { pixels, width, height, num_colors }, ['pixels'])).output.pixels; }; @@ -46,5 +49,5 @@ export function useWasmWorker() { .output.pixels; }; - return { call, gaussianBlur, blackThreshold, kmeans, mergeSmallRegionsInPlace }; + return { call, gaussianBlur, bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace }; } diff --git a/src/wasm/modules/image/include/image_utils.h b/src/wasm/modules/image/include/image_utils.h index da86ab524..9c549712b 100644 --- a/src/wasm/modules/image/include/image_utils.h +++ b/src/wasm/modules/image/include/image_utils.h @@ -21,4 +21,7 @@ EXPORTED void threshold_image(uint8_t *ptr, const int width, const int height, EXPORTED void black_threshold_image(uint8_t *ptr, const int width, const int height, const int num_thresholds); +EXPORTED void bilateral_filter(uint8_t *image, size_t width, size_t height, + double sigma_spatial, double sigma_range); + #endif diff --git a/src/wasm/modules/image/src/bilateral_filter.cpp b/src/wasm/modules/image/src/bilateral_filter.cpp new file mode 100644 index 000000000..acd05a9a7 --- /dev/null +++ b/src/wasm/modules/image/src/bilateral_filter.cpp @@ -0,0 +1,103 @@ +#include "image_utils.h" +#include +#include +#include +#include + +static constexpr double SIGMA_RADIUS_FACTOR = 3.0; +static constexpr int MAX_PIXEL_VAL = 255; +// Max possible squared Euclidean distance in a 3-channel 8-bit image: 255^2 * 3 = 195075 +// Means max delta between images (imageA - imageB) in RGB channels (255^2 * 3) +static constexpr int MAX_RGB_DIST_SQ = 255 * 255 * 3; + +/* +The Bilateral Filter applies a composite weight based on both spatial distance and radiometric difference (intensity) to return an image that is smoothed while preserving edges. +It reduces noise in flat regions while preserving edges by assigning near-zero weight to pixels across high-contrast boundaries. + +Parameters: +- image: Pointer to RGBA pixel buffer +- width, height: Image dimensions (px) +- sigma_spatial: Gaussian standard deviation for spatial proximity (spatial decay) +- sigma_range: Gaussian standard deviation for intensity difference (radiometric decay) +*/ +void bilateral_filter(uint8_t *image, size_t width, size_t height, + double sigma_spatial, double sigma_range) { + if (sigma_spatial <= 0.0 || sigma_range <= 0.0) return; + + const int radius = static_cast(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial)); + const int kernel_width = 2 * radius + 1; + const size_t stride = width * 4; + std::vector result(width * height * 4); + + // NOTE: precompute Spatial Weights (Gaussian Kernel) + std::vector spatial_weights(kernel_width * kernel_width); + double two_sigma_space_sq = 2 * sigma_spatial * sigma_spatial; + + for (int ky = -radius; ky <= radius; ++ky) { + for (int kx = -radius; kx <= radius; ++kx) { + double dist2 = static_cast(kx * kx + ky * ky); + spatial_weights[(ky + radius) * kernel_width + (kx + radius)] = + std::exp(-dist2 / two_sigma_space_sq); + } + } + + // NOTE: precompute Range Weights + std::vector range_lut(MAX_RGB_DIST_SQ + 1); + double two_sigma_range_sq = 2 * sigma_range * sigma_range; + + for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) { + range_lut[i] = std::exp(-static_cast(i) / two_sigma_range_sq); + } + + int h = static_cast(height); + int w = static_cast(width); + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + size_t center_idx = (y * width + x) * 4; + + uint8_t r0 = image[center_idx]; + uint8_t g0 = image[center_idx + 1]; + uint8_t b0 = image[center_idx + 2]; + uint8_t a0 = image[center_idx + 3]; + + double r_acc = 0.0, g_acc = 0.0, b_acc = 0.0, weight_acc = 0.0; + + for (int ky = -radius; ky <= radius; ++ky) { + int ny = std::clamp(y + ky, 0, h - 1); + + for (int kx = -radius; kx <= radius; ++kx) { + int nx = std::clamp(x + kx, 0, w - 1); + + size_t neighbor_idx = (ny * width + nx) * 4; + + uint8_t r = image[neighbor_idx]; + uint8_t g = image[neighbor_idx + 1]; + uint8_t b = image[neighbor_idx + 2]; + + double w_space = spatial_weights[(ky + radius) * kernel_width + (kx + radius)]; + + int dr = static_cast(r) - r0; + int dg = static_cast(g) - g0; + int db = static_cast(b) - b0; + int dist_sq = dr*dr + dg*dg + db*db; + + double w_range = range_lut[dist_sq]; + double w = w_space * w_range; + + r_acc += r * w; + g_acc += g * w; + b_acc += b * w; + weight_acc += w; + } + } + + result[center_idx] = static_cast(std::clamp(r_acc / weight_acc, 0.0, 255.0)); + result[center_idx + 1] = static_cast(std::clamp(g_acc / weight_acc, 0.0, 255.0)); + result[center_idx + 2] = static_cast(std::clamp(b_acc / weight_acc, 0.0, 255.0)); + result[center_idx + 3] = a0; + } + } + + std::memcpy(image, result.data(), result.size()); +} From 073e6f8dd9f08c57e167cdf6d29d281fc318bab7 Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Tue, 30 Dec 2025 20:44:16 -0800 Subject: [PATCH 02/15] test: add call method test, and custom parameters test --- src/hooks/useWasmWorker.test.js | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/hooks/useWasmWorker.test.js b/src/hooks/useWasmWorker.test.js index ab3d00f3e..266170747 100644 --- a/src/hooks/useWasmWorker.test.js +++ b/src/hooks/useWasmWorker.test.js @@ -202,6 +202,59 @@ describe('useWasmWorker', () => { }); }); + describe('bilateralFilter', () => { + it('should call worker with bilateral_filter function', async () => { + const { result } = renderHook(() => useWasmWorker()); + + const image = new Uint8ClampedArray([255, 0, 0, 255]); + const width = 1; + const height = 1; + + act(() => { + result.current.bilateralFilter({ image, width, height }); + }); + + expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + funcName: 'bilateral_filter', + args: expect.objectContaining({ + image, + width, + height, + sigma_spatial: 3.0, + sigma_range: 50.0, + }), + bufferKeys: ['image'], + }) + ); + }); + + it('should use custom sigma_spatial and sigma_range when provided', async () => { + const { result } = renderHook(() => useWasmWorker()); + + const image = new Uint8ClampedArray([255, 0, 0, 255]); + + act(() => { + result.current.bilateralFilter({ + image, + width: 100, + height: 100, + sigma_spatial: 5.0, + sigma_range: 25.0, + }); + }); + + expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.objectContaining({ + sigma_spatial: 5.0, + sigma_range: 25.0, + }), + }) + ); + }); + }); + describe('blackThreshold', () => { it('should call worker with black_threshold_image function', async () => { const { result } = renderHook(() => useWasmWorker()); From 227dd6a437bd4e0f4af41f17e6e92f878ff9994a Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Tue, 30 Dec 2025 20:56:53 -0800 Subject: [PATCH 03/15] refactor: rename image parameter to pixels to match lib conventions --- src/components/WasmImageProcessor.jsx | 2 +- src/hooks/useWasmWorker.js | 4 ++-- src/hooks/useWasmWorker.test.js | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/WasmImageProcessor.jsx b/src/components/WasmImageProcessor.jsx index 3fffba2c3..c347c3ded 100644 --- a/src/components/WasmImageProcessor.jsx +++ b/src/components/WasmImageProcessor.jsx @@ -85,7 +85,7 @@ const WasmImageProcessor = () => { // const blurred = await gaussianBlur(fileData); const imgBilateralFiltered = await bilateralFilter({ - image: fileData.pixels, + pixels: fileData.pixels, width, height, }); diff --git a/src/hooks/useWasmWorker.js b/src/hooks/useWasmWorker.js index 252e8b793..0a0e480d0 100644 --- a/src/hooks/useWasmWorker.js +++ b/src/hooks/useWasmWorker.js @@ -35,8 +35,8 @@ export function useWasmWorker() { const gaussianBlur = async ({ pixels, width, height, sigma_pixels = width * 0.005 }) => { return (await call('gaussian_blur_fft', { pixels, width, height, sigma_pixels }, ['pixels'])).output.pixels; }; - const bilateralFilter = async ({ image, width, height, sigma_spatial = 3.0, sigma_range = 50.0 }) => { - return (await call('bilateral_filter', { image, width, height, sigma_spatial, sigma_range }, ['image'])).output.image; + const bilateralFilter = async ({ pixels, width, height, sigma_spatial = 3.0, sigma_range = 50.0 }) => { + return (await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range }, ['pixels'])).output.pixels; }; const blackThreshold = async ({ pixels, width, height, num_colors }) => { return (await call('black_threshold_image', { pixels, width, height, num_colors }, ['pixels'])).output.pixels; diff --git a/src/hooks/useWasmWorker.test.js b/src/hooks/useWasmWorker.test.js index 266170747..ba3adffbb 100644 --- a/src/hooks/useWasmWorker.test.js +++ b/src/hooks/useWasmWorker.test.js @@ -206,25 +206,25 @@ describe('useWasmWorker', () => { it('should call worker with bilateral_filter function', async () => { const { result } = renderHook(() => useWasmWorker()); - const image = new Uint8ClampedArray([255, 0, 0, 255]); + const pixels = new Uint8ClampedArray([255, 0, 0, 255]); const width = 1; const height = 1; act(() => { - result.current.bilateralFilter({ image, width, height }); + result.current.bilateralFilter({ pixels, width, height }); }); expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith( expect.objectContaining({ funcName: 'bilateral_filter', args: expect.objectContaining({ - image, + pixels, width, height, sigma_spatial: 3.0, sigma_range: 50.0, }), - bufferKeys: ['image'], + bufferKeys: ['pixels'], }) ); }); @@ -232,11 +232,11 @@ describe('useWasmWorker', () => { it('should use custom sigma_spatial and sigma_range when provided', async () => { const { result } = renderHook(() => useWasmWorker()); - const image = new Uint8ClampedArray([255, 0, 0, 255]); + const pixels = new Uint8ClampedArray([255, 0, 0, 255]); act(() => { result.current.bilateralFilter({ - image, + pixels, width: 100, height: 100, sigma_spatial: 5.0, From 5c3b6c4fb65e87583eb3aa568f6c7129a4d53a9b Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Tue, 30 Dec 2025 21:01:00 -0800 Subject: [PATCH 04/15] test: add bilateralFilter as part of helper methods return test --- src/hooks/useWasmWorker.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useWasmWorker.test.js b/src/hooks/useWasmWorker.test.js index ba3adffbb..54da58936 100644 --- a/src/hooks/useWasmWorker.test.js +++ b/src/hooks/useWasmWorker.test.js @@ -59,12 +59,14 @@ describe('useWasmWorker', () => { expect(result.current).toHaveProperty('call'); expect(result.current).toHaveProperty('gaussianBlur'); + expect(result.current).toHaveProperty('bilateralFilter'); expect(result.current).toHaveProperty('blackThreshold'); expect(result.current).toHaveProperty('kmeans'); expect(result.current).toHaveProperty('mergeSmallRegionsInPlace'); expect(typeof result.current.call).toBe('function'); expect(typeof result.current.gaussianBlur).toBe('function'); + expect(typeof result.current.bilateralFilter).toBe('function'); expect(typeof result.current.blackThreshold).toBe('function'); expect(typeof result.current.kmeans).toBe('function'); expect(typeof result.current.mergeSmallRegionsInPlace).toBe('function'); From 009e0a77dbbe6b5f1f6165781fc6041334cbc929 Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Tue, 30 Dec 2025 21:01:37 -0800 Subject: [PATCH 05/15] refactor: remove unused variable MAX_PIXEL_VAL --- src/wasm/modules/image/src/bilateral_filter.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wasm/modules/image/src/bilateral_filter.cpp b/src/wasm/modules/image/src/bilateral_filter.cpp index acd05a9a7..e82da0c7e 100644 --- a/src/wasm/modules/image/src/bilateral_filter.cpp +++ b/src/wasm/modules/image/src/bilateral_filter.cpp @@ -5,7 +5,6 @@ #include static constexpr double SIGMA_RADIUS_FACTOR = 3.0; -static constexpr int MAX_PIXEL_VAL = 255; // Max possible squared Euclidean distance in a 3-channel 8-bit image: 255^2 * 3 = 195075 // Means max delta between images (imageA - imageB) in RGB channels (255^2 * 3) static constexpr int MAX_RGB_DIST_SQ = 255 * 255 * 3; From df4060136e736f5395b924af0ceb7c65583f4a6d Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Wed, 31 Dec 2025 08:56:41 -0800 Subject: [PATCH 06/15] feat: add headers to be used by WASM (best practice) --- .../modules/image/include/bilateral_filter.h | 20 +++++++++++++++++++ .../modules/image/src/bilateral_filter.cpp | 16 +++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/wasm/modules/image/include/bilateral_filter.h diff --git a/src/wasm/modules/image/include/bilateral_filter.h b/src/wasm/modules/image/include/bilateral_filter.h new file mode 100644 index 000000000..b05b824b4 --- /dev/null +++ b/src/wasm/modules/image/include/bilateral_filter.h @@ -0,0 +1,20 @@ +#ifndef BILATERAL_FILTER_H +#define BILATERAL_FILTER_H + +#include // for size_t +#include // for uint8_t + +namespace bilateral { + +// Apply bilateral filter to an image. +// Parameters: +// - image: Pointer to RGBA pixel buffer +// - width, height: Image dimensions (px) +// - sigma_spatial: Gaussian standard deviation for spatial proximity (spatial decay) +// - sigma_range: Gaussian standard deviation for intensity difference (radiometric decay) +void bilateral_filter(uint8_t *image, size_t width, size_t height, + double sigma_spatial, double sigma_range); + +} // namespace bilateral + +#endif // BILATERAL_FILTER_H diff --git a/src/wasm/modules/image/src/bilateral_filter.cpp b/src/wasm/modules/image/src/bilateral_filter.cpp index e82da0c7e..89c79321b 100644 --- a/src/wasm/modules/image/src/bilateral_filter.cpp +++ b/src/wasm/modules/image/src/bilateral_filter.cpp @@ -1,8 +1,13 @@ -#include "image_utils.h" +#include "bilateral_filter.h" +#include "exported.h" + #include #include #include #include +#include + +namespace bilateral { static constexpr double SIGMA_RADIUS_FACTOR = 3.0; // Max possible squared Euclidean distance in a 3-channel 8-bit image: 255^2 * 3 = 195075 @@ -25,7 +30,6 @@ void bilateral_filter(uint8_t *image, size_t width, size_t height, const int radius = static_cast(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial)); const int kernel_width = 2 * radius + 1; - const size_t stride = width * 4; std::vector result(width * height * 4); // NOTE: precompute Spatial Weights (Gaussian Kernel) @@ -100,3 +104,11 @@ void bilateral_filter(uint8_t *image, size_t width, size_t height, std::memcpy(image, result.data(), result.size()); } + +} // namespace bilateral + +// Global wrapper for WASM export +EXPORTED void bilateral_filter(uint8_t *image, size_t width, size_t height, + double sigma_spatial, double sigma_range) { + bilateral::bilateral_filter(image, width, height, sigma_spatial, sigma_range); +} From 9f30fb0ffaa2c4d397a0b7bc5ae049c44df32fd2 Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Wed, 31 Dec 2025 09:28:01 -0800 Subject: [PATCH 07/15] docs(WASM): add bilateral_filter documentation (overview, explained, implementation, and api) --- .../image/bilateral_filter/_category_.json | 10 +++ .../modules/image/bilateral_filter/api.md | 29 ++++++++ .../image/bilateral_filter/explained.md | 65 +++++++++++++++++ .../image/bilateral_filter/implementation.md | 73 +++++++++++++++++++ .../image/bilateral_filter/overview.md | 31 ++++++++ .../reference/wasm/modules/image/overview.md | 2 + 6 files changed, 210 insertions(+) create mode 100644 docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json create mode 100644 docs/docs/reference/wasm/modules/image/bilateral_filter/api.md create mode 100644 docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md create mode 100644 docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md create mode 100644 docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json b/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json new file mode 100644 index 000000000..2517950d2 --- /dev/null +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json @@ -0,0 +1,10 @@ +{ + "label": "bilateral_filter.h", + "position": 2, + "link": { + "type": "generated-index", + "title": "Bilateral Filter", + "description": "Documentation for the Bilateral Filter in the Image WebAssembly (WASM) module in Img2Num.", + "slug": "/reference/wasm/modules/image/bilateral_filter" + } +} \ No newline at end of file diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md new file mode 100644 index 000000000..3451cc049 --- /dev/null +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md @@ -0,0 +1,29 @@ +--- +id: api +title: Bilateral Filter — API & Reference +sidebar_label: API / Usage +sidebar_position: 5 +--- + +# Bilateral Filter — API & Reference + +Quick reference for the function implemented in the header. + +| Function | Signature | Purpose | +| :--- | :--- | :--- | +| `bilateral_filter` | `void bilateral_filter(uint8_t *image, size_t width, size_t height, double sigma_spatial, double sigma_range)` | Applies a bilateral filter to an RGBA image. | + +## Parameters + +| Parameter | Type | Description | +| :--- | :--- | :--- | +| `image` | `uint8_t*` | Pointer to the RGBA image data (4 bytes per pixel). Modified in-place. | +| `width` | `size_t` | Width of the image in pixels. | +| `height` | `size_t` | Height of the image in pixels. | +| `sigma_spatial` | `double` | Spatial standard deviation ($\sigma_s$). Controls how far pixels influence each other spatially. | +| `sigma_range` | `double` | Range standard deviation ($\sigma_r$). Controls how much color definition is preserved (edge preservation). | + +:::info Implementation Details +- **Namespace**: `bilateral` (C++) +- **Export**: Exposed to WASM via `extern "C"` wrapper as `bilateral_filter`. +::: diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md new file mode 100644 index 000000000..d98038924 --- /dev/null +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md @@ -0,0 +1,65 @@ +--- +id: explained +title: Implementation Explained +sidebar_position: 6 +--- + +# Bilateral Filter — Implementation Explained + +This section explains the inner workings of the **bilateral filter** implementation. + +## Overview + +The bilateral filter smoothes an image while **preserving edges**. It achieves this by weighting neighboring pixels based on two criteria: +1. **Spatial Distance**: Pixels closer to the center have higher weight. +2. **Range (Color) Difference**: Pixels with similar colors to the center have higher weight. + +This prevents the "blurring" from crossing strong edges, where the color difference is large. + +## How It Works + +For each pixel in the image, we look at a local window (kernel) around it. The new pixel value is a weighted average of its neighbors: + +$$ +I_{new}(x) = \frac{1}{W_p} \sum_{x_i \in \Omega} I(x_i) \cdot w_{spatial}(\|x_i - x\|) \cdot w_{range}(|I(x_i) - I(x)|) +$$ + +Where: +- $w_{spatial}$ is a Gaussian function of the distance. +- $w_{range}$ is a Gaussian function of the intensity difference. +- $W_p$ is the normalization factor (sum of all weights). + +## Implementation Details + +Our implementation uses a **naive sliding window** approach with **Look-Up Table (LUT) optimizations** to improve performance in WebAssembly. + +### 1. Precomputed Look-Up Tables + +Calculating `std::exp()` inside the inner loop is expensive. We precompute the two Gaussian functions: +- **Spatial Weights**: A 2D grid of weights based on the kernel radius. Since the spatial distance between a neighbor and the center never changes, this is calculated once per filter application. +- **Range Weights**: A 1D array mapping squared color distance ($0$ to $255^2 \times 3$) to a weight. This allows O(1) lookups for the "edge preservation" factor. + +```cpp +// Precomputing Range Weights +std::vector range_lut(MAX_RGB_DIST_SQ + 1); +for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) { + range_lut[i] = std::exp(-static_cast(i) / two_sigma_range_sq); +} +``` + +### 2. The Loop + +We iterate over every pixel `(y, x)` and then over every neighbor `(ky, kx)` within the kernel radius: + +1. **Load Neighbor**: Get RGB values of the neighbor. +2. **Spatial Weight**: Look up precomputed $G_{\sigma_s}$. +3. **Range Weight**: Calculate squared color distance $\|C_p - C_q\|^2$ and look up precomputed $G_{\sigma_r}$. +4. **Accumulate**: `pixel_acc += neighbor_rgb * (spatial_w * range_w)`. +5. **Normalize**: Divide by probability sum. + +### Complexity + +- **Time Complexity**: $O(W \cdot H \cdot R^2)$, where $R$ is the kernel radius. +- **Space Complexity**: $O(W \cdot H)$ for the output buffer. + +This complexity is why the filter can be slow for large radii ($\sigma_{spatial} > 5.0$), but we currently parameterize the radius to be small ($\sigma_{spatial} \leq 3.0$) so it is not a problem. diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md new file mode 100644 index 000000000..ab5c6b9fb --- /dev/null +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md @@ -0,0 +1,73 @@ +--- +id: implementation +title: Bilateral Filter — Implementation details +sidebar_label: Implementation +sidebar_position: 4 +--- + +# Bilateral Filter — Implementation details + +This page maps the conceptual steps of the Bilateral Filter to the concrete functions and loops in the implementation. + +## 1. Parameters & Window Size + +The filter first calculates the kernel size based on the spatial standard deviation ($\sigma_{spatial}$). + +```cpp +const int radius = static_cast(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial)); +const int kernel_width = 2 * radius + 1; +``` + +We primarily use $\sigma_{spatial} \approx 3.0$, which results in a kernel radius of 9 (width 19x19). + +## 2. Precomputing Weights (Optimization) + +To avoid computing `std::exp` millions of times per frame, we precalculate the weights. + +### Spatial Weights (constant per kernel) +The distance pattern is the same for every pixel, so we calculate the distance-based weights once at the start. + +```cpp +spatial_weights[(ky + radius) * kernel_width + (kx + radius)] = + std::exp(-dist2 / two_sigma_space_sq); +``` + +### Range Weights (LUT) +We calculate the `similarity score` for every possible color difference ahead of time. We just measure the color difference and look up the precomputed weight in the table. + +```cpp +for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) { + range_lut[i] = std::exp(-static_cast(i) / two_sigma_range_sq); +} +``` + +## 3. Sliding Window Loop + +The core processing happens in a nested loop over every pixel $(y, x)$. For each pixel, we: + +1. **Iterate** over the window (from $-radius$ to $+radius$). +2. **Fetch** neighbor RGB values. +3. **Calculate** color difference (squared Euclidean distance). +4. **Lookup** spatial weight (from array) and range weight (from LUT). +5. **Accumulate** the weighted sum and the sum of weights. + +```cpp +double w_space = spatial_weights[...]; +double w_range = range_lut[dist_sq]; +double w = w_space * w_range; + +r_acc += r * w; +g_acc += g * w; +b_acc += b * w; +weight_acc += w; +``` + +## 4. Normalization + +Finally, we normalize the accumulated color values by the total weight to get the filtered pixel value: + +```cpp +result[center_idx] = static_cast(std::clamp(r_acc / weight_acc, 0.0, 255.0)); +``` + +This ensures the pixel brightness remains consistent with the local area. diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md new file mode 100644 index 000000000..91705a4c7 --- /dev/null +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md @@ -0,0 +1,31 @@ +--- +id: overview +title: Bilateral Filter +sidebar_label: Overview +sidebar_position: 2 +--- + +# Bilateral Filter + +This section introduces the **bilateral filter** used in the Img2Num project +(see [`bilateral_filter.h`](https://github.com/Ryan-Millard/Img2Num/blob/main/src/wasm/modules/image/include/bilateral_filter.h) +& [`bilateral_filter.cpp`](https://github.com/Ryan-Millard/Img2Num/blob/main/src/wasm/modules/image/src/bilateral_filter.cpp)). +It focuses on how the algorithm is implemented, why each step is necessary, +and where the corresponding code lives so you can jump straight into the implementation. + +## At a glance +- **Algorithm:** Bilateral Filter (Non-linear, edge-preserving). +- **Data type:** `uint8_t` (8-bit unsigned integer channels). +- **Key steps:** + 1. For each pixel, inspect neighbors in radius $R$. + 2. Weight neighbors by **spatial distance** (Gaussian). + 3. Weight neighbors by **intensity difference** (Gaussian). + 4. Normalize and average. + +## Pages in this mini-guide + +* **Overview** (this page) +* **Implementation details** — step-by-step mapping between theory and the actual C++ code. +* **API & reference** — brief function signatures and purpose for quick lookup. + +Jump to implementation: [Implementation details](../implementation/) diff --git a/docs/docs/reference/wasm/modules/image/overview.md b/docs/docs/reference/wasm/modules/image/overview.md index c55f6a01a..001b834f2 100644 --- a/docs/docs/reference/wasm/modules/image/overview.md +++ b/docs/docs/reference/wasm/modules/image/overview.md @@ -25,6 +25,7 @@ src/wasm/modules/image/ │   ├── kmeans.h │   └── mergeSmallRegionsInPlace.h └── src + ├── bilateral_filter.cpp ├── fft_iterative.cpp ├── image_utils.cpp ├── kmeans.cpp @@ -38,6 +39,7 @@ Each header corresponds to a major subsystem: - `Image.h` — Core image class. - Internally uses a **Pixel type**. - `PixelConverters` — Functions for converting between pixel formats. +- `bilateral_filter` — Bilateral filter for image denoising. - `fft_iterative` — Fast Fourier Transform utilities. - Used by **Gaussian Blur** inside image_utils.h. - `kmeans` — K-means clustering used for quantization. From a8ff686442db8e9728ce8d9ebdb676e2f379e47f Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Wed, 31 Dec 2025 09:40:38 -0800 Subject: [PATCH 08/15] feat(bilateral_filter): add upper bound validation for sigma_spatial --- src/wasm/modules/image/src/bilateral_filter.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/wasm/modules/image/src/bilateral_filter.cpp b/src/wasm/modules/image/src/bilateral_filter.cpp index 89c79321b..7c7a19e35 100644 --- a/src/wasm/modules/image/src/bilateral_filter.cpp +++ b/src/wasm/modules/image/src/bilateral_filter.cpp @@ -9,7 +9,8 @@ namespace bilateral { -static constexpr double SIGMA_RADIUS_FACTOR = 3.0; +static constexpr double SIGMA_RADIUS_FACTOR = 3.0; +static constexpr int MAX_KERNEL_RADIUS = 50; // Max possible squared Euclidean distance in a 3-channel 8-bit image: 255^2 * 3 = 195075 // Means max delta between images (imageA - imageB) in RGB channels (255^2 * 3) static constexpr int MAX_RGB_DIST_SQ = 255 * 255 * 3; @@ -27,8 +28,10 @@ It reduces noise in flat regions while preserving edges by assigning near-zero w void bilateral_filter(uint8_t *image, size_t width, size_t height, double sigma_spatial, double sigma_range) { if (sigma_spatial <= 0.0 || sigma_range <= 0.0) return; + if (width <= 0 || height <= 0) return; - const int radius = static_cast(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial)); + const int raw_radius = static_cast(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial)); + const int radius = std::min(raw_radius, MAX_KERNEL_RADIUS); const int kernel_width = 2 * radius + 1; std::vector result(width * height * 4); From a16b18691aff643c0b75bfa9b6106dd3fa856e62 Mon Sep 17 00:00:00 2001 From: Francisco Sanchez Date: Wed, 31 Dec 2025 09:41:26 -0800 Subject: [PATCH 09/15] docs: improve bilateral headers documentation --- src/wasm/modules/image/include/bilateral_filter.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wasm/modules/image/include/bilateral_filter.h b/src/wasm/modules/image/include/bilateral_filter.h index b05b824b4..d6065cb49 100644 --- a/src/wasm/modules/image/include/bilateral_filter.h +++ b/src/wasm/modules/image/include/bilateral_filter.h @@ -7,6 +7,7 @@ namespace bilateral { // Apply bilateral filter to an image. +// The filter modifies the image buffer in-place. // Parameters: // - image: Pointer to RGBA pixel buffer // - width, height: Image dimensions (px) From 1bd096228942dc81a6bdaf75763f5d6a9b38c12d Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:24:15 +0200 Subject: [PATCH 10/15] docs(bilateral filter): explain use of Gaussian kernels inside formula - Simple info admonition that explains the link to Gaussian functions --- .../modules/image/bilateral_filter/explained.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md index d98038924..e54106593 100644 --- a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md @@ -29,6 +29,20 @@ Where: - $w_{range}$ is a Gaussian function of the intensity difference. - $W_p$ is the normalization factor (sum of all weights). + + + +:::info +In this implementation, both weighting terms are **Gaussian kernels**: + +$$ +w_{\text{spatial}}(d) = \exp!\left(-\frac{d^2}{2\sigma_s^2}\right), +\quad +w_{\text{range}}(d) = \exp!\left(-\frac{d^2}{2\sigma_r^2}\right) +$$ + +where $ \sigma_s$ controls spatial smoothing and $\sigma_r$ controls edge sensitivity. +::: ## Implementation Details Our implementation uses a **naive sliding window** approach with **Look-Up Table (LUT) optimizations** to improve performance in WebAssembly. From 6dc5097d2af5ff223476af3e875983b077d32a8b Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:24:45 +0200 Subject: [PATCH 11/15] docs(bilateral filter): better styling --- .../reference/wasm/modules/image/bilateral_filter/explained.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md index e54106593..8e83f4808 100644 --- a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md @@ -53,8 +53,7 @@ Calculating `std::exp()` inside the inner loop is expensive. We precompute the t - **Spatial Weights**: A 2D grid of weights based on the kernel radius. Since the spatial distance between a neighbor and the center never changes, this is calculated once per filter application. - **Range Weights**: A 1D array mapping squared color distance ($0$ to $255^2 \times 3$) to a weight. This allows O(1) lookups for the "edge preservation" factor. -```cpp -// Precomputing Range Weights +```cpp title="Precomputing Range Weights" std::vector range_lut(MAX_RGB_DIST_SQ + 1); for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) { range_lut[i] = std::exp(-static_cast(i) / two_sigma_range_sq); From 35f4e0d6911d86af492ae460b20e95b5ebf4c484 Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:25:04 +0200 Subject: [PATCH 12/15] docs(bilateral filter): better styling --- .../wasm/modules/image/bilateral_filter/explained.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md index 8e83f4808..07be50365 100644 --- a/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md @@ -65,8 +65,8 @@ for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) { We iterate over every pixel `(y, x)` and then over every neighbor `(ky, kx)` within the kernel radius: 1. **Load Neighbor**: Get RGB values of the neighbor. -2. **Spatial Weight**: Look up precomputed $G_{\sigma_s}$. -3. **Range Weight**: Calculate squared color distance $\|C_p - C_q\|^2$ and look up precomputed $G_{\sigma_r}$. +2. **Spatial Weight**: Look up precomputed $G_{\sigma_{spatial}}$. +3. **Range Weight**: Calculate squared color distance $\|C_p - C_q\|^2$ and look up precomputed $G_{\sigma_{range}}$. 4. **Accumulate**: `pixel_acc += neighbor_rgb * (spatial_w * range_w)`. 5. **Normalize**: Divide by probability sum. From ebefadbc80491e9e18558ec9fde6b1db1a53b2a7 Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:25:31 +0200 Subject: [PATCH 13/15] docs(bilateral filter): correct sidebar_position --- .../wasm/modules/image/bilateral_filter/_category_.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json b/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json index 2517950d2..dea27276f 100644 --- a/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/_category_.json @@ -1,6 +1,6 @@ { "label": "bilateral_filter.h", - "position": 2, + "position": 4, "link": { "type": "generated-index", "title": "Bilateral Filter", From e5a0c9bcc67bba8576ffcef073de521f939b52e2 Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:25:52 +0200 Subject: [PATCH 14/15] docs(bilateral filter): mobile accessibility --- .../reference/wasm/modules/image/bilateral_filter/api.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md b/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md index 3451cc049..0a23beb6f 100644 --- a/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md +++ b/docs/docs/reference/wasm/modules/image/bilateral_filter/api.md @@ -9,9 +9,11 @@ sidebar_position: 5 Quick reference for the function implemented in the header. -| Function | Signature | Purpose | -| :--- | :--- | :--- | -| `bilateral_filter` | `void bilateral_filter(uint8_t *image, size_t width, size_t height, double sigma_spatial, double sigma_range)` | Applies a bilateral filter to an RGBA image. | +```cpp title="Applies a bilateral filter to an RGBA image (modified in-place)." +void bilateral_filter(uint8_t *image, + size_t width, size_t height, + double sigma_spatial, + double sigma_range) ## Parameters From 39718f4c7a0254f3aa1068a070dcef3d757c0c34 Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:26:28 +0200 Subject: [PATCH 15/15] docs(bilateral filter): explicit description in module overview --- docs/docs/reference/wasm/modules/image/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/reference/wasm/modules/image/overview.md b/docs/docs/reference/wasm/modules/image/overview.md index 001b834f2..3620261f8 100644 --- a/docs/docs/reference/wasm/modules/image/overview.md +++ b/docs/docs/reference/wasm/modules/image/overview.md @@ -39,7 +39,7 @@ Each header corresponds to a major subsystem: - `Image.h` — Core image class. - Internally uses a **Pixel type**. - `PixelConverters` — Functions for converting between pixel formats. -- `bilateral_filter` — Bilateral filter for image denoising. +- `bilateral_filter` — Bilateral filter for edge-conserving image denoising. - `fft_iterative` — Fast Fourier Transform utilities. - Used by **Gaussian Blur** inside image_utils.h. - `kmeans` — K-means clustering used for quantization.