Skip to content

WIP: feat(bilateral filter): implement bilateral filter for denoising before K-Means in RGB & CIELAB#192

Merged
Ryan-Millard merged 15 commits into
Ryan-Millard:feat/kmean-preprocessing/bilateral-filterfrom
Krasner:feat/kmean-preprocessing/bilateral-filter-debug
Jan 6, 2026
Merged

WIP: feat(bilateral filter): implement bilateral filter for denoising before K-Means in RGB & CIELAB#192
Ryan-Millard merged 15 commits into
Ryan-Millard:feat/kmean-preprocessing/bilateral-filterfrom
Krasner:feat/kmean-preprocessing/bilateral-filter-debug

Conversation

@Krasner
Copy link
Copy Markdown
Collaborator

@Krasner Krasner commented Jan 4, 2026

⚠️ Are you using the correct pull request template?
Please choose one of the following:

If none of these fit, you may use this default to describe your change manually.

If this is the right template, go ahead and complete it below 👇


📌 Description

Please describe the changes made in this PR and why they are necessary.

Fixes #issue-number (if applicable)

✅ Type of Change

Place an "x" in the brackets below:

  • Bug fix 🐛
  • New feature ✨
  • Refactor 🔧
  • Documentation 📚
  • Build/dependency update 🧱
  • Other (describe):

🧪 How Has This Been Tested?

Please describe how you tested your changes (e.g., unit tests, manual testing, screenshots, etc.)

🧩 Checklist

Place an "x" in the brackets below:

  • I’ve followed the contribution guidelines.
  • My code follows the code style of this project.
  • I’ve added tests where necessary.
  • I’ve updated the documentation where applicable.
  • I’ve linked related issues or discussions (if any).
  • I’ve checked for breaking changes and backwards compatibility.

📸 Screenshots / Demo (if applicable)

Paste images, GIFs, or demo links here.

💬 Additional Context

Anything else relevant to the PR.

Summary by CodeRabbit

  • New Features

    • Bilateral filter now supports CIELAB color space in addition to RGB.
    • Added color_space parameter to the bilateral_filter function for color space selection.
  • Documentation

    • Comprehensive updates to reference documentation with detailed filter mechanics, kernel dimensions, and color space behavior.
    • Added keywords reference guide for key concepts.
  • Performance

    • Enabled fast-math compiler optimization for improved filter performance.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 4, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

The pull request extends the bilateral filter to support CIELAB color space processing alongside RGB, adds a color_space parameter to the public API, and introduces lab_to_rgb conversion utilities. Documentation is updated with expanded explanations and sidebar positioning adjustments. Build flags are updated to enable fast-math optimizations.

Changes

Cohort / File(s) Summary
Bilateral Filter Documentation
docs/docs/reference/wasm/modules/image/bilateral_filter/api.md, explained.md, implementation.md, overview.md
Updated sidebar positions, API signature to include new uint8_t color_space parameter, expanded "How It Works" with mathematical notation, added kernel visualization, documented RGB LUT vs. CIELAB on-the-fly weight computation, and introduced "Why Img2Num Uses Bilateral Filters" section.
Keywords Reference
docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md
New documentation file defining key bilateral filter concepts: spatial/range components, kernel, sigma parameters, LUT vs. CIELAB weights, and edge preservation.
Build Configuration
src/wasm/modules/image/CMakeLists.txt
Added -ffast-math compiler flag to both Debug and Release builds for performance optimization.
CIELAB Conversion Support
src/wasm/modules/image/include/cielab.h, src/wasm/modules/image/src/cielab.cpp
Added lab_to_rgb() function declaration and implementation; introduced helper functions inverse_xyz_to_lab(), gamma_encode(), and new color transformation constants for bidirectional LAB↔RGB conversion.
Bilateral Filter Implementation
src/wasm/modules/image/src/bilateral_filter.cpp
Extended function signature with uint8_t color_space parameter; implemented conditional CIELAB processing pathway with precomputed LAB image, per-channel accumulators, LAB-space distance calculations, and LAB-to-RGB output conversion; preserved RGB LUT-based path and alpha channel handling.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant bilateral_filter
    participant SpatialWeights
    participant RGBPath as RGB Path<br/>(LUT-based)
    participant CIELABPath as CIELAB Path<br/>(On-the-fly)
    participant rgb_to_lab
    participant lab_to_rgb

    Caller->>bilateral_filter: Call with image, width, height,<br/>sigma_spatial, sigma_range, color_space
    
    bilateral_filter->>SpatialWeights: Precompute spatial weights
    SpatialWeights-->>bilateral_filter: spatial_weights[]

    alt color_space == RGB
        bilateral_filter->>RGBPath: Use range_lut for weights
        loop For each pixel
            RGBPath->>RGBPath: Apply spatial weight
            RGBPath->>RGBPath: Lookup range weight from LUT
            RGBPath->>RGBPath: Update per-channel accumulators (R,G,B)
        end
        RGBPath->>RGBPath: Normalize & write RGB output
        RGBPath-->>bilateral_filter: Modified RGB image
    else color_space == CIELAB
        bilateral_filter->>rgb_to_lab: Convert image to LAB
        rgb_to_lab-->>bilateral_filter: cie_image(L,A,B)
        bilateral_filter->>CIELABPath: Use on-the-fly range weights
        loop For each pixel
            CIELABPath->>CIELABPath: Apply spatial weight
            CIELABPath->>CIELABPath: Compute LAB distance,<br/>calculate range weight (Gaussian)
            CIELABPath->>CIELABPath: Update per-channel accumulators (L,A,B)
        end
        CIELABPath->>lab_to_rgb: Convert averaged LAB back to RGB
        lab_to_rgb-->>CIELABPath: RGB uint8_t values
        CIELABPath-->>bilateral_filter: Modified RGB image
    end

    bilateral_filter-->>Caller: Return processed image
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #176: Introduces initial bilateral_filter implementation and public wrapper; this PR extends that foundation with color_space parameter and CIELAB pathway.
  • PR #177: Adds CIELAB-based bilateral_filter and rgb→Lab utilities; this PR generalizes the approach with parameterized color_space selection and bidirectional conversions.

Suggested labels

C/C++, docs

Poem

🐰 Hops through color spaces with glee,
RGB to LAB, with spatial harmony,
Filters blend smooth, edges stay bright,
Fast-math optimized—what a delight!
Documentation gardens now grow so deep. 🌱

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: implementing a bilateral filter for denoising with support for both RGB and CIELAB color spaces.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 4, 2026

deleted a comment about raw pointers vs std::vector for result. the implementation with std::vector is correct

Copy link
Copy Markdown
Owner

@Ryan-Millard Ryan-Millard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is much faster. Thank you!

I noticed quite a large difference between the RGB and CIELAB blurs:

Image

Left: CIELAB, Right: RGB

Please will you have a look at why it's so much blurrier for the CIELAB implementation (it could be SIGMA_RADIUS_FACTOR - I think you used 1.5 instead of 3.0 in your original implementation).

Comment thread src/wasm/modules/image/include/cielab.h Outdated
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please write some documentation for this change because it poses possible future risks and may make future code break. As a result, I'd like some clear documentation on how this file is set up in case we run into problems in the future.

A good place for the documentation file would be in docs/docs/reference/wasm/modules/image - please create a new folder for this documentation.

#include <algorithm>
#include <cstring>
#include <climits>
#include <iostream>
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this since it is not needed in production code - I'm thinking of adding a debug module in the future that will allow use to use things like std::cout in debug mode.

Suggested change
#include <iostream>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah std::cout does work and I can see the print outs in the browser console

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you attach the original image you used for testing?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you attach the original image you used for testing?

cow_head

Copy link
Copy Markdown
Owner

@Ryan-Millard Ryan-Millard Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah std::cout does work and I can see the print outs in the browser console

I did set up Emscripten's flags to allow std::cout, but we don't want that happening in production since it is an unnecessary slow-down (especially when it is coming from the C++ WebAssembly because it needs to switch to a JavaScript context to print it).

In the future, I'd like to create a module that uses the preprocessor to determine whether to include debug lines like std::cout to reduce the size of production binaries and speed it up slightly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try CIELAB with sigma_range = 30.0

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the comment I left below.

@Ryan-Millard
Copy link
Copy Markdown
Owner

This is really stunning!

Original

After processing

image

@Ryan-Millard
Copy link
Copy Markdown
Owner

The Supposed "Bug" I Mentioned Earlier

I found the problem: sigma_range affects RGB differently to how it affects CIELAB due to their differing channel max distances. To produce visually identical blur in both color spaces, sigma_range could be scaled. However, silently doing so is discouraged, as users might expect the natural difference between RGB and CIELAB blurring.

Before My Changes

Snippet from useWasmWorker.js:

  const bilateralFilter = async ({ pixels, width, height, sigma_spatial = 3.0, sigma_range = 50.0, color_space = 0 }) => {
    return (await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space }, ['pixels'])).output.pixels;
  };

Note

Nothing was altered, this code is from the current implementation.

Image Differences (CIELAB on the Left, RGB on the Right)

image

After My Changes

Snippet from useWasmWorker.js:

  const bilateralFilter = async ({ pixels, width, height, sigma_spatial = 3.0, sigma_range = 50.0, color_space = 0 }) => {
    // Scale RGB color space's sigma_range to visually match CIELAB's bilateral filter counterpart's output
    if (color_space === 1) {
      sigma_range *= 4.18;
    }
    return (await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space }, ['pixels'])).output.pixels;
  };

Note

The difference: sigma_range was scaled by 4.18 when the RGB color space was selected (when color_space == 1)

Image Differences (CIELAB on the Left, RGB on the Right)

image

Important Note

The constant I used in the example above (4.18) is probably not the correct constant to use because I did not perform any calculations to get to that number - it was just a trial-and-error test to see if my hunch was correct (I miscalculated that 3.18 was the correct number, then increased it until I got close enough to see a difference).

Conclusion

This is not a bug - it's a feature😂

The two color spaces represent visual data in distinct manners. Thus, we should not perform identical operations on them and expect the same output (in the same way that you would not expect x == y where int x = 4.1 * 3.123 and double y = 4.1 * 3.123).

Why the Difference Between the Color Spaces?

Why Bilateral Filter Blur Differs Between RGB and CIELAB

The bilateral filter relies on two distance metrics

  1. Spatial distance — how far a neighbor pixel is in the image plane.
  2. Range (color) distance — how different a neighbor pixel’s color is from the central pixel.

The color distance is computed differently depending on the color space:

  • RGB: Each channel is [0, 255], so the maximum Euclidean distance between two pixels is:

$$ \sqrt{(255)^2 + (255)^2 + (255)^2} \approx 442 $$

  • CIELAB: L $\in$ [0, 100], a,b $\in$ [-128, 127]. The maximum Euclidean distance is roughly:

$$ \sqrt{(100)^2 + (255)^2 + (255)^2} \approx 374 $$

Key point: Most real-world pixel differences are much smaller than the maximum possible. In Lab space, neighboring pixels typically have much smaller differences than in RGB, due to:

  1. Numerical compression from RGB → Lab conversion, especially for small differences.
  2. Human perceptual scaling: Lab is designed to reflect perceptual differences, which are smaller than raw RGB differences.

Effect on the bilateral filter

The bilateral filter uses a Gaussian for range weights:

$$ w_\text{range} = \exp\Big(-\frac{\text{dist}^2}{2\sigma_\text{range}^2}\Big) $$

  • If sigma_range is the same for RGB and Lab, the Lab distances are smaller, so almost all neighboring pixels contribute high weight, resulting in stronger smoothing (more blur).
  • Conversely, RGB distances are larger, so the same sigma_range preserves edges better.

Scaling for visual consistency

To make the bilateral filter produce similar visual blur across RGB and Lab, you can scale the sigma parameter:

$$ \sigma_\text{RGB-equivalent} \approx \sigma_\text{Lab} \cdot \frac{\text{effective RGB distance}}{\text{effective Lab distance}} $$

  • The scaling factor depends on image content.
  • Rough theoretical maximum: ~3.2
  • Practical, visually tested factor for natural images: ~4.0–4.2

Important

There is no single “perfect” factor; it varies depending on image statistics. For library use, a recommended default of ~4.18 works well for most images, but advanced users can adjust sigma_range or compute an image-specific scaling factor for exact matching.

Summary

  • Lab distances are numerically smaller than RGB distances.
  • Using the same sigma_range in Lab produces more blur.
  • To match visual blur, scale sigma_range proportionally to the typical RGB/Lab distance ratio.
  • For library defaults, a factor of ~4.18 is reasonable.

Next Steps

The next step for this would be to simply document it and nothing else - it would be absurdly confusing for people experienced with bilateral filters to use the two filters and find that they don't work intuitively (the RGB vs CIELAB sigma_range produce the same output when they are the same. For new users, they should be reading the documentation in any case, so they will see the note in the documentation about the difference between color spaces and recommended constant.

Feedback

I'd appreciate any feedback from you since you know the code a bit better than I do and probably have a deeper understanding of bilateral filters.

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

Currently sigma_range is hardcoded in src/hooks/useWasmWorker.js

should we put in a scaling factor in the bilateralFilter function to automatically scale sigma_range if color_space=0 (CIELAB)?

@Ryan-Millard
Copy link
Copy Markdown
Owner

Currently sigma_range is hardcoded in src/hooks/useWasmWorker.js

should we put in a scaling factor in the bilateralFilter function to automatically scale sigma_range if color_space=0 (CIELAB)?

I just opened #195 to discuss that. I'm not sure whether we should internally handle it or document that the users of the function must manage it themselves.

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

before we conclude on this I want to try another approach:

currently w_range computed in CIELAB space is applied to r_acc, g_acc, b_acc
but it can be applied instead to L_acc, A_acc, B_acc, then the final result is converted from CIELAB to RGB...

let me implement...

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

https://github.com/Krasner/Img2Num/tree/feat/kmean-preprocessing/bilateral-filter-debug2

check this branch. hard to say if there is a visual change but mathematically probably more correct.
filter weights accumulated in LAB space:

acc0 += L * w_space * w_range;
acc1 += A * w_space * w_range;
acc2 += B * w_space * w_range;

then combined and converted back to rgb

double L = acc0 / weight_acc;
double A = acc1 / weight_acc;
double B = acc2 / weight_acc;
uint8_t r, g, b;
lab_to_rgb(L, A, B, r, g, b);
result[center_idx]     = r;
result[center_idx + 1] = g;
result[center_idx + 2] = b;
result[center_idx + 3] = a0;

if looks good then I'll merge -debug2 into this branch and we'll continue
(pushed small fix in lab2rgb conversion to fix incorrect colors - missed a negative sign)

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

@Ryan-Millard
Copy link
Copy Markdown
Owner

feat/kmean-preprocessing/bilateral-filter-debug2

Hi. I'm having a hard time identifying what you changed - please will you merge it into into this branch so I can use the GitHub UI to see the difference.

@Ryan-Millard
Copy link
Copy Markdown
Owner

@Krasner, have you merged it yet? I don't see anything on my side.

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

Yes it's merged
image

@Ryan-Millard
Copy link
Copy Markdown
Owner

Yes it's merged image

Oh, okay. I thought you were referring to the changes on your feat/kmean-preprocessing/bilateral-filter-debug2 branch.

@Ryan-Millard
Copy link
Copy Markdown
Owner

@Krasner, please will you review the documentation I just pushed here.

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 5, 2026

looks good. thanks!

@Ryan-Millard
Copy link
Copy Markdown
Owner

looks good. thanks!

Your code looks great! I'm cleaning it up a bit and I'll push it soon.

…ization

    - Replace acc0/acc1/acc2 with descriptive weight_acc_channel_0/1/2
    - Use brace-initialization for ints and doubles
    - Minor spacing and formatting cleanup
Copy link
Copy Markdown
Owner

@Ryan-Millard Ryan-Millard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nice. Thank you @Krasner.

Please see my comments on the individual files as well as write some documentation for cielab.h & cielab.cpp - it doesn't need to be a lot because it's quite simple.

Comment thread src/wasm/modules/image/src/bilateral_filter.cpp Outdated
Comment thread src/wasm/modules/image/src/bilateral_filter.cpp Outdated
Copy link
Copy Markdown
Owner

@Ryan-Millard Ryan-Millard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Please will you write some docs for the CIELAB conversions.

@Ryan-Millard
Copy link
Copy Markdown
Owner

@CodeRabbit perform a full review and write some Docusaurus documentation to help with the CIELAB docs

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 5, 2026

🧩 Analysis chain

@Ryan-Millard, I'll perform a full review and create Docusaurus documentation for the CIELAB bilateral filtering. Let me first examine the documentation structure.


🏁 Script executed:

#!/bin/bash
# Find Docusaurus documentation structure
fd -t d -d 3 "docs" "docusaurus" "documentation"

# Look for existing documentation files
fd -e md -e mdx . | head -20

# Search for existing CIELAB or bilateral filter documentation
rg -l -i "cielab|bilateral" --type=md --type=mdx 2>/dev/null || rg -l -i "cielab|bilateral" -g "*.md" -g "*.mdx"

# Check if there's a docs or documentation directory
ls -la | grep -E "docs|documentation|website"

Length of output: 1198


🏁 Script executed:

#!/bin/bash
# Read the existing bilateral filter documentation files
echo "=== explained.md ==="
cat docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md

echo -e "\n=== keywords.md ==="
cat docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md

echo -e "\n=== implementation.md ==="
cat docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md

echo -e "\n=== api.md ==="
cat docs/docs/reference/wasm/modules/image/bilateral_filter/api.md

echo -e "\n=== overview.md ==="
cat docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md

Length of output: 20204


🏁 Script executed:

#!/bin/bash
# Get the PR diff to understand what changed
gh pr diff 192 --repo Ryan-Millard/Img2Num

# Also check what files were modified
gh pr view 192 --repo Ryan-Millard/Img2Num --json files --jq '.files[].path'

Length of output: 37804


@Ryan-Millard, I'll create comprehensive Docusaurus documentation for the CIELAB color space considerations and then perform a full review. Here's the new documentation file:

New Documentation: Color Space Selection Guide

Create a new file at docs/docs/reference/wasm/modules/image/bilateral_filter/color-spaces.md:

---
id: color-spaces
title: Color Space Selection — RGB vs CIELAB
sidebar_label: Color Spaces
sidebar_position: 3
---

# Color Space Selection — RGB vs CIELAB

The bilateral filter in Img2Num supports two color spaces for computing range (color) distances: **RGB** and **CIELAB**. This guide explains the differences, trade-offs, and when to use each.

## Quick Comparison

| Aspect | RGB | CIELAB |
|:---|:---|:---|
| **Perceptual accuracy** | Lower — equal Euclidean distances don't correspond to equal perceived color differences | Higher — designed to be perceptually uniform |
| **Performance** | Faster — uses precomputed LUT | Slower — requires conversion and on-the-fly computation |
| **Edge preservation** | Good for most images | Better for images with subtle color transitions |
| **Best for** | General purpose, real-time applications | High-quality processing, perceptual accuracy |

## When to Use Each Color Space

### Use RGB when:
- **Performance is critical** — RGB processing is significantly faster due to LUT optimization
- **Working with high-contrast images** — where edge preservation is less sensitive to color space choice
- **Real-time processing** — where milliseconds matter
- **Sigma_range values are well-tuned** — and visual results are satisfactory

### Use CIELAB when:
- **Perceptual uniformity matters** — you want visually equal smoothing across different hues
- **Working with skin tones or subtle gradients** — where human perception is sensitive
- **Quality over speed** — when processing time is less critical than output quality
- **Processing medical or scientific imagery** — where perceptual accuracy is important

## Mathematical Differences

### Distance Metrics

Both color spaces compute the Euclidean distance between color vectors, but the ranges differ significantly.

#### RGB Color Space
RGB channels are bounded `[0, 255]` per channel:

$$
\text{distance}_{\text{RGB}} = \sqrt{\Delta R^2 + \Delta G^2 + \Delta B^2}
$$

Maximum possible distance:
$$
\text{max}_{\text{RGB}} = \sqrt{255^2 + 255^2 + 255^2} \approx 441.67
$$

#### CIELAB Color Space
CIELAB channels have different ranges:
- **L\***: `[0, 100]` (lightness)
- **a\***: approximately `[-128, 127]` (green-red)
- **b\***: approximately `[-128, 127]` (blue-yellow)

$$
\text{distance}_{\text{LAB}} = \sqrt{\Delta L^2 + \Delta a^2 + \Delta b^2}
$$

Maximum theoretical distance:
$$
\text{max}_{\text{LAB}} = \sqrt{100^2 + 255^2 + 255^2} \approx 373.56
$$

:::important Key Insight
In practice, most real-world pixel differences are **much smaller** than the maximum possible distance. CIELAB distances for neighboring pixels are typically smaller than RGB distances due to:
1. **Numerical compression** from the RGB→LAB conversion
2. **Perceptual scaling** — LAB is designed to reflect human vision, which perceives smaller differences
:::

## Sigma_range Behavior Differences

The `sigma_range` parameter controls edge preservation by weighting color similarity. However, the same `sigma_range` value produces **different visual results** in RGB vs CIELAB.

### The Range Weight Formula

The bilateral filter computes range weights using a Gaussian:

$$
w_{\text{range}} = \exp\left(-\frac{\text{distance}^2}{2\sigma_{\text{range}}^2}\right)
$$

- When distance is **small**, weight is **high** (≈1) → strong contribution
- When distance is **large**, weight is **low** (≈0) → weak contribution

### Why the Same Sigma Produces Different Results

<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: '20px' }}>
  <svg
    style={{ width: '100%', maxWidth: '600px' }}
    viewBox="0 0 500 200"
    xmlns="http://www.w3.org/2000/svg"
  >
    {/* Background */}
    <rect x="0" y="0" width="100%" height="100%" fill="#f8f9fa" />
    
    {/* RGB curve (sigma_range = 50) */}
    <path
      d={(() => {
        const sigma = 50;
        const points = Array.from({ length: 151 }, (_, i) => {
          const dist = i * 3; // 0 to 450
          const weight = Math.exp(-(dist*dist)/(2*sigma*sigma));
          const x = 50 + i * 2.5;
          const y = 170 - weight * 120;
          return `${x},${y}`;
        });
        return `M${points.join(" L")}`;
      })()}
      fill="none"
      stroke="#ff6b6b"
      strokeWidth="3"
    />
    
    {/* CIELAB curve (sigma_range = 50) */}
    <path
      d={(() => {
        const sigma = 50;
        const points = Array.from({ length: 126 }, (_, i) => {
          const dist = i * 3; // 0 to 375
          const weight = Math.exp(-(dist*dist)/(2*sigma*sigma));
          const x = 50 + i * 2.5;
          const y = 170 - weight * 120;
          return `${x},${y}`;
        });
        return `M${points.join(" L")}`;
      })()}
      fill="none"
      stroke="#4dabf7"
      strokeWidth="3"
      strokeDasharray="5,5"
    />
    
    {/* Axes */}
    <line x1="50" y1="170" x2="450" y2="170" stroke="#000" strokeWidth="1" />
    <line x1="50" y1="30" x2="50" y2="170" stroke="#000" strokeWidth="1" />
    
    {/* Labels */}
    <text x="250" y="195" fontSize="14" textAnchor="middle" fill="#000">Color Distance</text>
    <text x="20" y="100" fontSize="14" textAnchor="middle" fill="#000" transform="rotate(-90 20 100)">Weight</text>
    
    {/* Legend */}
    <line x1="320" y1="20" x2="360" y2="20" stroke="#ff6b6b" strokeWidth="3" />
    <text x="370" y="25" fontSize="12" fill="#000">RGB (σᵣ = 50)</text>
    
    <line x1="320" y1="40" x2="360" y2="40" stroke="#4dabf7" strokeWidth="3" strokeDasharray="5,5" />
    <text x="370" y="45" fontSize="12" fill="#000">CIELAB (σᵣ = 50)</text>
    
    {/* Annotation */}
    <text x="150" y="90" fontSize="11" fill="#666" textAnchor="middle">CIELAB: Steeper falloff</text>
    <text x="150" y="105" fontSize="11" fill="#666" textAnchor="middle">→ More blur</text>
  </svg>
</div>

**With `sigma_range = 50`**:
- **RGB**: Typical neighboring pixel distances are small relative to 50, so many neighbors contribute significantly → **moderate blur**
- **CIELAB**: Typical neighboring pixel distances are even smaller, so almost all neighbors contribute strongly → **stronger blur**

### Sigma_range Scaling for Visual Consistency

To achieve **visually similar** blur between RGB and CIELAB, you can scale `sigma_range`:

```javascript
// Example: Scaling RGB sigma_range to match CIELAB visual output
const sigma_range_base = 50.0; // Target CIELAB sigma_range

let sigma_range_actual;
if (color_space === COLOR_SPACE_RGB) {
    // Scale RGB sigma_range to match CIELAB perceptually
    sigma_range_actual = sigma_range_base * 4.18;
} else {
    sigma_range_actual = sigma_range_base;
}

:::important Scaling Factor
The scaling factor of ~4.18 is empirically derived and works well for natural images. However:

  • It's not universal — depends on image statistics
  • It's not mandatory — the different behaviors are valid features of each color space
  • Advanced users may want different sigma_range values for each space
    :::

Visual Example

Using the same sigma_range = 50:

Color Space Visual Result
CIELAB Stronger smoothing, better edge preservation in perceptually uniform manner
RGB Moderate smoothing, adequate edge preservation for most use cases
RGB (scaled) Similar smoothing to CIELAB when sigma_range ≈ 209

Performance Considerations

RGB Performance

  • Precomputed LUT: All 195,075 possible squared distances are precomputed
  • O(1) lookup: Range weight retrieval is extremely fast
  • Memory: ~1.5 MB for LUT (acceptable for most applications)

CIELAB Performance

  • Full image conversion: RGB→LAB conversion for entire image upfront
  • On-the-fly computation: Range weights computed using exp() for each neighbor
  • Slower but optimized: Conversion is done once; only distance calculation repeated

Performance Impact: CIELAB is typically 2-4× slower than RGB, depending on image size and kernel radius.

:::tip Optimization Note
Future optimizations may include:

  • Taylor/Horner polynomial approximations for exp(-x²)
  • SIMD vectorization for distance calculations
  • Adaptive LUT for CIELAB (with quantization)
    :::

Implementation Details

RGB Range Weights (LUT)

// Precompute all possible RGB distances
std::vector<double> range_lut(MAX_RGB_DIST_SQ + 1);
for (int i = 0; i <= MAX_RGB_DIST_SQ; ++i) {
    range_lut[i] = gaussian(std::sqrt(i), sigma_range);
}

// Later, during filtering:
const int dr = r_neighbor - r_center;
const int dg = g_neighbor - g_center;
const int db = b_neighbor - b_center;
const int dist_sq = dr*dr + dg*dg + db*db;
double w_range = range_lut[dist_sq]; // O(1) lookup

CIELAB Range Weights (On-the-fly)

// Precompute full-image RGB → LAB conversion
std::vector<double> cie_image(width * height * 4);
for (each pixel) {
    rgb_to_lab(r, g, b, L, A, B);
    cie_image[idx] = L; cie_image[idx+1] = A; cie_image[idx+2] = B;
}

// Later, during filtering:
double dL = L_neighbor - L_center;
double dA = A_neighbor - A_center;
double dB = B_neighbor - B_center;
double dist = std::sqrt(dL*dL + dA*dA + dB*dB);
double w_range = gaussian(dist, sigma_range); // Computed on-the-fly

Recommendations

Default Choice

For most applications, RGB is the recommended default:

  • ✅ Faster processing
  • ✅ Good results for general images
  • ✅ Predictable behavior

When to Switch to CIELAB

Consider CIELAB when you observe:

  • Inconsistent smoothing across different hues
  • Need for perceptually uniform processing
  • Working with images where color accuracy is critical
  • Willing to accept 2-4× performance cost

Parameter Tuning

Starting values:

  • sigma_spatial = 3.0 (both color spaces)
  • sigma_range = 50.0 (CIELAB) or sigma_range = 200.0 (RGB for similar visual effect)

Adjustment guidelines:

  • Increase sigma_range → more blur, less edge preservation
  • Decrease sigma_range → sharper edges, less smoothing
  • Test with your specific images — optimal values vary by content

See Also


This documentation should be added to the Docusaurus site. Now I'll perform the full review.

<details>
<summary>✅ Actions performed</summary>

Full review triggered.

</details>

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Fix all issues with AI Agents 🤖
In @docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md:
- Around line 20-22: Update the paragraph that begins "Similarly to Gaussian
blurs..." to clarify that Gaussian blurs already use spatial distance in their
kernel and that the bilateral filter’s key distinction is that it additionally
weights by intensity/color similarity; replace the current sentences with a
concise rephrasing like: "Unlike Gaussian blurs (which only consider spatial
distance), bilateral filters also weight by intensity/color similarity, allowing
them to preserve sharp edges while still smoothing flat regions." Ensure the new
text replaces both sentences in that block to avoid implying Gaussian blurs
ignore spatial position.

In @src/wasm/modules/image/src/bilateral_filter.cpp:
- Around line 138-140: The code reads L/A/B from cie_image unconditionally
causing out-of-bounds when color_space != COLOR_SPACE_OPTION_CIELAB; move the
accesses to cie_image[neighbor_idx], cie_image[neighbor_idx + 1],
cie_image[neighbor_idx + 2] (the L, A, B variables) inside the branch that
checks color_space == COLOR_SPACE_OPTION_CIELAB so they are only read when
cie_image is populated, and ensure any logic that uses L/A/B only runs in that
CIELAB branch or uses safe RGB alternatives when color_space == RGB.

In @src/wasm/modules/image/src/cielab.cpp:
- Around line 101-105: Remove the invalid constexpr and stray trailing semicolon
from gamma_encode: change the function declaration from constexpr to inline (or
plain) so it doesn't require constexpr-evaluated std::pow, and delete the extra
semicolon after the function body; note that gamma_encode is only used at
runtime (see lab_to_rgb) so switching to inline fixes the compile error without
changing behavior.
♻️ Duplicate comments (1)
src/wasm/modules/image/CMakeLists.txt (1)

52-52: Verify -ffast-math implications for color space conversions.

Adding -ffast-math enables aggressive floating-point optimizations that can improve performance for the LAB conversion math. However, it relaxes IEEE 754 compliance—disabling strict NaN/Inf handling, enabling reassociation, and potentially affecting precision.

For the LAB↔RGB conversions (which use std::pow, std::cbrt, std::exp), this is generally acceptable, but verify that edge cases (e.g., out-of-gamut colors, near-zero values) don't produce unexpected results due to denormal flushing or NaN mishandling.

Regarding documentation: a past review comment already requested documentation for this CMakeLists.txt configuration. Please ensure that is addressed.

Also applies to: 58-58

🧹 Nitpick comments (2)
src/wasm/modules/image/include/cielab.h (1)

8-10: LGTM!

The lab_to_rgb declaration correctly complements rgb_to_lab for bidirectional conversion. The signature matches the implementation.

Minor note: Consider using the out_ prefix for output parameters (out_r_u8, out_g_u8, out_b_u8) to match the naming convention in rgb_to_lab for consistency.

src/wasm/modules/image/src/bilateral_filter.cpp (1)

77-77: Consider reducing cie_image memory footprint.

Currently allocating 4 doubles per pixel (width * height * 4) but only using 3 (L, A, B). You could use width * height * 3 and compute indices as y * width * 3 + x * 3 to save ~25% memory on large images.

This is a minor optimization—the current approach simplifies index consistency with the RGBA layout.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4425cce and 5937c8d.

📒 Files selected for processing (9)
  • docs/docs/reference/wasm/modules/image/bilateral_filter/api.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md
  • src/wasm/modules/image/CMakeLists.txt
  • src/wasm/modules/image/include/cielab.h
  • src/wasm/modules/image/src/bilateral_filter.cpp
  • src/wasm/modules/image/src/cielab.cpp
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-12-31T17:46:54.476Z
Learnt from: fransafu
Repo: Ryan-Millard/Img2Num PR: 176
File: src/wasm/modules/image/src/bilateral_filter.cpp:98-101
Timestamp: 2025-12-31T17:46:54.476Z
Learning: In bilateral filter implementations, the center pixel always contributes a weight of exactly 1.0 to the weight accumulator (spatial weight = exp(0) = 1.0 and range weight = exp(0) = 1.0), which inherently prevents division by zero during normalization without requiring explicit guards.

Applied to files:

  • docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/api.md
📚 Learning: 2026-01-04T15:34:04.654Z
Learnt from: Ryan-Millard
Repo: Ryan-Millard/Img2Num PR: 146
File: docs/docs/reference/react/components/ThemeSwitch/tests.md:123-124
Timestamp: 2026-01-04T15:34:04.654Z
Learning: In Docusaurus documentation, relative directory links (e.g., ../ or ../../../hooks/useTheme) resolve to index.md within the target directory. Do not require or force linking to index.md explicitly; such relative links remain valid and should not be changed when reviewing docs under the docs directory.

Applied to files:

  • docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md
  • docs/docs/reference/wasm/modules/image/bilateral_filter/api.md
📚 Learning: 2025-12-31T17:46:54.476Z
Learnt from: fransafu
Repo: Ryan-Millard/Img2Num PR: 176
File: src/wasm/modules/image/src/bilateral_filter.cpp:98-101
Timestamp: 2025-12-31T17:46:54.476Z
Learning: In bilateral_filter.cpp (src/wasm/modules/image/src/bilateral_filter.cpp), rely on the fact that the center pixel contributes a weight of exactly 1.0 to both spatial and range components (exp(0) = 1) so the normalization sum cannot be zero. This implies you do not need explicit guards against division by zero for normalization in this implementation.

Applied to files:

  • src/wasm/modules/image/src/bilateral_filter.cpp
  • src/wasm/modules/image/src/cielab.cpp
📚 Learning: 2025-12-20T20:11:28.422Z
Learnt from: Ryan-Millard
Repo: Ryan-Millard/Img2Num PR: 0
File: :0-0
Timestamp: 2025-12-20T20:11:28.422Z
Learning: In the Img2Num repository, all documentation should be properly organized in the docs/docs/ folder structure following the Docusaurus conventions, either as a dedicated category or integrated into existing categories like project-scripts.

Applied to files:

  • src/wasm/modules/image/CMakeLists.txt
📚 Learning: 2025-12-17T22:39:25.711Z
Learnt from: Ryan-Millard
Repo: Ryan-Millard/Img2Num PR: 0
File: :0-0
Timestamp: 2025-12-17T22:39:25.711Z
Learning: In the Img2Num repository, all documentation must be placed in the docs/docs/ folder following the Docusaurus structure, not at the repository root.

Applied to files:

  • src/wasm/modules/image/CMakeLists.txt
🧬 Code graph analysis (2)
src/wasm/modules/image/include/cielab.h (1)
src/wasm/modules/image/src/cielab.cpp (2)
  • lab_to_rgb (107-133)
  • lab_to_rgb (107-108)
src/wasm/modules/image/src/bilateral_filter.cpp (1)
src/wasm/modules/image/src/cielab.cpp (4)
  • rgb_to_lab (65-94)
  • rgb_to_lab (65-66)
  • lab_to_rgb (107-133)
  • lab_to_rgb (107-108)
🪛 markdownlint-cli2 (0.18.1)
docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md

53-53: Code block style
Expected: fenced; Actual: indented

(MD046, code-block-style)


176-176: Code block style
Expected: fenced; Actual: indented

(MD046, code-block-style)

🔇 Additional comments (11)
docs/docs/reference/wasm/modules/image/bilateral_filter/keywords.md (1)

1-28: LGTM!

Well-structured glossary that accurately defines the key concepts used throughout the bilateral filter documentation. The distinction between LUT-based RGB weights and on-the-fly CIELAB computation (line 23) correctly reflects the implementation.

src/wasm/modules/image/src/cielab.cpp (1)

107-133: LGTM!

The lab_to_rgb implementation correctly follows the standard Lab → XYZ → linear RGB → sRGB pipeline with proper gamma encoding and clamping. The conversion matrices and D65 white point constants are accurate.

src/wasm/modules/image/src/bilateral_filter.cpp (1)

176-196: LGTM!

The output paths correctly handle both color spaces:

  • RGB mode: direct weighted average with proper clamping
  • CIELAB mode: LAB-to-RGB conversion before output

Alpha channel preservation is correctly implemented for both paths. Based on learnings, division by weight_acc is safe since the center pixel always contributes weight 1.0.

docs/docs/reference/wasm/modules/image/bilateral_filter/api.md (1)

12-18: LGTM!

The API documentation accurately reflects the updated function signature with the new color_space parameter. The alpha channel preservation note and parameter descriptions are clear and helpful.

Also applies to: 29-29

docs/docs/reference/wasm/modules/image/bilateral_filter/explained.md (2)

26-46: Comprehensive notation and formula breakdown—excellent clarity.

The expanded definitions of $x$, $\Omega$, $I(x_i)$, and $C(x_i)$ with explicit RGB vs CIELAB representations make the mathematical foundation clear and accessible. The color distance formulas (lines 45–46) correctly capture Euclidean distance in both spaces.


63-96: Dual-path implementation strategy is well-articulated.

The LUT section (lines 63–74) and on-the-fly section (lines 76–96) clearly explain why RGB uses precomputed tables (bounded integer space) while CIELAB computes dynamically (unbounded floating-point space). The code examples reinforce the concepts effectively.

docs/docs/reference/wasm/modules/image/bilateral_filter/implementation.md (5)

27-81: SVG kernel visualization provides valuable pedagogical aid.

The 19×19 kernel diagram with center pixel and radius labels effectively communicates the spatial layout. The explicit "Kernel Dimensions" note (line 80) prevents misunderstanding. Note that Docusaurus uses MDX, which requires empty lines above and below language blocks for the MDX parser to recognize Markdown syntax and not JSX—verify the SVG block has appropriate blank lines before/after to ensure proper parsing.


83-158: In-depth LUT vs. on-the-fly comparison is technically sound and well-motivated.

The explanation of why RGB uses precomputed tables (bounded 8-bit channels, lines 120–133) versus CIELAB's on-the-fly computation (continuous floating-point values, lines 137–158) demonstrates clear understanding of the implementation trade-offs. The Gaussian helper function (lines 96–100) is concise and correct.


160-241: Collapsible visual comparison enhances comprehension.

The dual SVG diagrams showing RGB LUT versus CIELAB on-the-fly weight curves provide intuitive contrast. The annotations clarify why precomputation is feasible in one space but not the other. However, MDX requires empty lines around syntax blocks for the parser to differentiate Markdown from JSX; ensure blank lines surround this entire collapsible section to avoid parsing issues.


243-255: Sliding window loop description correctly reflects dual-path implementation.

The step-by-step breakdown (lines 1–5) now properly separates RGB (LUT) and CIELAB (on-the-fly) range weight retrieval, aligning with the implementation changes described in the PR. The terminology and logic are accurate.


1-259: Static analysis hints about code-block-style are likely false positives.

The markdownlint reports at lines 53 and 176 (MD046) flag "indented code block" style, but these lines fall within JSX/SVG content (not traditional markdown code blocks). The actual C++ code blocks at lines 96–100, 124–126, and 140–142 use correct triple-backtick fencing. The false positives are likely due to the linter misinterpreting JSX syntax as markdown code. No action required unless rendering issues occur in the published documentation.

Comment on lines +20 to +22
Similarly to Gaussian blurs, it acts as a *low-pass filter* that reduces noise.
Conversely, it is *less aggressive than Gaussian blurs, since it takes spatial position (x & y coordinates) into account* -
allowing it to preserve sharp edges.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify the distinction from Gaussian blurs.

The current wording suggests Gaussian blurs don't consider spatial position, but they do—that's the basis of their kernel. The key distinction is that bilateral filters additionally consider intensity/color similarity, which is what preserves edges.

Consider rewording to:

Unlike Gaussian blurs (which only consider spatial distance), bilateral filters also weight by intensity/color similarity, allowing them to preserve sharp edges while still smoothing flat regions.

🤖 Prompt for AI Agents
In @docs/docs/reference/wasm/modules/image/bilateral_filter/overview.md around
lines 20-22, Update the paragraph that begins "Similarly to Gaussian blurs..."
to clarify that Gaussian blurs already use spatial distance in their kernel and
that the bilateral filter’s key distinction is that it additionally weights by
intensity/color similarity; replace the current sentences with a concise
rephrasing like: "Unlike Gaussian blurs (which only consider spatial distance),
bilateral filters also weight by intensity/color similarity, allowing them to
preserve sharp edges while still smoothing flat regions." Ensure the new text
replaces both sentences in that block to avoid implying Gaussian blurs ignore
spatial position.

Comment on lines +138 to +140
double L{cie_image[neighbor_idx]};
double A{cie_image[neighbor_idx + 1]};
double B{cie_image[neighbor_idx + 2]};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Out-of-bounds access when color_space == RGB.

Lines 138-140 unconditionally read from cie_image, but cie_image is only populated when color_space == COLOR_SPACE_OPTION_CIELAB. In RGB mode, cie_image is empty (size 0), causing undefined behavior.

Move these reads inside the CIELAB case block:

🔎 Proposed fix
                     uint8_t r{image[neighbor_idx]};
                     uint8_t g{image[neighbor_idx + 1]};
                     uint8_t b{image[neighbor_idx + 2]};

-                    double L{cie_image[neighbor_idx]};
-                    double A{cie_image[neighbor_idx + 1]};
-                    double B{cie_image[neighbor_idx + 2]};
-
                     w_space = spatial_weights[(ky + radius) * kernel_diameter + (kx + radius)];

                     switch (color_space) {
                       case COLOR_SPACE_OPTION_RGB: {
                           const int dr{static_cast<int>(r) - r0};
                           const int dg{static_cast<int>(g) - g0};
                           const int db{static_cast<int>(b) - b0};
                           const int dist_sq{dr*dr + dg*dg + db*db};
                           w_range = range_lut[dist_sq];

                           weight_acc_channel_0 += r * w_space * w_range;
                           weight_acc_channel_1 += g * w_space * w_range;
                           weight_acc_channel_2 += b * w_space * w_range;
                           break;
                         }
                       case COLOR_SPACE_OPTION_CIELAB: {
+                        const double L{cie_image[neighbor_idx]};
+                        const double A{cie_image[neighbor_idx + 1]};
+                        const double B{cie_image[neighbor_idx + 2]};
+
                         dL = L - L0;
                         dA = A - A0;
                         dB = B - B0;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/wasm/modules/image/src/bilateral_filter.cpp around lines 138-140, The
code reads L/A/B from cie_image unconditionally causing out-of-bounds when
color_space != COLOR_SPACE_OPTION_CIELAB; move the accesses to
cie_image[neighbor_idx], cie_image[neighbor_idx + 1], cie_image[neighbor_idx +
2] (the L, A, B variables) inside the branch that checks color_space ==
COLOR_SPACE_OPTION_CIELAB so they are only read when cie_image is populated, and
ensure any logic that uses L/A/B only runs in that CIELAB branch or uses safe
RGB alternatives when color_space == RGB.

Comment thread src/wasm/modules/image/src/cielab.cpp Outdated
@Ryan-Millard
Copy link
Copy Markdown
Owner

@CodeRabbit, you were meant to help with documenting cielab.h and cielab.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 5, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@Ryan-Millard, you're absolutely right—I apologize for the misunderstanding. Let me read the CIELAB implementation files and create proper documentation for them.


🏁 Script executed:

#!/bin/bash
# Read the CIELAB header and implementation files
echo "=== cielab.h ==="
cat src/wasm/modules/image/include/cielab.h

echo -e "\n=== cielab.cpp ==="
cat src/wasm/modules/image/src/cielab.cpp

Length of output: 5564


@Ryan-Millard, thank you for the clarification! Here's comprehensive documentation for the CIELAB conversion functions:

1. Enhanced Header Documentation (cielab.h)

#ifndef CIELAB_H
#define CIELAB_H

#include <cstdint>

/**
 * @file cielab.h
 * @brief CIELAB color space conversion utilities
 * 
 * Provides bidirectional conversion between 8-bit sRGB and CIELAB color spaces.
 * Uses D65 illuminant (standard daylight) as the reference white point.
 * 
 * Color Space Ranges:
 * - RGB: [0, 255] per channel (uint8_t)
 * - L*: [0, 100] (lightness, double)
 * - a*: approximately [-128, 127] (green-red axis, double)
 * - b*: approximately [-128, 127] (blue-yellow axis, double)
 */

/**
 * @brief Convert 8-bit sRGB to CIELAB color space
 * 
 * Performs the following transformation pipeline:
 * 1. sRGB [0,255] → linear RGB [0,1] (inverse gamma correction)
 * 2. Linear RGB → XYZ (using sRGB matrix for D65 illuminant)
 * 3. XYZ → LAB (normalized by D65 white point)
 * 
 * CIELAB is a perceptually uniform color space designed to approximate
 * human vision. Equal Euclidean distances in LAB space correspond to
 * approximately equal perceived color differences.
 * 
 * @param r_u8 Red channel [0, 255]
 * @param g_u8 Green channel [0, 255]
 * @param b_u8 Blue channel [0, 255]
 * @param out_l Output L* (lightness) [0, 100]
 * @param out_a Output a* (green-red) [~-128, 127]
 * @param out_b Output b* (blue-yellow) [~-128, 127]
 * 
 * @note L* is clamped to [0, 100]; a* and b* are unconstrained but
 *       typically fall within [-128, 127] for valid sRGB inputs
 * 
 * @see lab_to_rgb for reverse conversion
 * @see https://en.wikipedia.org/wiki/CIELAB_color_space
 */
void rgb_to_lab(const uint8_t r_u8, const uint8_t g_u8, const uint8_t b_u8,
                double& out_l, double& out_a, double& out_b);

/**
 * @brief Convert CIELAB to 8-bit sRGB color space
 * 
 * Performs the following transformation pipeline:
 * 1. LAB → XYZ (denormalized by D65 white point)
 * 2. XYZ → linear RGB (using inverse sRGB matrix)
 * 3. Linear RGB → sRGB [0,255] (gamma correction + clamping)
 * 
 * Out-of-gamut LAB colors (not representable in sRGB) are clamped
 * to [0, 255] per channel.
 * 
 * @param L L* (lightness) [0, 100] - values outside range handled gracefully
 * @param A a* (green-red) - typically [-128, 127]
 * @param B b* (blue-yellow) - typically [-128, 127]
 * @param r_u8 Output red channel [0, 255]
 * @param g_u8 Output green channel [0, 255]
 * @param b_u8 Output blue channel [0, 255]
 * 
 * @note Out-of-gamut colors are clamped to nearest representable sRGB value
 * @note Rounding is applied before conversion to uint8_t
 * 
 * @see rgb_to_lab for forward conversion
 */
void lab_to_rgb(const double L, const double A, const double B,
                uint8_t& r_u8, uint8_t& g_u8, uint8_t& b_u8);

#endif // CIELAB_H

2. Enhanced Implementation Documentation (cielab.cpp)

#include "cielab.h"
#include <cmath>
#include <algorithm>

/**
 * @file cielab.cpp
 * @brief Implementation of CIELAB color space conversions
 * 
 * This implementation follows the CIE 1976 L*a*b* color space definition
 * with the following specifications:
 * - Color space: sRGB (IEC 61966-2-1:1999)
 * - Illuminant: D65 (standard daylight, 6500K)
 * - Observer: CIE 1931 2° Standard Observer
 * - Gamma: 2.4 (sRGB standard)
 * 
 * References:
 * - CIE 15:2004 - Colorimetry, 3rd edition
 * - IEC 61966-2-1:1999 - sRGB specification
 * - https://www.color.org/chardata/rgb/srgb.xalter
 */

// ============================================================================
// Constants for XYZ → LAB Transformation
// ============================================================================

/// Threshold for linear vs. nonlinear XYZ→LAB transformation (δ = 6/29)
constexpr double DELTA{6.0 / 29.0};                   // 0.2068966

/// Cube of DELTA, used for comparison in xyz_to_lab (δ³)
constexpr double DELTA_CUBED{DELTA * DELTA * DELTA};  // 0.008856

/// Slope of linear portion (κ = 1/(3δ²) ≈ 7.787)
constexpr double KAPPA{1.0 / (3.0 * DELTA * DELTA)};  // 7.787

/// Y-intercept of linear portion (16/116 ≈ 0.137931)
constexpr double EPSILON{16.0 / 116.0};               // 0.137931

// ============================================================================
// Constants for sRGB Gamma Correction
// ============================================================================

/// Threshold between linear and nonlinear sRGB segments
constexpr double SRGB_LINEAR_THRESHOLD{0.04045};

/// Scale factor for linear sRGB segment (12.92)
constexpr double SRGB_LINEAR_FACTOR{12.92};

/// Offset for nonlinear sRGB segment (0.055)
constexpr double SRGB_GAMMA_OFFSET{0.055};

/// sRGB gamma exponent (2.4)
constexpr double SRGB_GAMMA{2.4};

/// Inverse gamma for encoding (1/2.4 ≈ 0.41667)
constexpr double SRGB_GAMMA_INV{1.0 / SRGB_GAMMA};

// ============================================================================
// sRGB → XYZ Transformation Matrix (D65 illuminant)
// ============================================================================

/// sRGB to XYZ matrix coefficients (D65 white point)
/// Derived from ITU-R BT.709 primaries
constexpr double SRGB_R_TO_X{0.4124564};
constexpr double SRGB_G_TO_X{0.3575761};
constexpr double SRGB_B_TO_X{0.1804375};
constexpr double SRGB_R_TO_Y{0.2126729};
constexpr double SRGB_G_TO_Y{0.7151522};
constexpr double SRGB_B_TO_Y{0.0721750};
constexpr double SRGB_R_TO_Z{0.0193339};
constexpr double SRGB_G_TO_Z{0.1191920};
constexpr double SRGB_B_TO_Z{0.9503041};

// ============================================================================
// XYZ → sRGB Transformation Matrix (inverse of above)
// ============================================================================

/// XYZ to sRGB matrix coefficients (inverse transformation)
constexpr double SRGB_X_TO_R{3.2406};
constexpr double SRGB_Y_TO_R{-1.5372};
constexpr double SRGB_Z_TO_R{-0.4986};
constexpr double SRGB_X_TO_G{-0.9689};
constexpr double SRGB_Y_TO_G{1.8758};
constexpr double SRGB_Z_TO_G{0.0415};
constexpr double SRGB_X_TO_B{0.0557};
constexpr double SRGB_Y_TO_B{-0.2040};
constexpr double SRGB_Z_TO_B{1.0570};

// ============================================================================
// D65 Reference White Point
// ============================================================================

/// D65 illuminant tristimulus values (normalized so Y = 1.0)
/// Represents standard daylight at 6500K
constexpr double D65_Xn{0.95047};
constexpr double D65_Yn{1.0};
constexpr double D65_Zn{1.08883};

// ============================================================================
// LAB Scaling Constants
// ============================================================================

/// L* scale factor (116) and offset (16)
constexpr double LAB_L_FACTOR{116.0};
constexpr double LAB_L_OFFSET{16.0};

/// a* scale factor (500)
constexpr double LAB_A_FACTOR{500.0};

/// b* scale factor (200)
constexpr double LAB_B_FACTOR{200.0};

// ============================================================================
// Helper Functions
// ============================================================================

/**
 * @brief Nonlinear transformation for XYZ → LAB conversion
 * 
 * Applies the CIE-defined piecewise function:
 * - If t > δ³: f(t) = ∛t
 * - Otherwise: f(t) = κt + ε (linear approximation near zero)
 * 
 * This function ensures continuity and differentiability while avoiding
 * numerical issues near zero.
 * 
 * @param t Normalized XYZ component (X/Xn, Y/Yn, or Z/Zn)
 * @return Transformed value for LAB calculation
 */
inline double xyz_to_lab(const double t) {
  // Prevent negative values due to floating-point errors
  const double safe_t{std::max(0.0, t)};
  return (safe_t > DELTA_CUBED) ? std::cbrt(safe_t) : (KAPPA * safe_t) + EPSILON;
}

/**
 * @brief Inverse of xyz_to_lab for LAB → XYZ conversion
 * 
 * Applies the inverse piecewise function:
 * - If t > δ: f⁻¹(t) = t³
 * - Otherwise: f⁻¹(t) = 3δ²(t - ε)
 * 
 * @param t Transformed LAB intermediate value
 * @return Original normalized XYZ component
 */
constexpr double inverse_xyz_to_lab(double t)
{
  return (t > DELTA) ? (t * t * t) : (3 * DELTA * DELTA * (t - EPSILON));
}

/**
 * @brief Convert sRGB (gamma-corrected) to linear RGB
 * 
 * Applies inverse gamma correction (gamma expansion) according to
 * IEC 61966-2-1:1999 (sRGB specification):
 * 
 * - If c ≤ 0.04045: linear = c / 12.92
 * - Otherwise: linear = ((c + 0.055) / 1.055)^2.4
 * 
 * @param c sRGB component value [0.0, 1.0]
 * @return Linear RGB value [0.0, 1.0]
 */
inline double srgb_to_linear(const double c) {
    const double safe_c{std::clamp(c, 0.0, 1.0)};
    return (safe_c <= SRGB_LINEAR_THRESHOLD)
        ? safe_c / SRGB_LINEAR_FACTOR
        : std::pow((safe_c + SRGB_GAMMA_OFFSET) / (1.0 + SRGB_GAMMA_OFFSET), SRGB_GAMMA);
}

/**
 * @brief Convert linear RGB to sRGB (gamma-corrected)
 * 
 * Applies gamma correction (gamma compression) according to sRGB spec:
 * 
 * - If u ≤ 0.0031308: srgb = 12.92 × u
 * - Otherwise: srgb = 1.055 × u^(1/2.4) - 0.055
 * 
 * @param u Linear RGB value [0.0, 1.0]
 * @return sRGB component [0.0, 1.0]
 */
constexpr double gamma_encode(double u) {
  return (u <= SRGB_LINEAR_THRESHOLD / SRGB_LINEAR_FACTOR)
    ? SRGB_LINEAR_FACTOR * u
    : (1.0 + SRGB_GAMMA_OFFSET) * std::pow(u, SRGB_GAMMA_INV) - SRGB_GAMMA_OFFSET;
}

// ============================================================================
// Public API Implementation
// ============================================================================

/**
 * @brief Convert 8-bit sRGB to CIELAB
 * 
 * Transformation pipeline:
 * 
 * 1. **sRGB → Linear RGB**: Apply inverse gamma (gamma expansion)
 *    - Handles sRGB's piecewise transfer function
 *    - Converts [0,255] → [0,1] → linear [0,1]
 * 
 * 2. **Linear RGB → XYZ**: Matrix multiplication using sRGB primaries
 *    - Uses D65 illuminant standard (daylight, 6500K)
 *    - Applies ITU-R BT.709 color primaries
 * 
 * 3. **XYZ → LAB**: Normalize by white point, apply nonlinear transform
 *    - X, Y, Z divided by D65 reference white (Xn, Yn, Zn)
 *    - Apply CIE-defined piecewise function (cube root or linear)
 *    - Scale to L*a*b* values
 * 
 * Output ranges:
 * - L*: [0, 100] (clamped)
 * - a*: approximately [-128, 127] (unconstrained)
 * - b*: approximately [-128, 127] (unconstrained)
 */
void rgb_to_lab(const uint8_t r_u8, const uint8_t g_u8, const uint8_t b_u8,
                double& out_l, double& out_a, double& out_b) {
    // Step 1: Convert 8-bit sRGB [0, 255] to linear RGB [0.0, 1.0]
    double r{srgb_to_linear(r_u8 / 255.0)};
    double g{srgb_to_linear(g_u8 / 255.0)};
    double b{srgb_to_linear(b_u8 / 255.0)};

    // Step 2: Convert linear RGB to CIE XYZ (D65 white point)
    // Matrix multiplication using sRGB → XYZ transformation
    const double x{SRGB_R_TO_X * r + SRGB_G_TO_X * g + SRGB_B_TO_X * b};
    const double y{SRGB_R_TO_Y * r + SRGB_G_TO_Y * g + SRGB_B_TO_Y * b};
    const double z{SRGB_R_TO_Z * r + SRGB_G_TO_Z * g + SRGB_B_TO_Z * b};

    // Step 3: Normalize XYZ by D65 reference white point
    const double Xr{x / D65_Xn};
    const double Yr{y / D65_Yn};
    const double Zr{z / D65_Zn};

    // Step 4: Apply nonlinear transformation (piecewise: cube root or linear)
    const double fx{xyz_to_lab(Xr)};
    const double fy{xyz_to_lab(Yr)};
    const double fz{xyz_to_lab(Zr)};

    // Step 5: Calculate L*a*b* values
    // L*: lightness (0 = black, 100 = white)
    out_l = LAB_L_FACTOR * fy - LAB_L_OFFSET;
    
    // a*: green (negative) to red (positive)
    out_a = LAB_A_FACTOR * (fx - fy);
    
    // b*: blue (negative) to yellow (positive)
    out_b = LAB_B_FACTOR * (fy - fz);

    // Clamp L* to valid range [0, 100]
    out_l = std::clamp(out_l, 0.0, 100.0);
}

/**
 * @brief Convert CIELAB to 8-bit sRGB
 * 
 * Transformation pipeline:
 * 
 * 1. **LAB → XYZ**: Inverse of forward transform
 *    - Compute fy from L*, then fx and fz from a* and b*
 *    - Apply inverse piecewise function (cube or linear)
 *    - Denormalize by D65 white point
 * 
 * 2. **XYZ → Linear RGB**: Matrix multiplication (inverse of RGB→XYZ)
 *    - May produce out-of-gamut values (negative or >1.0)
 * 
 * 3. **Linear RGB → sRGB**: Apply gamma correction
 *    - Gamma compress using sRGB transfer function
 *    - Clamp to [0, 1], round, and convert to [0, 255]
 * 
 * @note Out-of-gamut LAB colors are clamped to nearest sRGB value
 * @note This may result in color shifts for LAB values outside sRGB gamut
 */
void lab_to_rgb(const double L, const double A, const double B,
                uint8_t& out_r_u8, uint8_t& out_g_u8, uint8_t& out_b_u8)
{
    // Step 1: LAB → intermediate f values
    const double fy{(L + LAB_L_OFFSET) / LAB_L_FACTOR};
    const double fx{fy + A / LAB_A_FACTOR};
    const double fz{fy - B / LAB_B_FACTOR};

    // Step 2: Apply inverse nonlinear transformation and denormalize
    const double X{D65_Xn * inverse_xyz_to_lab(fx)};
    const double Y{D65_Yn * inverse_xyz_to_lab(fy)};
    const double Z{D65_Zn * inverse_xyz_to_lab(fz)};

    // Step 3: XYZ → linear RGB (may be out-of-gamut)
    double r{SRGB_X_TO_R * X + SRGB_Y_TO_R * Y + SRGB_Z_TO_R * Z};
    double g{SRGB_X_TO_G * X + SRGB_Y_TO_G * Y + SRGB_Z_TO_G * Z};
    double b{SRGB_X_TO_B * X + SRGB_Y_TO_B * Y + SRGB_Z_TO_B * Z};

    // Step 4: Linear RGB → sRGB (apply gamma compression)
    r = gamma_encode(r);
    g = gamma_encode(g);
    b = gamma_encode(b);

    // Step 5: Clamp to [0, 1], scale to [0, 255], round to nearest integer
    out_r_u8 = static_cast<uint8_t>(std::round(255.0 * std::clamp(r, 0.0, 1.0)));
    out_g_u8 = static_cast<uint8_t>(std::round(255.0 * std::clamp(g, 0.0, 1.0)));
    out_b_u8 = static_cast<uint8_t>(std::round(255.0 * std::clamp(b, 0.0, 1.0)));
}

3. Docusaurus API Reference

Create docs/docs/reference/wasm/modules/image/cielab/api.md:

---
id: cielab-api
title: CIELAB Color Space API
sidebar_label: API Reference
sidebar_position: 1
---

# CIELAB Color Space Conversion API

## Overview

The CIELAB module provides bidirectional conversion between 8-bit sRGB and CIELAB (CIE L\*a\*b\*) color spaces. CIELAB is a perceptually uniform color space designed to approximate human vision, where equal Euclidean distances correspond to roughly equal perceived color differences.

## Functions

### `rgb_to_lab`

Convert 8-bit sRGB to CIELAB color space.

```cpp
void rgb_to_lab(
    const uint8_t r_u8,
    const uint8_t g_u8,
    const uint8_t b_u8,
    double& out_l,
    double& out_a,
    double& out_b
);

Parameters

Parameter Type Range Description
r_u8 uint8_t [0, 255] Red channel (input)
g_u8 uint8_t [0, 255] Green channel (input)
b_u8 uint8_t [0, 255] Blue channel (input)
out_l double& [0, 100] L* lightness (output, clamped)
out_a double& ~[-128, 127] a* green-red axis (output)
out_b double& ~[-128, 127] b* blue-yellow axis (output)

Transformation Pipeline

  1. sRGB → Linear RGB: Inverse gamma correction (gamma expansion)

    • Applies IEC 61966-2-1:1999 sRGB transfer function
    • Converts [0, 255] → [0, 1] → linear [0, 1]
  2. Linear RGB → XYZ: Matrix multiplication

    • Uses D65 illuminant (standard daylight, 6500K)
    • Applies ITU-R BT.709 color primaries
  3. XYZ → CIELAB: Normalization and nonlinear transform

    • Normalizes by D65 reference white point
    • Applies CIE-defined piecewise function (cube root or linear near zero)

Example

#include "cielab.h"

// Convert bright red to LAB
uint8_t r = 255, g = 0, b = 0;
double L, a, b_lab;
rgb_to_lab(r, g, b, L, a, b_lab);
// Result: L ≈ 53.2, a ≈ 80.1, b ≈ 67.2

lab_to_rgb

Convert CIELAB to 8-bit sRGB color space.

void lab_to_rgb(
    const double L,
    const double A,
    const double B,
    uint8_t& r_u8,
    uint8_t& g_u8,
    uint8_t& b_u8
);

Parameters

Parameter Type Range Description
L double [0, 100] L* lightness (input)
A double ~[-128, 127] a* green-red axis (input)
B double ~[-128, 127] b* blue-yellow axis (input)
r_u8 uint8_t& [0, 255] Red channel (output, clamped)
g_u8 uint8_t& [0, 255] Green channel (output, clamped)
b_u8 uint8_t& [0, 255] Blue channel (output, clamped)

Transformation Pipeline

  1. CIELAB → XYZ: Inverse nonlinear transform and denormalization

    • Applies inverse piecewise function (cube or linear)
    • Denormalizes by D65 white point
  2. XYZ → Linear RGB: Inverse matrix multiplication

    • May produce out-of-gamut values (negative or >1.0)
  3. Linear RGB → sRGB: Gamma correction

    • Applies gamma compression using sRGB transfer function
    • Clamps to [0, 1], rounds, and converts to [0, 255]

Out-of-Gamut Handling

:::warning Out-of-Gamut Colors
Not all LAB colors are representable in sRGB. Colors outside the sRGB gamut are clamped to the nearest valid RGB value, which may result in color shifts or loss of hue.
:::

Example

#include "cielab.h"

// Convert LAB back to RGB
double L = 53.2, a = 80.1, b = 67.2;
uint8_t r, g, b_rgb;
lab_to_rgb(L, a, b, r, g, b_rgb);
// Result: r ≈ 255, g ≈ 0, b ≈ 0 (bright red)

Technical Specifications

Color Space Standards

Property Value
Color space sRGB (IEC 61966-2-1:1999)
Illuminant D65 (6500K daylight)
Observer CIE 1931 2° Standard Observer
Gamma 2.4 (sRGB standard)
RGB primaries ITU-R BT.709

CIELAB Coordinate System

  • L* (Lightness): Perceptual lightness

    • 0 = black
    • 100 = white
    • 50 ≈ mid-gray
  • a* (Green-Red): Color opponent dimension

    • Negative values = green
    • Positive values = red
    • 0 = neutral (gray axis)
  • b* (Blue-Yellow): Color opponent dimension

    • Negative values = blue
    • Positive values = yellow
    • 0 = neutral (gray axis)

Distance Metric

Euclidean distance in CIELAB space approximates perceptual color difference:

$$ \Delta E = \sqrt{(\Delta L^_)^2 + (\Delta a^_)^2 + (\Delta b^*)^2} $$

Interpretation:

  • ΔE < 1: Imperceptible difference
  • ΔE < 2: Perceptible with close observation
  • ΔE < 10: Noticeable at a glance
  • ΔE > 10: Significant color difference

Usage in Bilateral Filter

The CIELAB color space is used in the bilateral filter to compute perceptually uniform range weights:

// In bilateral_filter.cpp (CIELAB mode)
double dL = L_neighbor - L_center;
double dA = A_neighbor - A_center;
double dB = B_neighbor - B_center;
double dist = std::sqrt(dL*dL + dA*dA + dB*dB);
double w_range = gaussian(dist, sigma_range);

This produces more perceptually consistent smoothing compared to RGB Euclidean distance.


Performance Considerations

Computational Cost

Operation Complexity
rgb_to_lab ~20-30 floating-point operations
lab_to_rgb ~25-35 floating-point operations

Key operations:

  • Gamma correction: piecewise with pow() for nonlinear segment
  • Matrix multiplication: 3×3 matrix
  • XYZ transform: cbrt() or linear approximation

Optimization Notes

  • Batch conversion: When processing entire images, consider vectorization (SIMD)
  • LUT for gamma: Can be precomputed for all 256 uint8_t values
  • Fast approximations: Polynomial approximations for pow() and cbrt() can improve speed at slight accuracy cost

See Also


---

These documentation updates provide:
1. **Inline comments** explaining constants, algorithms, and implementation details
2. **Function-level documentation** with parameter descriptions and transformation pipelines
3. **Docusaurus API reference** with examples, technical specifications, and usage guidance

Would you like me to create any additional documentation, such as a visual guide or comparison charts?

@Ryan-Millard Ryan-Millard changed the title Feat/kmean preprocessing/bilateral filter debug WIP: feat(bilateral filter): implement bilateral filter for denoising before K-Means in RGB & CIELAB Jan 5, 2026
@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 6, 2026

is this auto generated documentation good for you? or do you want something else?

@Ryan-Millard
Copy link
Copy Markdown
Owner

is this auto generated documentation good for you? or do you want something else?

It looks good.😁

@Krasner
Copy link
Copy Markdown
Collaborator Author

Krasner commented Jan 6, 2026

i guess coderabbitai didn't place these suggestions in the actual repo.... you can place them :)

@Ryan-Millard
Copy link
Copy Markdown
Owner

Thanks @Krasner!

@Ryan-Millard Ryan-Millard merged commit 7baacc8 into Ryan-Millard:feat/kmean-preprocessing/bilateral-filter Jan 6, 2026
1 check passed
@Ryan-Millard
Copy link
Copy Markdown
Owner

I just realized that I rebased the commits into #192, so you'll probably have to just through hoops to fix your history with something like git reset --hard <remote-name>/feat/kmean-preprocessing/bilateral-filter.

I'm sorry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants