Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e25087a
refactor(kmeans, RGBPixel): general cleanup for clarity
Ryan-Millard Jan 3, 2026
272e02d
Merge branch 'main' of https://github.com/Ryan-Millard/Img2Num into f…
Ryan-Millard Jan 8, 2026
c0c50a3
Merge branch 'main' of https://github.com/Ryan-Millard/Img2Num into f…
Ryan-Millard Jan 9, 2026
aa5d17f
feat(anti-contours): this labels every non-countour pixel - nice :'(
Ryan-Millard Jan 10, 2026
8f8094b
fix(find_contours): better labelling and border-following - very slow :(
Ryan-Millard Jan 10, 2026
ab83ef5
feat(contour visualization): add diff color borders and simple viewin…
Ryan-Millard Jan 10, 2026
b81809a
fix(contour tracing): never skip a contour pixel
Ryan-Millard Jan 10, 2026
98cf144
KMeans + graph based merging
Krasner Jan 12, 2026
a46c8e8
bug fixes
Krasner Jan 12, 2026
51e3b4a
Move Graph and Node to seperate .h and .cpp files
Krasner Jan 12, 2026
1a19dc1
suppress debug prints
Krasner Jan 12, 2026
315507d
Use each Node as binary image to compute contours from, then recombin…
Krasner Jan 13, 2026
96a457f
fix small edge bug
Krasner Jan 13, 2026
44dfaa9
address some PR review
Krasner Jan 14, 2026
00aaf90
Move bounding box and binary image creation to methods for Node
Krasner Jan 14, 2026
3c22c71
remove(find_contours files): old buggy contour tracing
Ryan-Millard Jan 14, 2026
fda539c
refactor(kmeans_clustering): new API style to preserve original data …
Ryan-Millard Jan 14, 2026
f1507ff
fix(kmeans out params): properly handle WASM boundary for int32
Ryan-Millard Jan 14, 2026
ade79f4
refactor(kmeans_graph): use updated kmeans function instead of kmeans…
Ryan-Millard Jan 14, 2026
ee5a61c
docs(JS-WASM interop): document entire process from function declarat…
Ryan-Millard Jan 14, 2026
bb81a67
Merge branch 'main' into feat/contour-tracing
Ryan-Millard Jan 15, 2026
6e5c3b9
chore(mergeSmallRegionsInPlace): remove legacy function - handled by …
Ryan-Millard Jan 15, 2026
fccd968
fix(node, graph): enforce const-correctness, use explicit types, fix …
Ryan-Millard Jan 15, 2026
f15868d
refactor(image): switch RGBXY to use RGBPixel for color + XY for posi…
Ryan-Millard Jan 15, 2026
0aecc33
refactor(kmeans): clean up dead code
Ryan-Millard Jan 15, 2026
f3f923b
chore(cleanup kmeans_graph, kmeans): remove debug prints & move heade…
Ryan-Millard Jan 17, 2026
5c1903a
refactor(kmeans_graph): split into visualize contours function & opti…
Ryan-Millard Jan 17, 2026
a2aca6f
fix(contours.cpp): Multiplication result converted to larger type
Ryan-Millard Jan 17, 2026
7466a46
fix(kmeans_graph.cpp): Multiplication result converted to larger type
Ryan-Millard Jan 17, 2026
d1be25a
style: lint & format code
Ryan-Millard Jan 17, 2026
3a006b7
fix(ipynb format error): remove ipynb file
Ryan-Millard Jan 17, 2026
84093ea
fix(node.cpp): Multiplication result converted to larger type
Ryan-Millard Jan 17, 2026
3e0a586
fix(worker): prevent prototype pollution from dynamic buffer keys
Ryan-Millard Jan 17, 2026
02dd5a1
docs(wasmWorker): update to match code changes
Ryan-Millard Jan 18, 2026
1701afa
fix(wasm,image)!: correct int sizes, prevent UB, and harden graph/kme…
Ryan-Millard Jan 18, 2026
fea31a2
Merge branch 'main' into feat/contour-tracing
Ryan-Millard Jan 18, 2026
9a7623f
Merge branch 'feat/contour-tracing' of https://github.com/Ryan-Millar…
Ryan-Millard Jan 18, 2026
ade7590
style: update to match repository reqs
Ryan-Millard Jan 18, 2026
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
849 changes: 849 additions & 0 deletions python/kmeans_contour.ipynb

Large diffs are not rendered by default.

220 changes: 153 additions & 67 deletions src/components/WasmImageProcessor.jsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,49 @@
import { useEffect, useState, useId, useRef, useCallback, useMemo } from 'react';
import { Upload } from 'lucide-react';
import { loadImageToUint8Array, uint8ClampedArrayToSVG } from '@utils/image-utils';
import { loadImageToUint8Array } from '@utils/image-utils';
import { useWasmWorker } from '@hooks/useWasmWorker';
import GlassCard from '@components/GlassCard';
import styles from './WasmImageProcessor.module.css';
import { useNavigate } from 'react-router-dom';
import LoadingHedgehog from '@components/LoadingHedgehog';
import Tooltip from '@components/Tooltip';
import styles from './WasmImageProcessor.module.css';

const WasmImageProcessor = () => {
const navigate = useNavigate();
const inputId = useId();
const inputRef = useRef(null);

const { bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace } = useWasmWorker();
const contourCanvasRef = useRef(null);
const mergedCanvasRef = useRef(null);

const {
bilateralFilter,
blackThreshold,
kmeans,
kmeans2,
mergeSmallRegionsInPlace,
findContours,
} = useWasmWorker();

const [originalSrc, setOriginalSrc] = useState(null);
const [fileData, setFileData] = useState(null);
const [mergedData, setMergedData] = useState(null);
const [contourData, setContourData] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);

/* Cleanup object URLs on unmount or src change */
/* Cleanup object URLs */
useEffect(() => {
return () => {
if (originalSrc) URL.revokeObjectURL(originalSrc);
};
}, [originalSrc]);

/* Stable loader for images */
/* Load image */
const loadOriginal = useCallback(async (file) => {
if (!file) return;

const url = URL.createObjectURL(file);
setOriginalSrc(url);
setMergedData(null);
setContourData(null);

const { pixels, width, height } = await loadImageToUint8Array(file);
setFileData({ pixels, width, height });
Expand All @@ -48,7 +59,6 @@
}
}
};

document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [loadOriginal]);
Expand All @@ -62,23 +72,23 @@
[loadOriginal]
);

const handleSelect = useCallback((e) => loadOriginal(e.target.files[0]), [loadOriginal]);
const handleSelect = useCallback(
(e) => loadOriginal(e.target.files[0]),
[loadOriginal]
);

/* Hashed steps to keep pipeline aligned */
const step = useCallback((p) => setProgress(p), []);

/* Main pipeline */
const processImage = useCallback(async () => {
if (!fileData) return;

setIsProcessing(true);
step(5);

try {
const { width, height } = fileData;

step(20);
// NOTE: Gaussian blur destroys the sharp outlines first, preventing the Bilateral filter from detecting and preserving them
const imgBilateralFiltered = await bilateralFilter({
pixels: fileData.pixels,
width,
Expand All @@ -93,43 +103,62 @@
});

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

const twoPercentOrOne = (dimension) =>
Math.ceil(Math.max(dimension * 0.0005, 1));

// Get 2% of the input dimension (width / height), but default to 1 pixel
const twoPercentOrOne = (dimension) => Math.ceil(Math.max(dimension * 0.02, 1));
const minWidth = twoPercentOrOne(width);

Check failure on line 115 in src/components/WasmImageProcessor.jsx

View workflow job for this annotation

GitHub Actions / Lint Code

'minWidth' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
const minHeight = twoPercentOrOne(height);

Check failure on line 116 in src/components/WasmImageProcessor.jsx

View workflow job for this annotation

GitHub Actions / Lint Code

'minHeight' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u

const area = width * height;
// Prevents minArea from being too small
const minimumAllowedMinArea = area > 100_000_000 ? 25 : area > 10_000_000 ? 20 : area > 1_000_000 ? 15 : 10;
const minArea = Math.ceil(Math.max(area / 10_000, minimumAllowedMinArea));

const merged = await mergeSmallRegionsInPlace({
pixels: kmeansed,
width,
height,
minArea,
minWidth,
minHeight,
const minimumAllowedMinArea =
area > 100_000_000
? 25
: area > 10_000_000
? 20
: area > 1_000_000
? 15
: 10;

const minArea = Math.ceil(
Math.max(area / 10_000, minimumAllowedMinArea)
);

// step(75);
// const merged = await mergeSmallRegionsInPlace({
// pixels: kmeansed,
// width,
// height,
// minArea,
// minWidth,
// minHeight,
// });

const kmeansed = await kmeans2({
...fileData,
pixels: thresholded,
num_colors: 16,
min_area: minArea,
max_iter: 100,
});

step(95);
const svg = await uint8ClampedArrayToSVG({
pixels: merged,
// Save merged image for display
setMergedData({ pixels: kmeansed, width, height });

step(90);
const contours = await findContours({
pixels: kmeansed,
width,
height,
});

setContourData({ pixels: contours, width, height });
step(100);

navigate('/editor', {
state: { svg },
});
} catch (err) {
console.error(err);
} finally {
Expand All @@ -138,66 +167,123 @@
step(0);
}, 800);
}
}, [fileData, bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace, navigate, step]);

/* Memo'd UI fragments */
const EmptyState = useMemo(
() => (
<>
<Tooltip content="Upload an image from your device">
<Upload className={`anchor-style ${styles.uploadIcon}`} />
}, [

Check warning on line 170 in src/components/WasmImageProcessor.jsx

View workflow job for this annotation

GitHub Actions / Lint Code

React Hook useCallback has a missing dependency: 'kmeans2'. Either include it or remove the dependency array
fileData,
bilateralFilter,
blackThreshold,
kmeans,
mergeSmallRegionsInPlace,
findContours,
step,
]);

/* Draw merged canvas */
useEffect(() => {
if (!mergedData || !mergedCanvasRef.current) return;
const { pixels, width, height } = mergedData;
const canvas = mergedCanvasRef.current;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.putImageData(new ImageData(pixels, width, height), 0, 0);
}, [mergedData]);

/* Draw contours canvas */
useEffect(() => {
if (!contourData || !contourCanvasRef.current) return;
const { pixels, width, height } = contourData;
const canvas = contourCanvasRef.current;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.putImageData(new ImageData(pixels, width, height), 0, 0);
}, [contourData]);

/* UI fragments */
const EmptyState = useMemo(() => (
<>
<Tooltip content="Upload an image from your device">
<Upload className={`anchor-style ${styles.uploadIcon}`} />
</Tooltip>

<p className={`text-center ${styles.dragDropText}`}>
Drag & Drop or{' '}
<Tooltip content="Select an image file from your computer">
<span className={`anchor-style ${styles.noTextWrap}`}>
Choose File
</span>
</Tooltip>

<p className={`text-center ${styles.dragDropText}`}>
Drag & Drop or{' '}
<Tooltip content="Select an image file from your computer">
<span className={`anchor-style ${styles.noTextWrap}`}>Choose File</span>
</Tooltip>
</p>
</>
),
[]
);
</p>
</>
), []);

const LoadedState = useMemo(() => {
if (!originalSrc) return null;

return (
<>
<img src={originalSrc} alt="Original" className={styles.preview} />
<div className={styles.imageSection}>
<p className={styles.previewLabel}>Original Image</p>
<img src={originalSrc} alt="Original" className={styles.preview} />
</div>

{mergedData && (
<div className={styles.imageSection}>
<p className={styles.previewLabel}>Merged Regions</p>
<canvas ref={mergedCanvasRef} className={styles.preview} />
</div>
)}

{!isProcessing ? (
<Tooltip content="Process the image and convert it to numbers">
<Tooltip content="Process the image and extract contours">
<button
className="uppercase button"
onClick={(e) => {
e.stopPropagation();
processImage();
}}>
}}
>
Ok
</button>
</Tooltip>
) : (
<LoadingHedgehog progress={progress} text={`Processing — ${Math.round(progress)}%`} />
<LoadingHedgehog
progress={progress}
text={`Processing – ${Math.round(progress)}%`}
/>
)}

{contourData && (
<div className={styles.imageSection}>
<p className={styles.previewLabel}>Contours</p>
<canvas ref={contourCanvasRef} className={styles.preview} />
</div>
)}
</>
);
}, [originalSrc, isProcessing, progress, processImage]);
}, [originalSrc, mergedData, contourData, isProcessing, progress, processImage]);

return (
<GlassCard
className={`flex-center flex-column ${styles.dropZone}`}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => {
if (!originalSrc) inputRef.current?.click();
}}
data-image-loaded={!!originalSrc}>
onClick={() => !originalSrc && inputRef.current?.click()}
data-image-loaded={!!originalSrc}
>
{originalSrc ? LoadedState : EmptyState}

<input ref={inputRef} id={inputId} type="file" accept="image/*" hidden onChange={handleSelect} />
<input
ref={inputRef}
id={inputId}
type="file"
accept="image/*"
hidden
onChange={handleSelect}
/>
</GlassCard>
);
};

export default WasmImageProcessor;

10 changes: 9 additions & 1 deletion src/hooks/useWasmWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export function useWasmWorker() {
return (await call('mergeSmallRegionsInPlace', { pixels, width, height, minArea, minWidth, minHeight }, ['pixels']))
.output.pixels;
};
const kmeans2 = async ({ pixels, width, height, num_colors, max_iter, min_area }) => {
return (await call('kmeans_clustering_graph', { pixels, width, height, num_colors, max_iter, min_area }, ['pixels'])).output.pixels;
};

const findContours = async ({ pixels, width, height }) => {
return (await call('visualize_contours', { pixels, width, height }, ['pixels']))
.output.pixels;
}
Comment thread
Ryan-Millard marked this conversation as resolved.
Outdated

return { call, gaussianBlur, bilateralFilter, blackThreshold, kmeans, mergeSmallRegionsInPlace };
return { call, gaussianBlur, bilateralFilter, blackThreshold, kmeans, kmeans2, mergeSmallRegionsInPlace, findContours };
}
2 changes: 1 addition & 1 deletion src/wasm/modules/image/include/Pixel.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace ImageLib {
template <typename NumberT> struct Pixel {
static_assert(std::is_arithmetic<NumberT>::value,
"RGBPixel<NumberT>: NumberT must be a numeric type");
"Pixel<NumberT>: NumberT must be a numeric type");

using value_type = NumberT;

Expand Down
31 changes: 31 additions & 0 deletions src/wasm/modules/image/include/contours.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#ifndef CONTOURS_H
#define CONTOURS_H

#include <vector>
#include <array>
#include <cstdint>
#include <cmath>
#include <stdexcept>
#include <cstdlib>

struct Point {
int x = 0;
int y = 0;
};

struct ContoursResult {
// contours[k] is a sequence of boundary pixels (x,y) in image coordinates (0..w-1, 0..h-1)
std::vector<std::vector<Point>> contours;

// hierarchy[k] = { next_sibling, prev_sibling, first_child, parent }
// -1 means "none"
std::vector<std::array<int, 4>> hierarchy;

// is_hole[k] == true if contour k is a hole border
std::vector<bool> is_hole;
};

namespace contours {
ContoursResult find_contours(const std::vector<uint8_t>& binary, int width, int height);
}
#endif
Loading
Loading