Skip to content

Multithreading + Color Rework - #234

Merged
Ryan-Millard merged 21 commits into
Ryan-Millard:mainfrom
Krasner:dev/multithread_rebased
Jan 22, 2026
Merged

Multithreading + Color Rework#234
Ryan-Millard merged 21 commits into
Ryan-Millard:mainfrom
Krasner:dev/multithread_rebased

Conversation

@Krasner

@Krasner Krasner commented Jan 18, 2026

Copy link
Copy Markdown
Collaborator

Sorry template is overwhelming to use.

Fixes #231
Fixes #236

Multithreading enable for bilateral filter and kmeans. Must enable USE_PTHREADS for emscripten to allow std::thread. Also vite config must allow browser to enable SharedArrayBuffer.
This is done with:

server: {
    host: '0.0.0.0', // Allow connections from outside Docker
    port: 5173, // Match docker-compose port
    watch: {
      ignored: ['**/docs/**', 'src/wasm/**/*.js', 'src/wasm/**/*.wasm'],
    },
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },

in plugins:

{
     name: 'force-security-headers',
     configureServer(server) {
       server.middlewares.use((_req, res, next) => {
         res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
         res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
         next();
       });
     }
   },

Summary by CodeRabbit

  • New Features

    • Multi-threaded image processing and clustering for faster performance.
    • Server now applies COOP and COEP security headers.
  • Improvements

    • K-means adds CIELAB support and threading; default clusters increased to 16.
    • Segmentation uses bilateral-filtered input; region merges favor color-similar neighbors.
    • Thresholding path bypassed in the processing pipeline.
    • SVG export no longer forces explicit color-quantization.
  • Documentation

    • Color conversion APIs expanded to generic, pixel-aware forms.

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

@Krasner
Krasner requested a review from Ryan-Millard January 18, 2026 22:11
@coderabbitai

coderabbitai Bot commented Jan 18, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Added multithreading and CIELAB support to WASM image algorithms (bilateral_filter, kmeans); JS worker API extended with color_space and n_threads; component thresholding disabled; build changed to enable pthreads/SIMD; new LAB pixel types and templated cielab APIs added; dev server enforces COOP/COEP headers.

Changes

Cohort / File(s) Summary of changes
WASM: core algorithms
src/wasm/modules/image/src/bilateral_filter.cpp, src/wasm/modules/image/src/kmeans.cpp
Added multithreading (row/centroid/pixel partitioning), k‑means++ init, CIELAB/LABA handling, squared-distance math, threaded helpers, and single-thread fallbacks. Signatures extended with color_space and n_threads.
WASM: public headers & types
src/wasm/modules/image/include/bilateral_filter.h, src/wasm/modules/image/include/kmeans.h, src/wasm/modules/image/include/LABPixel.h, src/wasm/modules/image/include/LABAPixel.h, src/wasm/modules/image/include/cielab.h, src/wasm/modules/image/include/cielab_impl.h
Updated function signatures to accept color_space/n_threads; introduced LABPixel/LABAPixel types; converted cielab API to templated variants and pixel overloads; hid older kmeans export.
WASM: build config
src/wasm/modules/image/CMakeLists.txt
Enabled Emscripten worker/pthreads environment, set USE_PTHREADS/PTHREAD_POOL_SIZE, added -pthread/-msimd128, adjusted Debug flags.
WASM: headers & small utils
src/wasm/modules/image/include/image_utils.h, src/utils/image-utils.js
Removed unused iostream include; commented out explicit ImageTracer numberofcolors option.
WASM: graph logic
src/wasm/modules/image/src/graph.cpp
Merge comparator now considers color distance alongside area when choosing merge targets.
JS: worker integration & component
src/hooks/useWasmWorker.js, src/components/WasmImageProcessor.jsx
Worker API now accepts color_space and n_threads for bilateralFilter and kmeans; component bypasses/disabled thresholding and feeds bilateral-filtered pixels to kmeans (num_colors increased).
Dev server config
vite.config.js
Added COOP (Cross-Origin-Opener-Policy: same-origin) and COEP (Cross-Origin-Embedder-Policy: require-corp) headers and middleware plugin to enforce them.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Client (React)
  participant Hook as useWasmWorker (Main Thread)
  participant Worker as WASM Worker
  participant WASM as WASM Module
  participant PThreads as PThread Pool

  UI->>Hook: requestProcess(pixels, params, color_space, n_threads)
  Hook->>Worker: postMessage({ pixels, params, color_space, n_threads })
  Worker->>WASM: bilateral_filter(pixels..., color_space, n_threads)
  WASM->>PThreads: dispatch row segments (n_threads)
  PThreads-->>WASM: return segment results
  WASM-->>Worker: bilateral result
  Worker->>WASM: kmeans(bilateral_pixels..., num_colors, max_iter, color_space, n_threads)
  WASM->>PThreads: partition centroids/pixels (n_threads)
  PThreads-->>WASM: distances & labels
  WASM-->>Worker: kmeans result (pixels + labels)
  Worker-->>Hook: postMessage(results)
  Hook-->>UI: deliver processed pixels/labels
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement, wasm, C/C++, important, BREAKING CHANGE

Suggested reviewers

  • Ryan-Millard

Poem

🐇 I hopped through threads and color-space light,
Centroids found homes in parallel delight,
Bilateral breezes smoothed each tiny line,
LAB hues danced, pixels tuned just right,
The rabbit stitched the image — soft and tight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Multithreading + Color Rework' directly and concisely summarizes the two main changes: adding multithreading support and implementing CIELAB color space for kmeans.
Linked Issues check ✅ Passed The PR successfully implements multithreading for bilateral filter and kmeans [#231] and kmeans in CIELAB color space [#236], including configurable thread counts, LAB pixel types, and pixel-to-centroid distance computations.
Out of Scope Changes check ✅ Passed Changes include security headers in vite.config.js for SharedArrayBuffer support, SIMD flags in CMakeLists.txt, and documentation updates—all supporting the multithreading feature scope. No unrelated refactoring detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 Krasner mentioned this pull request Jan 18, 2026
@Ryan-Millard

Copy link
Copy Markdown
Owner
image

To fix the CI errors on this PR, you should run one of these two:

Generic:

./img2num format

Specific to JS:

./img2num format-js

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
vite.config.js (1)

1-1: Pipeline warning: Prettier formatting issue.

The CI pipeline flagged a Prettier formatting issue. Run prettier --write vite.config.js to fix.

src/hooks/useWasmWorker.js (1)

1-1: Pipeline warning: Prettier formatting issue.

The CI pipeline flagged a Prettier formatting issue. Run prettier --write src/hooks/useWasmWorker.js to fix.

src/wasm/modules/image/include/kmeans.h (1)

13-16: Add n_threads parameter to kmeans_clustering_spatial for consistency and performance.

The kmeans function uses multi-threading (default 8 threads) for parallel distance calculations and pixel assignments. kmeans_clustering_spatial, which performs similar k-means clustering work with spatial weighting, does not support threading and runs purely sequentially. Since both functions are exposed as public APIs and perform computationally intensive work, adding threading support to kmeans_clustering_spatial would improve consistency and performance parity.

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

85-157: Guard n_threads before division and loop bounds.

num_pixels / n_threads and k / n_threads will divide by zero if n_threads <= 0. Clamp to at least 1 and use the clamped value consistently.

🛠️ Suggested diff
-  std::vector<std::thread> threads;
-  int pixels_per_thread = num_pixels / n_threads;
-  int centroids_per_thread = k / n_threads;
+  const int thread_count = std::max(1, n_threads);
+  std::vector<std::thread> threads;
+  const int pixels_per_thread = num_pixels / thread_count;
+  const int centroids_per_thread = k / thread_count;
...
-    if (n_threads > 1) {
-      for (unsigned int i = 0; i < n_threads; ++i) {
+    if (thread_count > 1) {
+      for (int i = 0; i < thread_count; ++i) {
         int start_c = i * centroids_per_thread;
-        int end_c = (i == n_threads - 1 ) ? k : (i + 1) * centroids_per_thread;
+        int end_c = (i == thread_count - 1 ) ? k : (i + 1) * centroids_per_thread;
...
-      for (unsigned int i = 0; i < n_threads; ++i) {
+      for (int i = 0; i < thread_count; ++i) {
         int start_pixel = i * pixels_per_thread;
-        int end_pixel = (i == n_threads - 1 ) ? num_pixels : (i + 1) * pixels_per_thread;
+        int end_pixel = (i == thread_count - 1 ) ? num_pixels : (i + 1) * pixels_per_thread;
src/wasm/modules/image/src/bilateral_filter.cpp (1)

103-139: RGB path reads cie_image out of bounds.

cie_image is empty in RGB mode, but it’s still indexed for every neighbor. This is UB and can crash. Move the CIELAB reads into the CIELAB case.

🐛 Suggested diff
-          double L{cie_image[neighbor_idx]};
-          double A{cie_image[neighbor_idx + 1]};
-          double B{cie_image[neighbor_idx + 2]};
...
           case COLOR_SPACE_OPTION_CIELAB: {
-            dL = L - L0;
-            dA = A - A0;
-            dB = B - B0;
+            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;
🤖 Fix all issues with AI agents
In `@src/wasm/modules/image/src/bilateral_filter.cpp`:
- Around line 179-270: The function bilateral_filter computes rows_per_thread =
height / n_threads which will divide by zero when n_threads <= 0; clamp or
sanitize n_threads at start of bilateral_filter (e.g., set int threads =
std::max(1, n_threads)) and use that sanitized value when computing
rows_per_thread, launching threads, and when passing the thread-count into calls
to _process; update all uses of n_threads in the thread loop, bounds
(start_row/end_row), and the single-thread _process call to use the sanitized
threads variable so no division by zero or negative-thread behavior occurs.

In `@src/wasm/modules/image/src/kmeans.cpp`:
- Around line 42-67: The function _apply_labels has a type mismatch: it
currently takes std::vector<int>& labels but callers pass std::vector<int32_t>,
causing a compile error; change the parameter type in _apply_labels to
std::vector<int32_t>& labels (keep best_cluster as int32_t and other logic
unchanged), and update any call sites to match this signature so the label
vector types are consistent across _apply_labels and its callers.
🧹 Nitpick comments (4)
vite.config.js (1)

116-126: Consider removing redundant header configuration.

The middleware plugin sets the same COOP/COEP headers already defined in server.headers (lines 49-52). While having both is harmless, the middleware approach is more robust as it catches all responses including proxied requests.

You could remove the server.headers block and rely solely on this middleware, or vice versa, to reduce duplication.

src/wasm/modules/image/include/bilateral_filter.h (1)

18-20: LGTM! Consider updating documentation.

The n_threads parameter is appropriately added with a sensible default. The default of 8 aligns with PTHREAD_POOL_SIZE in the CMake configuration.

The function documentation (lines 9-17) should be updated to describe the new n_threads parameter for completeness.

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

23-38: Avoid serializing centroid writes with a global mutex.

Each thread writes distinct output[j] slices, so the lock only adds contention. Removing the lock (and write_mutex if unused elsewhere) should improve scalability.

♻️ Suggested diff
-std::mutex write_mutex;
 ...
-    std::unique_lock<std::mutex> lock(write_mutex);
-    std::copy(_res.begin(), _res.end(), output[j].begin());
-    lock.unlock();
+    std::copy(_res.begin(), _res.end(), output[j].begin());
src/wasm/modules/image/src/bilateral_filter.cpp (1)

145-173: Avoid per‑pixel mutex; row partitioning already isolates writes.

Each thread writes disjoint rows, so locking for every pixel adds contention and can erase threading gains. Removing the lock (and write_mutex if unused) should speed up.

♻️ Suggested diff
-      // writing - must grab mutex
-      std::unique_lock<std::mutex> lock(write_mutex);
-      
       switch (color_space) {
         case COLOR_SPACE_OPTION_RGB: {
           result[center_idx] = static_cast<uint8_t>(
               std::clamp(weight_acc_channel_0 / weight_acc, 0.0, 255.0));
...
           break;
         }
       }
-      // done writing
-      lock.unlock();

Comment thread src/wasm/modules/image/src/bilateral_filter.cpp Outdated
Comment thread src/wasm/modules/image/src/kmeans.cpp Outdated
Comment on lines +42 to +67
void _apply_labels(
const ImageLib::Image<ImageLib::RGBAPixel<float>>& pixels,
const std::vector<std::vector<float>>& distances,
std::vector<int>& labels,
int start_pixel,
int end_pixel,
int k,
std::atomic<bool>& changed
)
{
float min_color_dist{std::numeric_limits<float>::max()};
int32_t best_cluster{0};
for (int i{start_pixel}; i < end_pixel; ++i) {
min_color_dist = std::numeric_limits<float>::max();
best_cluster = 0;
for (int j{0}; j < k; ++j) {
if (distances[j][i] < min_color_dist) {
min_color_dist = distances[j][i];
best_cluster = j;
}
}
if (labels[i] != best_cluster) {
//std::unique_lock<std::mutex> lock(write_mutex);
labels[i] = best_cluster;
changed.store(true, std::memory_order_relaxed);
//lock.unlock();

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

Fix labels vector type mismatch (won’t compile).

_apply_labels expects std::vector<int>&, but the caller passes std::vector<int32_t>. This is a hard compile error.

🐛 Suggested diff
-  std::vector<int>& labels,
+  std::vector<int32_t>& labels,
🤖 Prompt for AI Agents
In `@src/wasm/modules/image/src/kmeans.cpp` around lines 42 - 67, The function
_apply_labels has a type mismatch: it currently takes std::vector<int>& labels
but callers pass std::vector<int32_t>, causing a compile error; change the
parameter type in _apply_labels to std::vector<int32_t>& labels (keep
best_cluster as int32_t and other logic unchanged), and update any call sites to
match this signature so the label vector types are consistent across
_apply_labels and its callers.

@Krasner

Krasner commented Jan 18, 2026

Copy link
Copy Markdown
Collaborator Author

i'm getting this error running ./img2num format

I'm not using docker. I'm just using yarn run dev:debug on my linux machine.

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.oneoff%3DFalse%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%2C%22com.docker.compose.service%3Ddev%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied
Starting dev container...
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?all=1&filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%2C%22com.docker.compose.service%3Ddev%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied

@Krasner

Krasner commented Jan 18, 2026

Copy link
Copy Markdown
Collaborator Author

@Ryan-Millard before you jump on to the next stuff, can you review this PR. it really does make things a lot faster with not many changes

@Ryan-Millard

Copy link
Copy Markdown
Owner

i'm getting this error running ./img2num format

I'm not using docker. I'm just using yarn run dev:debug on my linux machine.

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.oneoff%3DFalse%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%2C%22com.docker.compose.service%3Ddev%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied
Starting dev container...
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?all=1&filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/json?filters=%7B%22label%22%3A%7B%22com.docker.compose.config-hash%22%3Atrue%2C%22com.docker.compose.project%3Dimg2num%22%3Atrue%2C%22com.docker.compose.service%3Ddev%22%3Atrue%7D%7D": dial unix /var/run/docker.sock: connect: permission denied

That's not nice. You basically have 2 options:

  1. Install Docker (I take it that you don't want to since you haven't yet).
  2. Swap ./img2num for yarn run almost every time you see it (it does have a few other argument options, but not many - you can just look at the file).

I recommend just running:

yarn run format

It's a JS script.

@Ryan-Millard

Copy link
Copy Markdown
Owner

@Ryan-Millard before you jump on to the next stuff, can you review this PR. it really does make things a lot faster with not many changes

I was already looking at it. It seems much better.

@Krasner

Krasner commented Jan 18, 2026

Copy link
Copy Markdown
Collaborator Author
yarn run format

this works!

@Ryan-Millard

Copy link
Copy Markdown
Owner
yarn run format

this works!

Try this one:

yarn run help

CI enforces that all new scripts get descriptions added to the package.json file so this script can give nice outputs.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/wasm/modules/image/src/bilateral_filter.cpp (2)

136-164: Per-pixel mutex locking severely degrades multithreading performance.

Each pixel write acquires/releases the global mutex, serializing nearly all work and negating the benefit of multiple threads. Since each thread writes to a disjoint row range, no synchronization is needed.

🛠️ Suggested fix: Remove mutex from _process
-      // writing - must grab mutex
-      std::unique_lock<std::mutex> lock(write_mutex);
-
       switch (color_space) {
       // ... (keep switch body unchanged)
       }
-      // done writing
-      lock.unlock();

98-100: Undefined behavior: accessing empty cie_image in RGB mode.

Lines 98-100 unconditionally read from cie_image, but when color_space == COLOR_SPACE_OPTION_RGB, the vector is empty. This causes out-of-bounds access.

🐛 Suggested fix: Move CIELAB reads inside the conditional
-          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: {
             // ... RGB logic unchanged
             break;
           }
           case COLOR_SPACE_OPTION_CIELAB: {
+            double L{cie_image[neighbor_idx]};
+            double A{cie_image[neighbor_idx + 1]};
+            double B{cie_image[neighbor_idx + 2]};
             dL = L - L0;
             // ... rest unchanged
           }
🤖 Fix all issues with AI agents
In `@src/wasm/modules/image/src/kmeans.cpp`:
- Around line 92-100: The block initializing distances in kmeans.cpp is
misformatted (the std::vector<std::vector<float>> distances declaration and
subsequent resize loop); run the repository formatter and commit the changes so
the clang-format CI check passes (e.g., run `yarn run format` or `./img2num
format`), ensuring the declarations around distances, n_threads, k, and
num_pixels follow the project's clang-format rules before pushing.
- Around line 79-81: Validate and clamp n_threads to be at least 1 before
computing pixels_per_thread and centroids_per_thread: create a local
thread_count = std::max(1, n_threads) (or equivalent), then compute int
pixels_per_thread = num_pixels / thread_count and int centroids_per_thread = k /
thread_count; also replace subsequent uses of n_threads in the kmeans threading
logic (references to threads, pixels_per_thread, centroids_per_thread and any
loops using n_threads) with thread_count to prevent division-by-zero and
negative-thread counts.
♻️ Duplicate comments (2)
src/wasm/modules/image/src/bilateral_filter.cpp (1)

179-181: Division by zero when n_threads <= 0 remains unaddressed.

This issue was flagged in a previous review. Clamp n_threads to at least 1 before computing rows_per_thread.

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

42-44: Type mismatch between labels vector and _apply_labels parameter.

This was flagged in a previous review. Change parameter type to std::vector<int32_t>& to match the caller.

🧹 Nitpick comments (3)
src/wasm/modules/image/src/bilateral_filter.cpp (1)

237-246: Type mismatch in thread loop.

Loop variable i is unsigned int while n_threads is int. After fixing the n_threads validation, use a consistent type (e.g., int i) or cast appropriately to avoid signed/unsigned comparison warnings.

src/wasm/modules/image/src/kmeans.cpp (2)

36-38: Mutex is unnecessary for disjoint centroid writes.

Each thread writes to its own range of output[j] indices (start_centroid to end_centroid), so no concurrent writes occur. Remove the mutex to improve performance.

♻️ Suggested fix
   for (int j{start_centroid}; j < end_centroid; ++j) {
     std::transform(pixels.begin(), pixels.end(), _res.begin(),
                    [&centroids, j](const ImageLib::RGBAPixel<float> &p) {
                      return colorDistance(p, centroids[j]);
                    });
-    std::unique_lock<std::mutex> lock(write_mutex);
     std::copy(_res.begin(), _res.end(), output[j].begin());
-    lock.unlock();
   }

107-114: Edge case: When k < n_threads, most threads do no work.

If the number of centroids k is less than thread_count, centroids_per_thread becomes 0, and only the last thread processes all centroids. Consider limiting thread count to min(thread_count, k) for the distance computation phase.

Comment thread src/wasm/modules/image/src/kmeans.cpp Outdated
Comment thread src/wasm/modules/image/src/kmeans.cpp Outdated

@Ryan-Millard Ryan-Millard left a comment

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.

I'm sorry for taking so long to review this.

This is an excellent implementation and I like it a lot. It's clean and efficient. I just have a few requests from you in conjunction to the comments I left on the files:

  1. Please will you use the Image class - it makes code much cleaner.
  2. I'd like to discuss the possibility of tiling and SIMD in this PR since the point is to speed things up rather than to add threads and I think adding those as well would really help (especially SIMD).

Comment thread src/wasm/modules/image/include/kmeans.h Outdated
Comment thread src/wasm/modules/image/src/bilateral_filter.cpp Outdated
Comment thread src/wasm/modules/image/src/bilateral_filter.cpp
Comment thread src/components/WasmImageProcessor.jsx Outdated
Comment on lines +92 to +99
num_colors: 16,
});

step(70);
const { pixels: kmeansed, labels } = await kmeans({
...fileData,
pixels: thresholded,
num_colors: 8,
num_colors: 16,

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.

I don't recommend using 16 for this right now because it really slows down larger images and will make it difficult for mobile users to use the app.

My proposed reversion
Suggested change
num_colors: 16,
});
step(70);
const { pixels: kmeansed, labels } = await kmeans({
...fileData,
pixels: thresholded,
num_colors: 8,
num_colors: 16,
num_colors: 8,
});
step(70);
const { pixels: kmeansed, labels } = await kmeans({
...fileData,
pixels: thresholded,
num_colors: 8,

Try running this image through it:

Image

Unless you have a strong PC, it'll take a while to process and then lag when you try to color it in or interact with the viewport on the editor page.

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.

this is in my set of test images. with multithreading it processes fast on my machine which is using an i7-13700H cpu

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.

Try processing the image on a cellphone.

In the future, we will add a config panel for the web app, but we don't have that right now, so this could be a problem for some users.

Comment thread src/wasm/modules/image/CMakeLists.txt Outdated
Comment thread src/wasm/modules/image/src/kmeans.cpp
Comment thread src/wasm/modules/image/src/kmeans.cpp Outdated
Comment thread src/wasm/modules/image/src/kmeans.cpp Outdated
Comment thread src/wasm/modules/image/src/kmeans.cpp
Comment thread vite.config.js
@Krasner

Krasner commented Jan 19, 2026

Copy link
Copy Markdown
Collaborator Author

2. I'd like to discuss the possibility of tiling and SIMD in this PR since the point is to speed things up rather than to add threads and I think adding those as well would really help (especially SIMD).

There is no problem doing multithreading and SIMD together. A smart compiler should figure out SIMD on it's own but it also is very hardware specific. So for now let's trust the compiler

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/wasm/modules/image/src/bilateral_filter.cpp (1)

94-124: Guard CIELAB buffer reads in RGB mode (out-of-bounds).
In RGB mode cie_image is empty, yet reads are unconditional. This is UB; move these reads into the CIELAB case.

🐛 Suggested fix
-          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)];
@@
           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;
🤖 Fix all issues with AI agents
In `@src/wasm/modules/image/src/bilateral_filter.cpp`:
- Around line 237-241: The block that spawns threads (referencing n_threads,
rows_per_thread, start_row, end_row) is failing clang-format; reformat the file
using the repository formatting wrapper (e.g., run ./img2num format-wasm or
./img2num format) instead of invoking clang-format directly, then commit the
updated bilateral_filter.cpp so the loop and related declarations conform to the
project's style rules.
♻️ Duplicate comments (2)
src/wasm/modules/image/src/kmeans.cpp (1)

41-44: Fix labels vector type mismatch (won’t compile).
_apply_labels takes std::vector<int>& but callers pass std::vector<int32_t>.

🐛 Suggested fix
-                   std::vector<int> &labels, int start_pixel, int end_pixel,
+                   std::vector<int32_t> &labels, int start_pixel, int end_pixel,
src/wasm/modules/image/src/bilateral_filter.cpp (1)

179-182: Clamp n_threads before computing rows_per_thread.
rows_per_thread divides by n_threads; passing 0 will crash. Use a clamped thread_count for division, loop bounds, and thread dispatch.

🛠️ Suggested fix
-  std::vector<std::thread> threads;
-
-  int rows_per_thread = static_cast<int>(height) / static_cast<int>(n_threads);
+  const int thread_count{std::max(1, static_cast<int>(n_threads))};
+  std::vector<std::thread> threads;
+  int rows_per_thread{static_cast<int>(height) / thread_count};
@@
-  if (n_threads > 1) {
-    for (int i = 0; i < n_threads; ++i) {
+  if (thread_count > 1) {
+    for (int i = 0; i < thread_count; ++i) {
       int start_row{i * rows_per_thread};
-      int end_row{(i == n_threads - 1) ? static_cast<int>(height) : (i + 1) * rows_per_thread};
+      int end_row{(i == thread_count - 1) ? static_cast<int>(height)
+                                          : (i + 1) * rows_per_thread};
@@
-      threads.emplace_back(_process, std::cref(image), std::cref(cie_image),
-                           std::ref(result), std::cref(spatial_weights),
-                           std::cref(range_lut), radius, sigma_range, start_row,
-                           end_row, height, width, color_space, n_threads);
+      threads.emplace_back(_process, std::cref(image), std::cref(cie_image),
+                           std::ref(result), std::cref(spatial_weights),
+                           std::cref(range_lut), radius, sigma_range, start_row,
+                           end_row, height, width, color_space,
+                           static_cast<uint8_t>(thread_count));
     }
@@
-  } else {
-    _process(image, cie_image, result, spatial_weights, range_lut, radius,
-             sigma_range, 0, static_cast<int>(height), height, width,
-             color_space, n_threads);
+  } else {
+    _process(image, cie_image, result, spatial_weights, range_lut, radius,
+             sigma_range, 0, static_cast<int>(height), height, width,
+             color_space, static_cast<uint8_t>(thread_count));
   }
Based on learnings, keep the thread-count clamp before any division.

Comment thread src/wasm/modules/image/src/bilateral_filter.cpp

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/wasm/modules/image/src/bilateral_filter.cpp (1)

98-100: Move cie_image reads into the CIELAB case to prevent out-of-bounds access in RGB mode.

Lines 98–100 unconditionally read from cie_image, but this vector is only populated in CIELAB mode (line 215–216). In RGB mode, cie_image remains empty, causing out-of-bounds access. Since L, A, and B are used only in the CIELAB case (lines 45–47), move these reads inside the case block.

Proposed fix
-          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: {
             // ...
           }
           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;
🤖 Fix all issues with AI agents
In `@src/wasm/modules/image/src/kmeans.cpp`:
- Around line 79-82: Validate the centroid count k before calling std::clamp to
avoid UB: at the start of the function that contains n_threads, k, and
num_pixels (where nthreads, pixels_per_thread, centroids_per_thread are
computed) add a guard that checks k > 0 and handle invalid input (e.g., throw
std::invalid_argument or return an error status), then use the validated k when
computing nthreads = std::clamp(static_cast<int>(n_threads), 1, k) and the
subsequent pixels_per_thread and centroids_per_thread calculations; ensure the
new check occurs before any use of k in std::clamp or division operations.
♻️ Duplicate comments (2)
src/wasm/modules/image/src/kmeans.cpp (1)

41-44: Fix labels vector type mismatch (compile error).

_apply_labels takes std::vector<int>&, but the caller passes std::vector<int32_t>. This won’t compile; align the parameter type with the callers.

🐛 Proposed fix
-void _apply_labels(const ImageLib::Image<ImageLib::RGBAPixel<float>> &pixels,
-                   const std::vector<std::vector<float>> &distances,
-                   std::vector<int> &labels, int start_pixel, int end_pixel,
-                   int k, std::atomic<bool> &changed) {
+void _apply_labels(const ImageLib::Image<ImageLib::RGBAPixel<float>> &pixels,
+                   const std::vector<std::vector<float>> &distances,
+                   std::vector<int32_t> &labels, int start_pixel, int end_pixel,
+                   int k, std::atomic<bool> &changed) {
src/wasm/modules/image/src/bilateral_filter.cpp (1)

179-182: Clamp n_threads to at least 1 before division.

rows_per_thread divides by n_threads, so n_threads == 0 will crash. Clamp once and use the sanitized value everywhere in this block. Based on learnings, ensure the clamped count is used for all thread math.

🛠️ Suggested fix
-  std::vector<std::thread> threads;
-
-  int rows_per_thread = static_cast<int>(height) / static_cast<int>(n_threads);
+  const int thread_count{std::max(1, static_cast<int>(n_threads))};
+  std::vector<std::thread> threads;
+  const int rows_per_thread{static_cast<int>(height) / thread_count};
@@
-  if (n_threads > 1) {
-    for (int i = 0; i < n_threads; ++i) {
+  if (thread_count > 1) {
+    for (int i{0}; i < thread_count; ++i) {
       int start_row{i * rows_per_thread};
-      int end_row{(i == n_threads - 1) ? static_cast<int>(height)
+      int end_row{(i == thread_count - 1) ? static_cast<int>(height)
                                        : (i + 1) * rows_per_thread};
@@
-                           end_row, height, width, color_space, n_threads);
+                           end_row, height, width, color_space,
+                           static_cast<uint8_t>(thread_count));
@@
-    _process(image, cie_image, result, spatial_weights, range_lut, radius,
-             sigma_range, 0, static_cast<int>(height), height, width,
-             color_space, n_threads);
+    _process(image, cie_image, result, spatial_weights, range_lut, radius,
+             sigma_range, 0, static_cast<int>(height), height, width,
+             color_space, static_cast<uint8_t>(thread_count));

Comment thread src/wasm/modules/image/src/kmeans.cpp
@Krasner Krasner changed the title Multithreading Multithreading + Color Rework Jan 19, 2026

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/wasm/modules/image/src/bilateral_filter.cpp (1)

94-100: Guard cie_image access in RGB mode (out-of-bounds read).

Lines 98–100 read from cie_image unconditionally; in RGB mode, the vector is empty and these accesses are undefined behavior. The center pixel reads are correctly guarded at lines 70–74, but the neighbor pixel reads are not. Move the reads into the CIELAB case where the vector is guaranteed populated and the values are actually used.

Proposed fix
-          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: {
             ...
             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;

Also applies to: 118–130

🤖 Fix all issues with AI agents
In `@src/components/WasmImageProcessor.jsx`:
- Around line 97-101: The destructured variable kmeansed from the kmeans(...)
call is unused and causing an ESLint error; update the await call in
WasmImageProcessor.jsx to only destructure the needed value(s) (e.g., const {
labels } = await kmeans({ ...fileData, pixels: imgBilateralFiltered, num_colors:
16 });) or otherwise use kmeansed where intended—modify the kmeans invocation or
subsequent code so you no longer declare an unused variable named kmeansed.

In `@src/wasm/modules/image/include/LABPixel.h`:
- Around line 16-28: The setGray implementation in class LABPixel currently
zeroes a and b but fails to assign the luminance channel; update
LABPixel::setGray(NumberT gray) to set l = gray (with appropriate static_cast if
needed) and then set a = b = static_cast<NumberT>(0) so the L channel reflects
the grayscale value; ensure you modify the inline void setGray(NumberT gray)
method accordingly.

In `@src/wasm/modules/image/src/graph.cpp`:
- Around line 10-22: The function colorDistance uses std::sqrt but the
translation unit lacks <cmath>; add the missing include directive so std::sqrt
is defined. Open graph.cpp and add `#include` <cmath> near the top of the file
(alongside other includes) to resolve the compile error referenced in the
colorDistance function.

In `@src/wasm/modules/image/src/kmeans.cpp`:
- Around line 82-145: In kMeansPlusPlusInit, add input validation at the top to
guard against num_pixels == 0 and nonpositive k: compute int num_pixels =
pixels.getSize(); if (num_pixels <= 0 || k <= 0) return or throw (consistent
with project error handling); also validate k <= num_pixels and that
out_centroids has capacity for k (e.g., out_centroids.getSize() >= k) and handle
the error similarly; these checks must appear before the
uniform_int_distribution call and any indexing (references: function
kMeansPlusPlusInit, variables num_pixels, k, out_centroids, and the
uniform_int_distribution usage).
♻️ Duplicate comments (4)
src/wasm/modules/image/src/bilateral_filter.cpp (2)

29-29: Remove the per-pixel mutex to avoid serializing output.

Threads operate on disjoint row ranges, so the lock at Line 136 just serializes writes and negates parallelism. Safe to drop it.

♻️ Proposed fix
-std::mutex write_mutex;
...
-      // writing - must grab mutex
-      std::unique_lock<std::mutex> lock{write_mutex};
-
       switch (color_space) {
         ...
       }
-      // done writing
-      lock.unlock();

Also applies to: 136-165


179-183: Clamp n_threads before computing rows_per_thread.

Line 181 divides by n_threads before the n_threads > 1 check; n_threads == 0 will crash. Use a sanitized thread_count >= 1 for division, loop bounds, and _process calls.

🛠️ Proposed fix
-  std::vector<std::thread> threads;
-
-  int rows_per_thread = static_cast<int>(height) / static_cast<int>(n_threads);
+  const int thread_count{std::max(1, static_cast<int>(n_threads))};
+  std::vector<std::thread> threads;
+  int rows_per_thread{static_cast<int>(height) / thread_count};
...
-  if (n_threads > 1) {
-    for (int i = 0; i < n_threads; ++i) {
+  if (thread_count > 1) {
+    for (int i = 0; i < thread_count; ++i) {
       int start_row{i * rows_per_thread};
-      int end_row{(i == n_threads - 1) ? static_cast<int>(height)
+      int end_row{(i == thread_count - 1) ? static_cast<int>(height)
                                        : (i + 1) * rows_per_thread};
       threads.emplace_back(_process, std::cref(image), std::cref(cie_image),
                            std::ref(result), std::cref(spatial_weights),
                            std::cref(range_lut), radius, sigma_range, start_row,
-                           end_row, height, width, color_space, n_threads);
+                           end_row, height, width, color_space, thread_count);
     }
...
-  } else {
+  } else {
     _process(image, cie_image, result, spatial_weights, range_lut, radius,
              sigma_range, 0, static_cast<int>(height), height, width,
-             color_space, n_threads);
+             color_space, thread_count);
   }

Based on learnings, apply a safe thread-count before any division.

Also applies to: 237-259

src/wasm/modules/image/src/kmeans.cpp (2)

147-175: Guard k before std::clamp.

Line 171 calls std::clamp(..., 1, k) which requires k >= 1. If k <= 0, this is UB and can also break per-thread divisions. Validate k (and image dimensions) before computing thread counts.

🛠️ Proposed fix
 void kmeans(const uint8_t *data, uint8_t *out_data, int32_t *out_labels,
             const int32_t width, const int32_t height, const int32_t k,
             const int32_t max_iter, const uint8_t color_space, const uint8_t n_threads) {
+  if (k <= 0 || width <= 0 || height <= 0) {
+    return;
+  }
   ImageLib::Image<ImageLib::RGBAPixel<float>> pixels;
   pixels.loadFromBuffer(data, width, height, ImageLib::RGBA_CONVERTER<float>);
   const int32_t num_pixels{pixels.getSize()};
   ...
-  int nthreads = std::clamp(static_cast<int>(n_threads), 1, k);
+  const int nthreads{std::clamp(static_cast<int>(n_threads), 1, k)};

Based on learnings, derive a safe thread count before any divisions.


58-77: Fix labels vector type mismatch (compile error).

labels is std::vector<int32_t> in kmeans, but _apply_labels expects std::vector<int>&. This won’t compile.

🐛 Proposed fix
 template <typename PixelT>
 void _apply_labels(const ImageLib::Image<PixelT> &pixels,
                    const std::vector<std::vector<float>> &distances,
-                   std::vector<int> &labels, int start_pixel, int end_pixel,
+                   std::vector<int32_t> &labels, int start_pixel, int end_pixel,
                    int k, std::atomic<bool> &changed) {
🧹 Nitpick comments (4)
src/wasm/modules/image/CMakeLists.txt (1)

34-50: Make pthread pool size configurable to match runtime n_threads.

PTHREAD_POOL_SIZE=8 is hard-coded, but JS allows passing n_threads. If a caller supplies a higher value, the worker pool may cap or error depending on Emscripten behavior. Consider exposing the pool size as a CMake cache option and keeping defaults aligned with the JS-side defaults.

♻️ Proposed refactor
+# Allow overriding pthread pool size at configure time
+set(PTHREAD_POOL_SIZE 8 CACHE STRING "Emscripten pthread pool size")
+
 set(COMMON_FLAGS
   "SHELL:-s MODULARIZE=1"
   "SHELL:-s EXPORT_ES6=1"
   "SHELL:-s EXIT_RUNTIME=1"
   "SHELL:-s ENVIRONMENT=web,worker"
   "SHELL:-s EXPORTED_FUNCTIONS=['_malloc','_free']"
   "SHELL:-s EXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8','HEAP32']"
   "SHELL:-s INITIAL_MEMORY=1024MB"
   "SHELL:-s MAXIMUM_MEMORY=2048MB"
   "SHELL:-s ALLOW_MEMORY_GROWTH=1"
   "SHELL:-s EXPORT_NAME=create${CAP_MODULE_NAME}Module"
   "SHELL:-s USE_PTHREADS=1"
-  "SHELL:-s PTHREAD_POOL_SIZE=8"
+  "SHELL:-s PTHREAD_POOL_SIZE=${PTHREAD_POOL_SIZE}"
 )
src/wasm/modules/image/src/graph.cpp (1)

10-22: Avoid sqrt in the sort key for better throughput.

You only need relative ordering, so a squared distance avoids sqrt. This also matches the approach in src/wasm/modules/image/src/kmeans.cpp (lines 19-25), which explicitly avoids sqrt.

♻️ Proposed refactor
-  return std::sqrt((af.red - bf.red) * (af.red - bf.red) +
-         (af.green - bf.green) * (af.green - bf.green) +
-         (af.blue - bf.blue) * (af.blue - bf.blue));
+  return (af.red - bf.red) * (af.red - bf.red) +
+         (af.green - bf.green) * (af.green - bf.green) +
+         (af.blue - bf.blue) * (af.blue - bf.blue);
src/hooks/useWasmWorker.js (1)

38-69: Clamp n_threads to a safe minimum before sending to WASM.

If a caller passes 0 or a negative value, the wasm side may hit divide-by-zero or invalid thread scheduling. Consider sanitizing n_threads in the hook.

♻️ Proposed refactor
   const bilateralFilter = async ({
     pixels,
     width,
     height,
     sigma_spatial = 3.0,
     sigma_range = 50.0,
     color_space = 0,
     n_threads = 8,
   }) => {
+    const threadCount = Math.max(1, Math.floor(n_threads));
     return (
-      await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space, n_threads }, [
+      await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space, n_threads: threadCount }, [
         'pixels',
       ])
     ).output.pixels;
   };
   const kmeans = async ({
     pixels,
     out_pixels = new Uint8ClampedArray(pixels.length),
     width,
     height,
     out_labels = new Int32Array(width * height),
     num_colors,
     max_iter = 250,
     color_space = 0,
     n_threads = 8,
   }) => {
+    const threadCount = Math.max(1, Math.floor(n_threads));
     const result = (
-      await call('kmeans', { pixels, out_pixels, out_labels, width, height, num_colors, max_iter, color_space, n_threads }, [
+      await call('kmeans', { pixels, out_pixels, out_labels, width, height, num_colors, max_iter, color_space, n_threads: threadCount }, [
         'pixels',
         'out_pixels',
         'out_labels',
       ])
     ).output;

Based on learnings, ensure thread counts are non-zero before any division or scheduling logic.

src/wasm/modules/image/include/cielab_impl.h (1)

98-133: Avoid integral wraparound in LAB outputs.

rgb_to_lab casts to Tout before clamping; if Tout is integral/unsigned, negative a/b (and any pre-clamp L) can wrap. Consider clamping in double and/or constraining Tout to floating-point.

♻️ Proposed fix
 template <typename Tin, typename Tout>
 void rgb_to_lab(const Tin r_u8, const Tin g_u8, const Tin b_u8,
                 Tout &out_l, Tout &out_a, Tout &out_b) {
+  static_assert(std::is_floating_point_v<Tout>,
+                "LAB outputs should be floating-point to preserve sign/precision.");
   // 1. Convert 8-bit RGB [0, 255] to linear RGB [0.0, 1.0]
   ...
-  out_l = static_cast<Tout>(LAB_L_FACTOR * fy - LAB_L_OFFSET);
-  out_a = static_cast<Tout>(LAB_A_FACTOR * (fx - fy));
-  out_b = static_cast<Tout>(LAB_B_FACTOR * (fy - fz));
-
-  out_l = std::clamp(out_l, static_cast<Tout>(0.0), static_cast<Tout>(100.0));
+  const double l = LAB_L_FACTOR * fy - LAB_L_OFFSET;
+  const double a = LAB_A_FACTOR * (fx - fy);
+  const double b = LAB_B_FACTOR * (fy - fz);
+  out_l = static_cast<Tout>(std::clamp(l, 0.0, 100.0));
+  out_a = static_cast<Tout>(a);
+  out_b = static_cast<Tout>(b);
 }

Comment thread src/components/WasmImageProcessor.jsx
Comment thread src/wasm/modules/image/include/LABPixel.h Outdated
Comment thread src/wasm/modules/image/src/graph.cpp Outdated
Comment on lines +82 to 145
// The K-Means++ Initialization Function
template <typename PixelT>
void kMeansPlusPlusInit(const ImageLib::Image<PixelT>& pixels, ImageLib::Image<PixelT>& out_centroids, int k) {
std::vector<PixelT> centroids;

int num_pixels = pixels.getSize();
int c_idx{0}; // centroid index
// Random number generator setup
std::random_device rd;
std::mt19937 gen(rd());

// --- Step 1: Choose the first centroid uniformly at random ---
std::uniform_int_distribution<> dis(0, num_pixels - 1);
int first_index = dis(gen);
centroids.push_back(pixels[first_index]);

// Vector to store the squared distance of each pixel to its NEAREST existing centroid.
// Initialize with max double so the first distance calculation always updates it.
std::vector<double> min_dist_sq(num_pixels, std::numeric_limits<double>::max());

// --- Step 2 & 3: Repeat until we have k centroids ---
for (int i = 1; i < k; ++i) {

double sum_dist_sq = 0.0;

// Update distances relative to the LAST added centroid (centroids.back())
// We don't need to recheck previous centroids; min_dist_sq already holds the best distance to them.
for (int j = 0; j < num_pixels; ++j) {
double d = colorDistance(pixels[j], centroids.back());

// If this new centroid is closer than the previous best, update the min distance
if (d < min_dist_sq[j]) {
min_dist_sq[j] = d;
}
sum_dist_sq += min_dist_sq[j];
}

// --- Step 3: Choose new center with probability proportional to D(x)^2 ---
// We use a weighted random selection (Roulette Wheel Selection)
std::uniform_real_distribution<> dist_selector(0.0, sum_dist_sq);
double random_value = dist_selector(gen);

double current_sum = 0.0;
int selected_index = -1;

// Iterate to find the pixel corresponding to the random_value
for (int j = 0; j < num_pixels; ++j) {
current_sum += min_dist_sq[j];
if (current_sum >= random_value) {
selected_index = j;
break;
}
}

// Fallback for floating point rounding errors (pick last one if loop finishes)
if (selected_index == -1) {
selected_index = num_pixels - 1;
}

centroids.push_back(pixels[selected_index]);
}

std::copy(centroids.begin(), centroids.end(), out_centroids.begin());
}

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

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file and confirm the code matches the review comment
cat -n src/wasm/modules/image/src/kmeans.cpp | sed -n '82,145p'

Repository: Ryan-Millard/Img2Num

Length of output: 2755


🏁 Script executed:

# Also check the full function context to see if there are any surrounding checks
cat -n src/wasm/modules/image/src/kmeans.cpp | sed -n '75,155p'

Repository: Ryan-Millard/Img2Num

Length of output: 3390


🏁 Script executed:

# Let's search for how this function is called in the codebase
rg "kMeansPlusPlusInit" -A 3 -B 3

Repository: Ryan-Millard/Img2Num

Length of output: 1267


🏁 Script executed:

# Check if there's an Image class and its getSize() method
fd -e "\.h" -e "\.hpp" | xargs rg "class Image" -A 10 | head -50

Repository: Ryan-Millard/Img2Num

Length of output: 1552


🏁 Script executed:

# Find the Image class definition and getSize() method
cat -n src/wasm/modules/image/include/Image.h | head -100

Repository: Ryan-Millard/Img2Num

Length of output: 3360


🏁 Script executed:

# Look for the getSize() method specifically
rg "getSize" src/wasm/modules/image/include/Image.h -A 2

Repository: Ryan-Millard/Img2Num

Length of output: 176


🏁 Script executed:

# Check the kmeans function to see if k is validated before calling kMeansPlusPlusInit
cat -n src/wasm/modules/image/src/kmeans.cpp | sed -n '151,200p'

Repository: Ryan-Millard/Img2Num

Length of output: 2215


🏁 Script executed:

# Check if there's any validation of k parameter in the kmeans function
cat -n src/wasm/modules/image/src/kmeans.cpp | sed -n '151,250p'

Repository: Ryan-Millard/Img2Num

Length of output: 4021


Add input validation to prevent undefined behavior with empty or invalid inputs.

uniform_int_distribution(0, num_pixels - 1) results in undefined behavior when num_pixels == 0 (invalid range). Additionally, k <= 0 creates invalid state. Add an early guard:

Proposed fix
 template <typename PixelT>
 void kMeansPlusPlusInit(const ImageLib::Image<PixelT> &pixels,
                         ImageLib::Image<PixelT> &out_centroids, int k) {
   std::vector<PixelT> centroids;
 
-  int num_pixels = pixels.getSize();
+  const int num_pixels{pixels.getSize()};
+  if (k <= 0 || num_pixels <= 0) {
+    return;
+  }
🤖 Prompt for AI Agents
In `@src/wasm/modules/image/src/kmeans.cpp` around lines 82 - 145, In
kMeansPlusPlusInit, add input validation at the top to guard against num_pixels
== 0 and nonpositive k: compute int num_pixels = pixels.getSize(); if
(num_pixels <= 0 || k <= 0) return or throw (consistent with project error
handling); also validate k <= num_pixels and that out_centroids has capacity for
k (e.g., out_centroids.getSize() >= k) and handle the error similarly; these
checks must appear before the uniform_int_distribution call and any indexing
(references: function kMeansPlusPlusInit, variables num_pixels, k,
out_centroids, and the uniform_int_distribution usage).

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/wasm/modules/image/src/kmeans.cpp`:
- Around line 166-208: Validate the color_space input early and add robust
handling for invalid values: check the color_space parameter (used in switches
around COLOR_SPACE_OPTION_RGB and COLOR_SPACE_OPTION_CIELAB) before any
processing and return/throw an error (or set a safe default) if it's not one of
the expected enums; also add default branches to every switch that currently
lacks one (including the initialization switch that calls kMeansPlusPlusInit,
the distance computation switch that sets dist, and any switches that touch lab
or centroids_lab) to either initialize variables to safe values or abort with a
clear error so dist is never left uninitialized and lab is not left
unconstructed when COLOR_SPACE_OPTION_CIELAB is required. Ensure any early
return or exception prevents further use of uninitialized variables (dist, lab)
and keep behavior consistent across functions that reference color_space.
♻️ Duplicate comments (5)
src/wasm/modules/image/src/graph.cpp (1)

10-21: Add the missing <cmath> include for std::sqrt.

Line 19 calls std::sqrt, but <cmath> is not included in this translation unit. This will fail to compile unless it’s indirectly included, which is fragile. Please add <cmath> explicitly.

🔧 Suggested fix
 `#include` "Pixel.h"
 `#include` <algorithm>
+#include <cmath>
 `#include` <iterator>
src/wasm/modules/image/src/kmeans.cpp (3)

151-180: Validate k before std::clamp and thread partitioning.

std::clamp(..., 1, k) is undefined if k <= 0, and subsequent divisions (/ nthreads) can blow up. Add an early guard before creating centroids or clamping.

🐛 Suggested guard
   pixels.loadFromBuffer(data, width, height, ImageLib::RGBA_CONVERTER<float>);
   const int32_t num_pixels{pixels.getSize()};
+  if (k <= 0 || num_pixels <= 0) {
+    return;
+  }
 
   // width = k, height = 1
   ImageLib::Image<ImageLib::RGBAPixel<float>> centroids{k, 1};

56-60: Fix labels vector type mismatch (won’t compile).

_apply_labels takes std::vector<int>& but callers pass std::vector<int32_t>. This is a hard compile error.

🐛 Suggested fix
 void _apply_labels(const ImageLib::Image<PixelT> &pixels,
                    const std::vector<std::vector<float>> &distances,
-                   std::vector<int> &labels, int start_pixel, int end_pixel,
+                   std::vector<int32_t> &labels, int start_pixel, int end_pixel,
                    int k, std::atomic<bool> &changed) {

82-95: Guard kMeansPlusPlusInit against empty inputs.

uniform_int_distribution(0, num_pixels - 1) is invalid when num_pixels == 0, and k <= 0 creates invalid state.

🐛 Proposed guard
-  int num_pixels = pixels.getSize();
+  const int num_pixels = pixels.getSize();
+  if (k <= 0 || num_pixels <= 0) {
+    return;
+  }
src/wasm/modules/image/include/LABPixel.h (1)

27-28: Fix grayscale conversion to set the L channel.

setGray currently leaves l unchanged, so grayscale writes keep stale luminance values.

🐛 Proposed fix
-  inline void setGray(NumberT gray) { a = b = static_cast<NumberT>(0); }
+  inline void setGray(NumberT gray) {
+    l = gray;
+    a = b = static_cast<NumberT>(0);
+  }
🧹 Nitpick comments (3)
src/wasm/modules/image/src/graph.cpp (1)

152-159: Precompute sort keys to avoid repeated colorDistance work inside the comparator.

The comparator recomputes colorDistance (and std::sqrt) many times during sort. Consider precomputing a weighted key per neighbor and sorting by that to reduce CPU cost without changing behavior.

♻️ Suggested refactor
-        std::sort(neighbors.begin(), neighbors.end(),
-                  [col](Node_ptr a, Node_ptr b) {
-                    float cdista = colorDistance(a->color(), col);
-                    float cdistb = colorDistance(b->color(), col);
-                    return (static_cast<float>(a->area()) + 10.f * cdista) <
-                           (static_cast<float>(b->area()) + 10.f * cdistb);
-                  });
+        struct NeighborScore {
+          Node_ptr node;
+          float score;
+        };
+        std::vector<NeighborScore> scored;
+        scored.reserve(neighbors.size());
+        for (const auto &ne : neighbors) {
+          const float cdist = colorDistance(ne->color(), col);
+          const float score = static_cast<float>(ne->area()) + 10.f * cdist;
+          scored.push_back({ne, score});
+        }
+        std::sort(scored.begin(), scored.end(),
+                  [](const NeighborScore &a, const NeighborScore &b) {
+                    return a.score < b.score;
+                  });
+        neighbors.clear();
+        neighbors.reserve(scored.size());
+        for (const auto &s : scored) {
+          neighbors.push_back(s.node);
+        }
src/wasm/modules/image/include/cielab_impl.h (1)

98-179: Add round‑trip accuracy tests for the RGB↔LAB templates.

Given the forward/inverse matrix constants, add tests that round‑trip RGB→LAB→RGB (and LAB→RGB→LAB) within a defined tolerance to catch coefficient drift and precision regressions.

Based on learnings, add a tolerance-based symmetry test to validate conversion accuracy.

Also applies to: 181-210

src/hooks/useWasmWorker.js (1)

38-72: Normalize n_threads before posting to WASM.

Optional: coerce to a positive integer to avoid accidental floats/zeros being sent across the ABI.

♻️ Suggested tweak
   const bilateralFilter = async ({
     pixels,
     width,
     height,
     sigma_spatial = 3.0,
     sigma_range = 50.0,
     color_space = 0,
     n_threads = 8,
   }) => {
+    const threadCount = Math.max(1, Math.trunc(n_threads));
     return (
-      await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space, n_threads }, [
+      await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space, n_threads: threadCount }, [
         'pixels',
       ])
     ).output.pixels;
   };
@@
   const kmeans = async ({
     pixels,
     out_pixels = new Uint8ClampedArray(pixels.length),
     width,
     height,
     out_labels = new Int32Array(width * height),
     num_colors,
     max_iter = 250,
     color_space = 0,
     n_threads = 8,
   }) => {
+    const threadCount = Math.max(1, Math.trunc(n_threads));
     const result = (
       await call(
         'kmeans',
-        { pixels, out_pixels, out_labels, width, height, num_colors, max_iter, color_space, n_threads },
+        { pixels, out_pixels, out_labels, width, height, num_colors, max_iter, color_space, n_threads: threadCount },
         ['pixels', 'out_pixels', 'out_labels']
       )
     ).output;

Comment on lines +166 to 208
ImageLib::Image<ImageLib::LABAPixel<float>> lab(pixels.getWidth(),
pixels.getHeight());
if (color_space == COLOR_SPACE_OPTION_CIELAB) {
std::cout << "KMeans using CIELAB" << std::endl;
for (int i{0}; i < pixels.getSize(); ++i) {
rgb_to_lab<float, float>(pixels[i], lab[i]);
}
}

std::vector<std::thread> threads;

int nthreads = std::clamp(static_cast<int>(n_threads), 1, k);

int pixels_per_thread{num_pixels / nthreads};
int centroids_per_thread{k / nthreads};

// Step 2: Initialize centroids randomly
srand(static_cast<uint32_t>(time(nullptr)));
// This does not give satisfactory initializations
/*srand(static_cast<uint32_t>(time(nullptr)));
for (int32_t i{0}; i < k; ++i) {
int32_t idx = rand() % num_pixels;
centroids[i] = pixels[idx];
switch (color_space) {
case COLOR_SPACE_OPTION_RGB : {
centroids[i] = pixels[idx];
break;
}
case COLOR_SPACE_OPTION_CIELAB : {
centroids_lab[i] = lab[idx];
break;
}
}
}*/

switch (color_space) {
case COLOR_SPACE_OPTION_RGB: {
kMeansPlusPlusInit<ImageLib::RGBAPixel<float>>(pixels, centroids, k);
break;
}
case COLOR_SPACE_OPTION_CIELAB: {
kMeansPlusPlusInit<ImageLib::LABAPixel<float>>(lab, centroids_lab, k);
break;
}
}

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

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the kmeans.cpp file
find . -name "kmeans.cpp" -type f

Repository: Ryan-Millard/Img2Num

Length of output: 103


🏁 Script executed:

# Read the kmeans.cpp file to see the full context
cat -n src/wasm/modules/image/src/kmeans.cpp | head -250

Repository: Ryan-Millard/Img2Num

Length of output: 10110


🏁 Script executed:

# Search for the dist variable and switch statements handling color_space
rg -n "dist" src/wasm/modules/image/src/kmeans.cpp

Repository: Ryan-Millard/Img2Num

Length of output: 2618


🏁 Script executed:

# Look for switch statements in the file
rg -n "switch" src/wasm/modules/image/src/kmeans.cpp -A 15

Repository: Ryan-Millard/Img2Num

Length of output: 4013


🏁 Script executed:

# Check if there's any validation of color_space at the function caller level
rg -n "kmeans\(" src/wasm/modules/image/src/ -A 3

Repository: Ryan-Millard/Img2Num

Length of output: 478


🏁 Script executed:

# Check the header file to see if there are any comments about valid values
cat src/wasm/modules/image/src/kmeans.h

Repository: Ryan-Millard/Img2Num

Length of output: 131


🏁 Script executed:

# Find the kmeans header file
find . -name "kmeans.h" -type f

Repository: Ryan-Millard/Img2Num

Length of output: 105


🏁 Script executed:

# Check where kmeans function is called from
rg -n "kmeans\(" --type cpp -B 2 -A 2 | head -100

Repository: Ryan-Millard/Img2Num

Length of output: 902


🏁 Script executed:

# Read the kmeans header file
cat src/wasm/modules/image/include/kmeans.h

Repository: Ryan-Millard/Img2Num

Length of output: 739


🏁 Script executed:

# Search for where kmeans is called in bindings or main entry points
rg -n "kmeans\(" src/ --type cpp -B 5 | grep -A 5 -B 5 "color_space"

Repository: Ryan-Millard/Img2Num

Length of output: 46


🏁 Script executed:

# Check if COLOR_SPACE constants are exported or used elsewhere
rg -n "COLOR_SPACE_OPTION" src/

Repository: Ryan-Millard/Img2Num

Length of output: 2661


Add validation for color_space parameter before processing.

Multiple switch statements (lines 199–208, 230–245, 264–279, 301–310, 335–348, 358–371) lack default cases. If an invalid color_space value is passed, the dist variable at line 298 remains uninitialized, causing undefined behavior when used at line 311. The lab image is also left uninitialized if the value doesn't match COLOR_SPACE_OPTION_CIELAB.

Suggested guard
   ImageLib::Image<ImageLib::LABAPixel<float>> lab(pixels.getWidth(),
                                                   pixels.getHeight());
+  if (color_space != COLOR_SPACE_OPTION_RGB &&
+      color_space != COLOR_SPACE_OPTION_CIELAB) {
+    return;
+  }
🤖 Prompt for AI Agents
In `@src/wasm/modules/image/src/kmeans.cpp` around lines 166 - 208, Validate the
color_space input early and add robust handling for invalid values: check the
color_space parameter (used in switches around COLOR_SPACE_OPTION_RGB and
COLOR_SPACE_OPTION_CIELAB) before any processing and return/throw an error (or
set a safe default) if it's not one of the expected enums; also add default
branches to every switch that currently lacks one (including the initialization
switch that calls kMeansPlusPlusInit, the distance computation switch that sets
dist, and any switches that touch lab or centroids_lab) to either initialize
variables to safe values or abort with a clear error so dist is never left
uninitialized and lab is not left unconstructed when COLOR_SPACE_OPTION_CIELAB
is required. Ensure any early return or exception prevents further use of
uninitialized variables (dist, lab) and keep behavior consistent across
functions that reference color_space.

@Ryan-Millard

Copy link
Copy Markdown
Owner

Hi, @Krasner. Please will you update the body of the PR to state which issues this fixes. That will allow the issues to be closed when this PR is merged.

For example, you would write:

Fixes: #234 
Fixes: #xyz

@Krasner

Krasner commented Jan 19, 2026

Copy link
Copy Markdown
Collaborator Author

updated

@Krasner

Krasner commented Jan 21, 2026

Copy link
Copy Markdown
Collaborator Author

I will return kmeansed back to findContours but I'm not 100% sure that that is better (or worse) than passing imgBilateralFiltered. We can revisit once the SVG contours are implemented

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/WasmImageProcessor.jsx (1)

16-16: CI blocker: blackThreshold is imported but never used.

The pipeline failure confirms ESLint fails because blackThreshold is destructured but the call is commented out on lines 88-94.

🐛 Proposed fix
-  const { bilateralFilter, blackThreshold, kmeans, findContours } = useWasmWorker();
+  const { bilateralFilter, kmeans, findContours } = useWasmWorker();
🤖 Fix all issues with AI agents
In `@src/utils/image-utils.js`:
- Line 2: Remove the unused named import "number" from 'prop-types' in the
import statement; locate the import line that reads import { number } from
'prop-types' in image-utils and delete the "number" specifier (or remove the
entire import if nothing else from 'prop-types' is used) so ESLint no longer
flags an unused import.
♻️ Duplicate comments (5)
src/components/WasmImageProcessor.jsx (1)

97-101: CI blocker: kmeansed is assigned but never used.

Static analysis confirms ESLint fails on line 97. Additionally, per reviewer discussion, using imgBilateralFiltered in findContours (line 104) instead of kmeansed may cause border mismatches since K-Means shifts coordinates during clustering.

If the quantized pixels are intentionally discarded, destructure only labels. Otherwise, pass kmeansed to findContours as originally intended.

🐛 Option A: Remove unused variable (if intentional)
-      const { pixels: kmeansed, labels } = await kmeans({
+      const { labels } = await kmeans({
🐛 Option B: Use kmeansed in findContours (preserves K-Means benefits)
       const contours = await findContours({
-        pixels: imgBilateralFiltered,
+        pixels: kmeansed,
         labels,
src/wasm/modules/image/src/kmeans.cpp (4)

54-58: Fix labels vector type mismatch (won’t compile).

_apply_labels expects std::vector<int>, but the caller passes std::vector<int32_t>. This is a hard compile error.

🐛 Proposed fix
-void _apply_labels(const ImageLib::Image<PixelT> &pixels,
-                   const std::vector<std::vector<float>> &distances,
-                   std::vector<int> &labels, int start_pixel, int end_pixel,
-                   int k, std::atomic<bool> &changed) {
+void _apply_labels(const ImageLib::Image<PixelT> &pixels,
+                   const std::vector<std::vector<float>> &distances,
+                   std::vector<int32_t> &labels, int start_pixel, int end_pixel,
+                   int k, std::atomic<bool> &changed) {

84-91: Guard against empty input in KMeans++ init.

num_pixels == 0 or k <= 0 makes the uniform distribution invalid.

🐛 Proposed fix
-  int num_pixels = pixels.getSize();
+  const int num_pixels{pixels.getSize()};
+  if (k <= 0 || num_pixels <= 0) {
+    return;
+  }

163-170: Validate color_space early and remove unguarded logging.

Invalid color_space can leave LAB buffers and distances undefined; also avoid std::cout in production.

🐛 Proposed fix
-  ImageLib::Image<ImageLib::LABAPixel<float>> lab(pixels.getWidth(),
-                                                  pixels.getHeight());
-  if (color_space == COLOR_SPACE_OPTION_CIELAB) {
-    std::cout << "KMeans using CIELAB" << std::endl;
+  if (color_space != COLOR_SPACE_OPTION_RGB &&
+      color_space != COLOR_SPACE_OPTION_CIELAB) {
+    return;
+  }
+  ImageLib::Image<ImageLib::LABAPixel<float>> lab(pixels.getWidth(),
+                                                  pixels.getHeight());
+  if (color_space == COLOR_SPACE_OPTION_CIELAB) {
     for (int i{0}; i < pixels.getSize(); ++i) {
       rgb_to_lab<float, float>(pixels[i], lab[i]);
     }
   }

174-178: Validate k before std::clamp and division.

std::clamp requires low <= high; k <= 0 is UB and can lead to divide-by-zero.

🐛 Proposed fix
-  std::vector<std::thread> threads;
-
-  int nthreads = std::clamp(static_cast<int>(n_threads), 1, k);
+  std::vector<std::thread> threads;
+  if (k <= 0 || num_pixels <= 0) {
+    return;
+  }
+  const int nthreads{std::clamp<int>(static_cast<int>(n_threads), 1, k)};
🧹 Nitpick comments (3)
src/wasm/modules/image/CMakeLists.txt (1)

50-51: Consider consolidating -pthread and -msimd128 into COMMON_FLAGS.

Per the prior review suggestion, adding these flags to COMMON_FLAGS would reduce duplication and simplify maintenance.

♻️ Proposed consolidation
 set(COMMON_FLAGS
     "SHELL:-s MODULARIZE=1"
     "SHELL:-s EXPORT_ES6=1"
     "SHELL:-s EXIT_RUNTIME=1"
     "SHELL:-s ENVIRONMENT=web,worker"
     "SHELL:-s EXPORTED_FUNCTIONS=['_malloc','_free']"
     "SHELL:-s EXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8','HEAP32']"
     "SHELL:-s INITIAL_MEMORY=1024MB"
     "SHELL:-s MAXIMUM_MEMORY=2048MB"
     "SHELL:-s ALLOW_MEMORY_GROWTH=1"
     "SHELL:-s EXPORT_NAME=create${CAP_MODULE_NAME}Module"
     "SHELL:-s USE_PTHREADS=1"
     "SHELL:-s PTHREAD_POOL_SIZE=8"
+    -pthread
+    -msimd128
 )

 # Apply common flags
-target_compile_options(${MODULE_NAME}_wasm PRIVATE -pthread -msimd128)
-target_link_options(${MODULE_NAME}_wasm PRIVATE ${COMMON_FLAGS} -pthread -msimd128)
+target_compile_options(${MODULE_NAME}_wasm PRIVATE ${COMMON_FLAGS})
+target_link_options(${MODULE_NAME}_wasm PRIVATE ${COMMON_FLAGS})
src/wasm/modules/image/include/LABPixel.h (1)

20-22: Consider epsilon-based comparison for floating-point types.

Direct equality comparison (==) on floating-point types (float/double, as noted in the comment on line 8) can fail due to precision errors. For pixel equality checks in color processing, this may cause unexpected mismatches.

♻️ Optional: Add tolerance-based comparison for floating-point
+#include <cmath>
+#include <type_traits>
+
+// ...
+
   [[nodiscard]] inline bool operator==(const LABPixel &other) const {
-    return l == other.l && a == other.a && b == other.b;
+    if constexpr (std::is_floating_point_v<NumberT>) {
+      constexpr NumberT eps = static_cast<NumberT>(1e-6);
+      return std::abs(l - other.l) < eps &&
+             std::abs(a - other.a) < eps &&
+             std::abs(b - other.b) < eps;
+    } else {
+      return l == other.l && a == other.a && b == other.b;
+    }
   }
src/wasm/modules/image/include/cielab_impl.h (1)

134-139: Minor: Clamp before assignment for clarity.

out_l is assigned on line 134, then clamped on line 138. For clarity and to avoid any intermediate out-of-range state, consider clamping in-place during assignment.

♻️ Proposed simplification
   // 4. Output values
-  out_l = static_cast<Tout>(LAB_L_FACTOR * fy - LAB_L_OFFSET);
+  out_l = static_cast<Tout>(
+      std::clamp(LAB_L_FACTOR * fy - LAB_L_OFFSET, 0.0, 100.0));
   out_a = static_cast<Tout>(LAB_A_FACTOR * (fx - fy));
   out_b = static_cast<Tout>(LAB_B_FACTOR * (fy - fz));
-
-  out_l = std::clamp(out_l, static_cast<Tout>(0.0), static_cast<Tout>(100.0));
 }

Comment thread src/utils/image-utils.js Outdated
@Krasner

Krasner commented Jan 21, 2026

Copy link
Copy Markdown
Collaborator Author

@Ryan-Millard do you think this is ready to merge. I can address the Pixel data structure issue in the next PR.

@Ryan-Millard

Copy link
Copy Markdown
Owner

@Ryan-Millard do you think this is ready to merge. I can address the Pixel data structure issue in the next PR.

Yes. It is. Please just fix the linting errors and remove these:

[19:31] ~/projects/Img2Num/src/wasm/modules/image $ rg "cout" include/ src/
src/kmeans.cpp
166:    std::cout << "KMeans using CIELAB" << std::endl;
251:      // std::cout << "Computed distances" << std::endl;
[19:32] ~/projects/Img2Num/src/wasm/modules/image $ rg "iostream" include/ src/
include/image_utils.h
6:#include <iostream>

src/kmeans.cpp
13:#include <iostream>

src/kmeans_graph.cpp
16:#include <iostream>
[19:32] ~/projects/Img2Num/src/wasm/modules/image $

I'll make an issue now to make a debug suite that evaporates in production.

@Krasner

Krasner commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator Author

done

@Ryan-Millard

Copy link
Copy Markdown
Owner

@Ryan-Millard do you think this is ready to merge. I can address the Pixel data structure issue in the next PR.

Yes. It is. Please just fix the linting errors and remove these:

[19:31] ~/projects/Img2Num/src/wasm/modules/image $ rg "cout" include/ src/
src/kmeans.cpp
166:    std::cout << "KMeans using CIELAB" << std::endl;
251:      // std::cout << "Computed distances" << std::endl;
[19:32] ~/projects/Img2Num/src/wasm/modules/image $ rg "iostream" include/ src/
include/image_utils.h
6:#include <iostream>

src/kmeans.cpp
13:#include <iostream>

src/kmeans_graph.cpp
16:#include <iostream>
[19:32] ~/projects/Img2Num/src/wasm/modules/image $

I'll make an issue now to make a debug suite that evaporates in production.

The issue is #240.

@Ryan-Millard

Copy link
Copy Markdown
Owner

@Krasner, please will you write some documentation in the docs/ folder to explain the code you wrote. Some of it is difficult to understand and documentation would help me understand your thought processes.

For example, I don't understand why you used cielab.h and cielab_impl.h (instead of the previous cielab.cpp) with a circular include and no include guards in cielab_impl.h as well as templated parameters instead of explicit types.

Documentation would solve that problem for me as well as for future contributors. If you need help with understanding Docusaurus, just let me know and I'll find out what I can do to help. This guide from our documentation explains the basics of writing documentation with Docusuarus.

@Krasner, do you need help with understanding how to use Docusaurus? We don't need much - just API documentation, an explanation of the implementation and a few short extras.

@Krasner

Krasner commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator Author

@Ryan-Millard I can update the existing cielab documentation to match the new templated implementation.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@docs/docs/reference/wasm/modules/image/cielab/api.md`:
- Around line 121-128: The parameter type annotations in the table are reversed:
update the entries so inputs L, A, B use the input type (const Tin) and the
outputs r_u8, g_u8, b_u8 use the output reference type (Tout&); specifically
change `L`, `A`, `B` from `Tout` to `const Tin` and change `r_u8`, `g_u8`,
`b_u8` from `Tin&` to `Tout&`, keeping the same ranges and descriptions to match
the function signature for L/A/B and r_u8/g_u8/b_u8.
🧹 Nitpick comments (1)
docs/docs/reference/wasm/modules/image/cielab/api.md (1)

29-37: Consider clarifying parameter naming for templated functions.

The parameter names use a _u8 suffix (e.g., r_u8, g_u8, b_u8) which implies uint8_t types, but the functions are templated to accept arbitrary input/output types. While this may be legacy naming preserved for API compatibility, it could confuse developers about the acceptable types.

Consider either:

  1. Adding a note explaining that _u8 is a legacy naming convention and the parameters accept any type compatible with Tin/Tout
  2. Updating parameter names to be more generic (e.g., r, g, b or r_in, g_in, b_in)

This would align better with the templated nature emphasized at lines 14-18 and the note at line 20 suggesting float or double for CIELAB.

Also applies to: 54-61, 96-104, 121-128

Comment thread docs/docs/reference/wasm/modules/image/cielab/api.md Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/docs/reference/wasm/modules/image/cielab/api.md (1)

1-251: Fix Prettier formatting to unblock CI.

CI reports a Prettier failure for this file; please run the formatter and update the doc accordingly.

@Krasner

Krasner commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator Author

@Ryan-Millard ready to merge

@Ryan-Millard Ryan-Millard left a comment

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.

Thank you very much, @Krasner. You always have such wonderful PRs!

@Ryan-Millard
Ryan-Millard merged commit 5c2c93c into Ryan-Millard:main Jan 22, 2026
3 checks passed
@Ryan-Millard

Copy link
Copy Markdown
Owner

This broke the production code last because GitHub Pages is a terrible hosting service.😢

I need to migrate elsewhere soon. Unfortunately, I'll only have time on Monday or Tuesday to do a migration. The problem is that we can't set our own headers, which causes the below:

wasmWorker-9o6JCJ9n.js:1 Uncaught (in promise) DataCloneError: Failed to execute 'postMessage' on 'Worker': SharedArrayBuffer transfer requires self.crossOriginIsolated.
    at wasmWorker-9o6JCJ9n.js:1:6126
    at new Promise (<anonymous>)
    at loadWasmModuleToWorker (wasmWorker-9o6JCJ9n.js:1:5380)
    at Array.map (<anonymous>)
    at Object.loadWasmModuleToAllWorkers (wasmWorker-9o6JCJ9n.js:1:6270)
    at wasmWorker-9o6JCJ9n.js:1:4936
    at We (wasmWorker-9o6JCJ9n.js:1:3849)
    at fr (wasmWorker-9o6JCJ9n.js:1:2093)
    at G (wasmWorker-9o6JCJ9n.js:1:13217)
    at _e (wasmWorker-9o6JCJ9n.js:1:13448)
crossOriginIsolated

false
crossOriginIsolated=true
true
crossOriginIsolated

false

It is related to threads.

@Krasner

Krasner commented Jan 23, 2026

Copy link
Copy Markdown
Collaborator Author

Oh that sucks! I think cloudflare workers might be a good hosting option but I dont't have experience with that.
For now you can disable those headers and set n_threads = 1 for bilateralFilter and kmeans...

@Ryan-Millard

Copy link
Copy Markdown
Owner

Oh that sucks! I think cloudflare workers might be a good hosting option but I dont't have experience with that. For now you can disable those headers and set n_threads = 1 for bilateralFilter and kmeans...

I'm busy looking into the solution. I don't really want to disable the threads, but I think I may need to do it since this project has such a complex build process and most free hosts out there don't support these types of fancy builds.

The biggest issue with threads is that we will need to inform users that they need specific headers if they use the library in JS.

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.

KMeans in CIELAB color space Multithreading

2 participants