Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/WasmImageProcessor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ const WasmImageProcessor = () => {

step(20);
// NOTE: Gaussian blur destroys the sharp outlines first, preventing the Bilateral filter from detecting and preserving them
console.time("bilateralFilter")
const imgBilateralFiltered = await bilateralFilter({
pixels: fileData.pixels,
width,
height,
});
console.timeEnd("bilateralFilter")

step(45);
const thresholded = await blackThreshold({
Expand All @@ -93,11 +95,13 @@ const WasmImageProcessor = () => {
});

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

const contours = await findContours({
pixels: kmeansed,
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useWasmWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ export function useWasmWorker() {
sigma_spatial = 3.0,
sigma_range = 50.0,
color_space = 0,
n_threads = 8,
}) => {
return (
await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space }, ['pixels'])
await call('bilateral_filter', { pixels, width, height, sigma_spatial, sigma_range, color_space, n_threads }, ['pixels'])
).output.pixels;
};
const blackThreshold = async ({ pixels, width, height, num_colors }) => {
Expand All @@ -58,9 +59,10 @@ export function useWasmWorker() {
out_labels = new Int32Array(width * height),
num_colors,
max_iter = 100,
n_threads = 8,
}) => {
const result = (
await call('kmeans', { pixels, out_pixels, out_labels, width, height, num_colors, max_iter }, [
await call('kmeans', { pixels, out_pixels, out_labels, width, height, num_colors, max_iter, n_threads }, [
'pixels',
'out_pixels',
'out_labels',
Expand All @@ -71,7 +73,7 @@ export function useWasmWorker() {
labels: result.out_labels,
};
};
const findContours = async ({ pixels, labels, width, height, min_area = 100, draw_contour_borders = false }) => {
const findContours = async ({ pixels, labels, width, height, min_area = 10, draw_contour_borders = false }) => {
return (
await call('kmeans_clustering_graph', { pixels, labels, width, height, min_area, draw_contour_borders }, [
'pixels',
Expand Down
6 changes: 5 additions & 1 deletion src/wasm/modules/image/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ set(COMMON_FLAGS
"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 PROXY_TO_PTHREAD"
)

# Apply common flags
target_link_options(${MODULE_NAME}_wasm PRIVATE ${COMMON_FLAGS})
target_compile_options(${MODULE_NAME}_wasm PRIVATE -pthread)
target_link_options(${MODULE_NAME}_wasm PRIVATE ${COMMON_FLAGS} -pthread)

# Build-type specific flags
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
Expand Down
2 changes: 1 addition & 1 deletion src/wasm/modules/image/include/bilateral_filter.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace bilateral {
// (radiometric decay)
void bilateral_filter(uint8_t *image, size_t width, size_t height,
double sigma_spatial, double sigma_range,
uint8_t color_space);
uint8_t color_space, int n_threads=8);
Comment on lines 18 to +20

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:

#!/bin/bash
# Search for n_threads validation in the codebase
rg -n -A5 'n_threads' --type=cpp

Repository: Ryan-Millard/Img2Num

Length of output: 4313


Add validation for n_threads parameter to prevent undefined behavior.

The function does not validate n_threads, which allows negative values that cause issues: division by zero at line 191 (rows_per_thread = height / n_threads), and undefined behavior in the unsigned loop comparison at line 248. Clamp n_threads to a valid range (e.g., [1, std::thread::hardware_concurrency()]) in the validation block at the start of the function.

🤖 Prompt for AI Agents
In `@src/wasm/modules/image/include/bilateral_filter.h` around lines 18 - 20, In
bilateral_filter, add validation to clamp the n_threads parameter into a safe
range to avoid division by zero and unsigned loop issues: compute a max_threads
= std::thread::hardware_concurrency() (fallback to 1 if it returns 0), then set
n_threads = std::max(1, std::min(n_threads, static_cast<int>(max_threads))); use
this validated n_threads when computing rows_per_thread and any loop bounds
(ensure casts to signed/size_t are deliberate), and replace any unsigned loop
comparisons that rely on possibly negative n_threads with iterations based on
the validated positive n_threads to prevent undefined behavior.


} // namespace bilateral

Expand Down
2 changes: 1 addition & 1 deletion src/wasm/modules/image/include/kmeans.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

EXPORTED void kmeans(const uint8_t *data, uint8_t *out_data, int *out_labels,
const int width, const int height, const int k,
const int max_iter);
const int max_iter, const int n_threads=8);

EXPORTED void kmeans_clustering_spatial(uint8_t *data, int width, int height,
int k, int max_iter,
Expand Down
236 changes: 147 additions & 89 deletions src/wasm/modules/image/src/bilateral_filter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
#include <cmath>
#include <cstring>
#include <vector>
#include <thread>
#include <mutex>
#include <functional>

namespace bilateral {

Expand All @@ -23,6 +26,8 @@ inline double gaussian(double x, double sigma) {
return std::exp(-(x * x) / (2.0 * sigma * sigma));
}

std::mutex write_mutex;

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

Global mutex is unnecessary and introduces severe performance bottleneck.

The write_mutex is used to protect writes to result, but each thread processes non-overlapping row ranges (start_row to end_row). Since threads never write to the same indices, no synchronization is needed. The current implementation serializes all pixel writes across threads, effectively negating all parallelism benefits.

🐛 Remove unnecessary mutex

Remove the global mutex declaration:

-std::mutex write_mutex;

And remove the lock/unlock in _process (lines 145-173):

-      // writing - must grab mutex
-      std::unique_lock<std::mutex> lock(write_mutex);
-      
       switch (color_space) {
         // ... (keep the switch body unchanged)
       }
-      // done writing
-      lock.unlock();
🤖 Prompt for AI Agents
In `@src/wasm/modules/image/src/bilateral_filter.cpp` at line 29, The global
std::mutex write_mutex and any locking in _process should be removed because
threads write disjoint ranges (use start_row/end_row) into the result buffer;
locate the write_mutex declaration and delete it, then remove the
lock_guard/lock/unlock usage inside the _process function (and any includes only
used for the mutex) so that each worker thread writes directly to result for its
assigned rows without synchronization.


/*
The Bilateral Filter applies a composite weight based on both spatial distance
and radiometric difference (intensity) to return an image that is smoothed while
Expand All @@ -40,74 +45,27 @@ decay)
├── 0: CIELAB
└── 1: RGB
*/
void bilateral_filter(uint8_t *image, size_t width, size_t height,
double sigma_spatial, double sigma_range,
uint8_t color_space) {
// bad data -> return
if (sigma_spatial <= 0.0 || sigma_range <= 0.0 || width <= 0 || height <= 0)
return;
if (color_space != COLOR_SPACE_OPTION_CIELAB &&
color_space != COLOR_SPACE_OPTION_RGB)
return;

const int raw_radius{
static_cast<int>(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial))};
const int radius{std::min(raw_radius, MAX_KERNEL_RADIUS)};
const int kernel_diameter{2 * radius + 1};

std::vector<uint8_t> result(width * height * 4);

std::vector<double> spatial_weights(kernel_diameter * kernel_diameter);

// Precompute Spatial Weights (Gaussian Kernel)
for (int ky{-radius}; ky <= radius; ++ky) {
for (int kx{-radius}; kx <= radius; ++kx) {
const double dist{static_cast<double>(std::sqrt(kx * kx + ky * ky))};
spatial_weights[(ky + radius) * kernel_diameter + (kx + radius)] =
gaussian(dist, sigma_spatial);
}
}

// ========= RGB-only section start =========
// Precompute Range Weights
std::vector<double> range_lut;
if (color_space == COLOR_SPACE_OPTION_RGB) {
range_lut.resize(MAX_RGB_DIST_SQ + 1);
for (int i{0}; i <= MAX_RGB_DIST_SQ; ++i) {
range_lut[i] = gaussian(static_cast<double>(std::sqrt(i)), sigma_range);
}
}
// ========= RGB-only section end =========

// ========= CIELAB section start =========
// Compute full image RGB - CIELAB conversion
std::vector<double> cie_image;
if (color_space == COLOR_SPACE_OPTION_CIELAB) {
cie_image.resize(width * height * 4);

for (int y{0}; y < height; y++) {
for (int x{0}; x < width; x++) {
int center_idx{(y * static_cast<int>(width) + x) * 4};
uint8_t r0{image[center_idx]};
uint8_t g0{image[center_idx + 1]};
uint8_t b0{image[center_idx + 2]};
uint8_t a0{image[center_idx + 3]};
double L0, A0, B0;
rgb_to_lab(r0, g0, b0, L0, A0, B0);

cie_image[center_idx] = L0;
cie_image[center_idx + 1] = A0;
cie_image[center_idx + 2] = B0;
cie_image[center_idx + 3] =
0.0; // unused but keep for indexing purposes
}
}
}
// ========= CIELAB section end =========

void _process(
const uint8_t* image,
const std::vector<double>& cie_image,
std::vector<uint8_t>& result,
const std::vector<double>& spatial_weights,
const std::vector<double>& range_lut,
int radius,
double sigma_range,
int start_row,
int end_row,
size_t height,
size_t width,
uint8_t color_space,
int n_threads
) {
int h{static_cast<int>(height)};
int w{static_cast<int>(width)};
for (int y{0}; y < h; ++y) {
const int kernel_diameter{2 * radius + 1};

for (int y{start_row}; y < end_row; ++y) {
for (int x{0}; x < w; ++x) {
size_t center_idx{(y * width + x) * 4};

Expand Down Expand Up @@ -184,33 +142,133 @@ void bilateral_filter(uint8_t *image, size_t width, size_t height,
weight_acc += w_space * w_range;
}
}

// 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));
result[center_idx + 1] = static_cast<uint8_t>(
std::clamp(weight_acc_channel_1 / weight_acc, 0.0, 255.0));
result[center_idx + 2] = static_cast<uint8_t>(
std::clamp(weight_acc_channel_2 / weight_acc, 0.0, 255.0));
result[center_idx + 3] = a0;
break;
}
case COLOR_SPACE_OPTION_CIELAB: {
double L{weight_acc_channel_0 / weight_acc};
double A{weight_acc_channel_1 / weight_acc};
double B{weight_acc_channel_2 / 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;
break;
case COLOR_SPACE_OPTION_RGB: {
result[center_idx] = static_cast<uint8_t>(
std::clamp(weight_acc_channel_0 / weight_acc, 0.0, 255.0));
result[center_idx + 1] = static_cast<uint8_t>(
std::clamp(weight_acc_channel_1 / weight_acc, 0.0, 255.0));
result[center_idx + 2] = static_cast<uint8_t>(
std::clamp(weight_acc_channel_2 / weight_acc, 0.0, 255.0));
result[center_idx + 3] = a0;
break;
}
case COLOR_SPACE_OPTION_CIELAB: {
double L{weight_acc_channel_0 / weight_acc};
double A{weight_acc_channel_1 / weight_acc};
double B{weight_acc_channel_2 / 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;
break;
}
}
// done writing
lock.unlock();

}
}
}

void bilateral_filter(uint8_t *image, size_t width, size_t height,
double sigma_spatial, double sigma_range,
uint8_t color_space, int n_threads) {
// bad data -> return
if (sigma_spatial <= 0.0 || sigma_range <= 0.0 || width <= 0 || height <= 0)
return;
if (color_space != COLOR_SPACE_OPTION_CIELAB &&
color_space != COLOR_SPACE_OPTION_RGB)
return;

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

int rows_per_thread = static_cast<int>(height) / n_threads;

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

Potential division by zero if n_threads exceeds image height.

If n_threads > height, rows_per_thread becomes 0, leading to incorrect row distribution. Consider clamping n_threads to at most height.

🐛 Proposed fix
+  n_threads = std::min(n_threads, static_cast<int>(height));
+  if (n_threads < 1) n_threads = 1;
+
   int rows_per_thread = static_cast<int>(height) / n_threads;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
int rows_per_thread = static_cast<int>(height) / n_threads;
n_threads = std::min(n_threads, static_cast<int>(height));
if (n_threads < 1) n_threads = 1;
int rows_per_thread = static_cast<int>(height) / n_threads;
🤖 Prompt for AI Agents
In `@src/wasm/modules/image/src/bilateral_filter.cpp` at line 191, The current
calculation int rows_per_thread = static_cast<int>(height) / n_threads can yield
zero when n_threads > height; clamp the thread count first (e.g., used_threads =
std::min(static_cast<int>(height), n_threads)) and then compute rows_per_thread
= static_cast<int>(height) / used_threads so each thread gets at least one row;
update all uses of n_threads in the scheduling logic (e.g., where
rows_per_thread and loop bounds are used) to use used_threads to avoid
division-by-zero and incorrect distribution.


const int raw_radius{
static_cast<int>(std::ceil(SIGMA_RADIUS_FACTOR * sigma_spatial))};
const int radius{std::min(raw_radius, MAX_KERNEL_RADIUS)};
const int kernel_diameter{2 * radius + 1};

std::vector<uint8_t> result(width * height * 4);

std::vector<double> spatial_weights(kernel_diameter * kernel_diameter);

// Precompute Spatial Weights (Gaussian Kernel)
for (int ky{-radius}; ky <= radius; ++ky) {
for (int kx{-radius}; kx <= radius; ++kx) {
const double dist{static_cast<double>(std::sqrt(kx * kx + ky * ky))};
spatial_weights[(ky + radius) * kernel_diameter + (kx + radius)] =
gaussian(dist, sigma_spatial);
}
}

// ========= RGB-only section start =========
// Precompute Range Weights
std::vector<double> range_lut;
if (color_space == COLOR_SPACE_OPTION_RGB) {
range_lut.resize(MAX_RGB_DIST_SQ + 1);
for (int i{0}; i <= MAX_RGB_DIST_SQ; ++i) {
range_lut[i] = gaussian(static_cast<double>(std::sqrt(i)), sigma_range);
}
}
// ========= RGB-only section end =========

// ========= CIELAB section start =========
// Compute full image RGB - CIELAB conversion
std::vector<double> cie_image;
if (color_space == COLOR_SPACE_OPTION_CIELAB) {
cie_image.resize(width * height * 4);

for (int y{0}; y < height; y++) {
for (int x{0}; x < width; x++) {
int center_idx{(y * static_cast<int>(width) + x) * 4};
uint8_t r0{image[center_idx]};
uint8_t g0{image[center_idx + 1]};
uint8_t b0{image[center_idx + 2]};
uint8_t a0{image[center_idx + 3]};
double L0, A0, B0;
rgb_to_lab(r0, g0, b0, L0, A0, B0);

cie_image[center_idx] = L0;
cie_image[center_idx + 1] = A0;
cie_image[center_idx + 2] = B0;
cie_image[center_idx + 3] =
0.0; // unused but keep for indexing purposes
}
}
}
// ========= CIELAB section end =========
if (n_threads > 1){
for (unsigned int i = 0; i < n_threads; ++i) {
int start_row = i * rows_per_thread;
int end_row = (i == n_threads - 1) ? height : (i + 1) * rows_per_thread;
// Launch a thread and add to vector
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);
}

// wait for threads to finish
for (auto& thread : threads) {
thread.join();
}
}
else {
_process(image, cie_image, result, spatial_weights, range_lut, radius, sigma_range, 0, static_cast<int>(height), height, width, color_space, n_threads);
}

std::memcpy(image, result.data(), result.size());
}
Expand All @@ -220,7 +278,7 @@ void bilateral_filter(uint8_t *image, size_t width, size_t height,
// Global wrapper for WASM export
EXPORTED void bilateral_filter(uint8_t *image, size_t width, size_t height,
double sigma_spatial, double sigma_range,
uint8_t color_space) {
uint8_t color_space, int n_threads) {
bilateral::bilateral_filter(image, width, height, sigma_spatial, sigma_range,
color_space);
color_space, n_threads);
}
Loading
Loading