Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
72a6a7a
modifications to svg extraction
Krasner Jun 4, 2026
aac9407
updated method for hole-free svg
Krasner Jun 8, 2026
7e5ac60
clean up
Krasner Jun 8, 2026
37309f0
When merging contours consider thickness - set a minimum thickness to…
Krasner Jun 8, 2026
b37807e
update python arguments to include min_thickness
Krasner Jun 8, 2026
761fd24
formatting
Krasner Jun 8, 2026
7cbded3
missing bracket
Krasner Jun 8, 2026
51348fc
Merge branch 'dev' into dev/core-opt
Krasner Jun 9, 2026
b4ee348
add emsdk dev ownsership
Krasner Jun 9, 2026
9efab7c
allow dev permissions to uv
Krasner Jun 9, 2026
02cc692
formatting
Krasner Jun 9, 2026
0e973cb
hardcode userid and groupid to be 1000 to match host machine - is thi…
Krasner Jun 10, 2026
8e35991
remove dev user. everything should be root
Krasner Jun 10, 2026
680922e
Merge branch 'dev' into dev/core-opt
Krasner Jun 11, 2026
6da4d6f
Install UV in development Dockerfile
Krasner Jun 11, 2026
7aa21d7
Refactor Dockerfile.dev to match dev branch
Krasner Jun 11, 2026
b3dc95c
Merge branch 'dev' into dev/core-opt
Krasner Jun 12, 2026
0017754
Merge branch 'dev' into dev/core-opt
Krasner Jun 15, 2026
b728615
remove rw
Krasner Jun 15, 2026
8160c24
Apply suggestions from code review
Krasner Jun 15, 2026
1cb1f87
Apply suggestions from code review
Krasner Jun 15, 2026
b9864ce
docs(core): doxygen comment for coupled_smooth_junctions in contours.h
Ryan-Millard Jun 15, 2026
c2bc23b
docs(core): doxygen comment for getPixel and analyzeJunctions in graph.h
Ryan-Millard Jun 15, 2026
df07f98
chore(core): remove console prints in contours.cpp
Ryan-Millard Jun 15, 2026
b3e33ef
docs(core): add doxygen comment to min_thickness of ImageToSvgConfig …
Ryan-Millard Jun 15, 2026
d6f29a1
chore: delete accidentally-committed image
Ryan-Millard Jun 15, 2026
9360efa
clean up old code
Krasner Jun 15, 2026
b3d016e
update readme svgs
Krasner Jun 17, 2026
7dcf8aa
Merge branch 'dev' into dev/core-opt
Krasner Jun 19, 2026
b2a8c27
formatting/ apply PR suggestions
Krasner Jun 19, 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
4 changes: 3 additions & 1 deletion bindings/c/include/cimg2num.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ typedef struct img2num_ImageToSvgConfig {

/// Minimum area (in pixels) for a region to be included in the SVG.
int min_cluster_area;
/// Minimum thickness (in pixels) for a region to be included in the SVG.
int min_thickness;
Comment thread
Krasner marked this conversation as resolved.

/// Color space flag.
/// - 0 = CIE LAB (more perceptually accurate)
Expand Down Expand Up @@ -82,7 +84,7 @@ void img2num_bilateral_filter(
/// @copydoc ::IMG2NUM_H_LABELS_TO_SVG_DOC
char* img2num_labels_to_svg(
const uint8_t* data, const int32_t* labels, const int width, const int height,
const int min_area
const int min_area, const int min_thickness
);

/// @copydoc ::IMG2NUM_H_IMAGE_TO_SVG_DOC
Expand Down
11 changes: 7 additions & 4 deletions bindings/c/src/cimg2num.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ static img2num::ImageToSvgConfig to_cpp(const img2num_ImageToSvgConfig& c) {
},

.min_cluster_area = c.min_cluster_area,
.min_thickness = c.min_thickness,
.color_space = c.color_space
};
// clang-format on
Expand All @@ -38,6 +39,7 @@ static img2num_ImageToSvgConfig to_c(const img2num::ImageToSvgConfig& cpp) {
.max_iter = cpp.kmeans.max_iter
},
.min_cluster_area = cpp.min_cluster_area,
.min_thickness = cpp.min_thickness,
.color_space = cpp.color_space
};
// clang-format on
Expand Down Expand Up @@ -92,19 +94,20 @@ void img2num_bilateral_filter(

char* img2num_labels_to_svg(
const uint8_t* data, const int32_t* labels, const int width, const int height,
const int min_area
const int min_area, const int min_thickness
) {
char* result {nullptr};
img2num::clear_last_error_and_catch(
[&](const uint8_t* d, const int32_t* l, const int w, const int h, const int min_a) {
std::string svg {img2num::labels_to_svg(d, l, w, h, min_a)};
[&](const uint8_t* d, const int32_t* l, const int w, const int h, const int min_a,
const int min_t) {
std::string svg {img2num::labels_to_svg(d, l, w, h, min_a, min_t)};
result = static_cast<char*>(std::malloc(svg.size() + 1));
if (!result) {
return; // Allocation failed
}
std::memcpy(result, svg.c_str(), svg.size() + 1);
},
data, labels, width, height, min_area
data, labels, width, height, min_area, min_thickness
);
return result;
}
Expand Down
8 changes: 5 additions & 3 deletions bindings/js/src/wasm_wrapper.c
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ EMSCRIPTEN_KEEPALIVE void bilateral_filter(
}

EMSCRIPTEN_KEEPALIVE char* labels_to_svg(
uint8_t* data, int32_t* labels, const int width, const int height, const int min_area
uint8_t* data, int32_t* labels, const int width, const int height, const int min_area,
const int min_thickness
) {
return img2num_labels_to_svg(data, labels, width, height, min_area);
return img2num_labels_to_svg(data, labels, width, height, min_area, min_thickness);
}

EMSCRIPTEN_KEEPALIVE char* image_to_svg(
const uint8_t* data, const int width, const int height, double sigma_spatial,
double sigma_range, const int32_t k, const int32_t max_iter, const int min_area,
const uint8_t color_space
const int min_thickness, const uint8_t color_space
) {
img2num_ImageToSvgConfig config = img2num_ImageToSvgConfig_default();

Expand All @@ -52,6 +53,7 @@ EMSCRIPTEN_KEEPALIVE char* image_to_svg(
config.kmeans.k = k;
config.kmeans.max_iter = max_iter;
config.min_cluster_area = min_area;
config.min_thickness = min_thickness;
config.color_space = color_space;

return img2num_image_to_svg(data, width, height, &config);
Expand Down
13 changes: 10 additions & 3 deletions bindings/py/src/img2num_pybind.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,20 @@ PYBIND11_MODULE(_img2num, m) {
"labels_to_svg",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> data,
pybind11::array_t<int32_t, pybind11::array::c_style> labels, int width, int height,
int min_area) {
int min_area, int min_thickness) {
const uint8_t* data_ptr {static_cast<const uint8_t*>(data.request().ptr)};
const int32_t* labels_ptr {static_cast<const int32_t*>(labels.request().ptr)};

std::string svg {img2num::labels_to_svg(data_ptr, labels_ptr, width, height, min_area)};
std::string svg {img2num::labels_to_svg(
data_ptr, labels_ptr, width, height, min_area, min_thickness
)};
pybind11::str svg_py_str(std::move(svg));

return svg_py_str;
},
pybind11::arg("data"), pybind11::arg("labels"), pybind11::arg("width"),
pybind11::arg("height"), pybind11::arg("min_area"), "Convert labels to SVG string"
pybind11::arg("height"), pybind11::arg("min_area"), pybind11::arg("min_thickness"),
"Convert labels to SVG string"
);

// ---------------------- Config Structs ----------------------
Expand Down Expand Up @@ -180,6 +183,8 @@ PYBIND11_MODULE(_img2num, m) {
// 4. Process remaining top-level kwargs (like color_space or min_cluster_area)
if (kwargs.contains("min_cluster_area"))
c->min_cluster_area = kwargs["min_cluster_area"].cast<int>();
if (kwargs.contains("min_thickness"))
c->min_thickness = kwargs["min_thickness"].cast<int>();
if (kwargs.contains("color_space"))
c->color_space = kwargs["color_space"].cast<uint8_t>();

Expand All @@ -190,6 +195,7 @@ PYBIND11_MODULE(_img2num, m) {
)
.def_readwrite("bilateral_filter", &img2num::ImageToSvgConfig::bilateral_filter)
.def_readwrite("min_cluster_area", &img2num::ImageToSvgConfig::min_cluster_area)
.def_readwrite("min_thickness", &img2num::ImageToSvgConfig::min_thickness)
.def_readwrite("color_space", &img2num::ImageToSvgConfig::color_space)
.def_readwrite("kmeans", &img2num::ImageToSvgConfig::kmeans)
.def("__repr__", [](const img2num::ImageToSvgConfig& c) {
Expand All @@ -199,6 +205,7 @@ PYBIND11_MODULE(_img2num, m) {
<< "bilateral_filter: "
<< pybind11::repr(pybind11::cast(c.bilateral_filter)).cast<std::string>() << ", "
<< "min_cluster_area: " << c.min_cluster_area << ", "
<< "min_thickness: " << c.min_thickness << ", "
<< "color_space: " << (int)c.color_space << ", "
<< "kmeans: " << pybind11::repr(pybind11::cast(c.kmeans)).cast<std::string>()
<< "}>";
Expand Down
7 changes: 6 additions & 1 deletion core/include/img2num.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ struct ImageToSvgConfig {
/// Minimum area (in pixels) for a region to be included in the SVG.
int min_cluster_area = 100;

/// Minimum thickness (in pixels) for a region to be included in the SVG.
/// Regions with an inscribed disk diameter less than this value are merged.
/// Set to 0 to disable thickness-based filtering.
int min_thickness = 0;
Comment thread
Ryan-Millard marked this conversation as resolved.

/// Color space flag.
/// - 0 = CIE LAB (more perceptually accurate)
/// - 1 = sRGB (faster).
Expand Down Expand Up @@ -77,7 +82,7 @@ void bilateral_filter(
/// @copydoc IMG2NUM_H_LABELS_TO_SVG_DOC
std::string labels_to_svg(
const uint8_t* data, const int32_t* labels, const int width, const int height,
const int min_area
const int min_area, const int min_thickness
);

/// @copydoc IMG2NUM_H_IMAGE_TO_SVG_DOC
Expand Down
3 changes: 1 addition & 2 deletions core/include/internal/LABAPixel.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ namespace ImageLib {
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
template <typename NumberT>
struct LABAPixel : public ImageLib::LABPixel<NumberT> {
template <typename NumberT> struct LABAPixel : public ImageLib::LABPixel<NumberT> {
// ----- Members -----
NumberT alpha;

Expand Down
3 changes: 1 addition & 2 deletions core/include/internal/LABPixel.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ namespace ImageLib {
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
template <typename NumberT>
struct LABPixel : public Pixel<NumberT> {
template <typename NumberT> struct LABPixel : public Pixel<NumberT> {
// ----- Members -----
NumberT l, a, b;

Expand Down
3 changes: 1 addition & 2 deletions core/include/internal/RGBAPixel.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ namespace ImageLib {
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
template <typename NumberT>
struct RGBAPixel : public ImageLib::RGBPixel<NumberT> {
template <typename NumberT> struct RGBAPixel : public ImageLib::RGBPixel<NumberT> {
// ----- Members -----
NumberT alpha;

Expand Down
3 changes: 1 addition & 2 deletions core/include/internal/RGBPixel.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ namespace ImageLib {
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
template <typename NumberT>
struct RGBPixel : public Pixel<NumberT> {
template <typename NumberT> struct RGBPixel : public Pixel<NumberT> {
// ----- Members -----
NumberT red, green, blue;

Expand Down
9 changes: 9 additions & 0 deletions core/include/internal/bezier.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ void fit_curve_reduction(
const std::vector<std::vector<Point>>& chains, std::vector<std::vector<QuadBezier>>& results,
float tolerance
);

// Same, but `fixed[i][k]!=0` marks point k of chain i as a junction that must NOT
// move: the chain is split at those points so each becomes an exact (pinned)
// curve endpoint. Chains with no fixed points fit identically to the overload
// above.
void fit_curve_reduction(
const std::vector<std::vector<Point>>& chains, const std::vector<std::vector<uint8_t>>& fixed,
std::vector<std::vector<QuadBezier>>& results, float tolerance
);
#endif
17 changes: 17 additions & 0 deletions core/include/internal/contours.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ ContoursResult find_contours(const std::vector<uint8_t>& binary, int width, int
void stitch_smooth(std::vector<Point>& vecA, std::vector<Point>& vecB);
void coupled_smooth(std::vector<std::vector<Point>>& contours, Rect bounds);

/**
* `@brief` Applies coupled smoothing with junction point locking.
*
* Similar to coupled_smooth but additionally locks points identified as junctions,
* preventing them from moving during the smoothing process. This preserves junction
* positions where multiple region boundaries meet.
*
* `@param` contours Vector of contour polylines to smooth
* `@param` bounds Bounding rectangle defining boundary constraints
* `@param` junctions Junction mask (image buffer with nonzero entries marking junction pixels)
* `@param` width Image width for raster indexing into the junctions mask
*/
void coupled_smooth_junctions(
std::vector<std::vector<Point>>& contours, Rect bounds, std::vector<uint8_t> junctions,
int width
);

void pack_with_boundary_constraints(
std::vector<std::vector<Point>>& contours, Rect bounds, int iterations = 15
);
Expand Down
31 changes: 31 additions & 0 deletions core/include/internal/douglas_peucker.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#ifndef DOUGLAS_PEUCKER_H
#define DOUGLAS_PEUCKER_H

#include "internal/contours.h"

#include <cstdint>
#include <vector>

/**
* `@brief` Douglas-Peucker contour point reduction with junction locking and retraction bounds.
*
* Reduces each contour's point count while preserving junctions and preventing gaps.
* Fixed junctions marked by `fixed[i][k] != 0` are kept as exact shared endpoints.
* Simplification is bounded to never retract a boundary inward past the inter-region
* overlap margin, preventing gaps between neighboring regions. Kept points are emitted
* as straight-line quadratic Bezier segments for compatibility with existing SVG output.
*
* `@param` chains Input polyline chains
* `@param` fixed Junction mask: fixed[i][k] != 0 marks point k of chain i as a junction to preserve
* `@param` results Output straight-line QuadBezier segments for each chain
* `@param` eps Overall deviation tolerance in pixels (overridable via IMG2NUM_DP_EPS env var)
*
* `@note` retract_eps (max inward boundary move) defaults to min(eps, 0.5px) and can be
* overridden via IMG2NUM_DP_RETRACT environment variable.
*/
void dp_curve_reduction(
const std::vector<std::vector<Point>>& chains, const std::vector<std::vector<uint8_t>>& fixed,
std::vector<std::vector<QuadBezier>>& results, float eps
);

#endif
5 changes: 3 additions & 2 deletions core/include/internal/gpu.h
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,9 @@ class GPU {
std::cout << "Device Acquired" << std::endl;
} else {
std::cerr << "Device Failed: "
<< (msg.data && msg.length > 0 ? std::string(msg.data, msg.length) : "Unknown error")
<< std::endl;
<< (msg.data && msg.length > 0 ? std::string(msg.data, msg.length)
: "Unknown error")
<< std::endl;
}
device_ready = true; // Unblock the loop
}
Expand Down
32 changes: 31 additions & 1 deletion core/include/internal/graph.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ class Graph {
void hash_node_ids(void);
void process_overlapping_edges();

/**
* `@brief` Safely retrieves a pixel value from a binary image with bounds checking.
*
* `@param` img Binary image buffer
* `@param` w Image width
* `@param` h Image height
* `@param` x Pixel x-coordinate
* `@param` y Pixel y-coordinate
* `@return` Pixel value at (x, y), or 0 if out of bounds
*/
inline uint8_t getPixel(const std::vector<uint8_t>& img, int w, int h, int x, int y) {
if (x < 0 || x >= w || y < 0 || y >= h)
return 0; // Boundary check
return img[y * w + x];
}
Comment on lines +42 to +56

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.

We should probably schedule a refactor to make the library use the image class wherever possible because we repeat this pattern a lot.


/**
* `@brief` Analyzes a skeleton image to detect junction points using 8-neighbor
* crossing-number.
*
* Scans the skeleton image and marks pixels as junctions where three or more branches meet,
* using the crossing-number method (counts 0→1 transitions in the 8-neighbor ring).
*
* `@param` skel Binary skeleton image (nonzero = skeleton pixel)
* `@param` w Image width
* `@param` h Image height
* `@return` Junction mask with nonzero entries marking junction pixels
*/
std::vector<uint8_t> analyzeJunctions(const std::vector<uint8_t>& skel, int w, int h);
Comment thread
Ryan-Millard marked this conversation as resolved.

public:
inline Graph(std::unique_ptr<std::vector<Node_ptr>>& nodes, int width, int height)
: m_nodes(std::move(nodes))
Expand Down Expand Up @@ -71,7 +101,7 @@ class Graph {
void discover_edges(
const std::vector<int32_t>& region_labels, const int32_t width, const int32_t height
);
void merge_small_area_nodes(const int32_t min_area);
void merge_small_area_nodes(const int32_t min_area, const int32_t min_thickness = 0);
void compute_contours();
};

Expand Down
27 changes: 27 additions & 0 deletions core/include/internal/shared_contours.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#ifndef SHARED_CONTOURS_H
#define SHARED_CONTOURS_H

#include "internal/contours.h" // QuadBezier
#include "internal/Point.h"

#include <cstdint>
#include <unordered_map>
#include <vector>

/**
* `@brief` Build crack-grid shared boundary loops for each region.
*
* Builds region boundaries on the pixel-corner ("crack") grid rather than on
* pixel centres. Shared edges are extracted once, simplified once, and reused
* by both adjacent regions so neighbouring loops stay exactly coincident.
*
* `@param` labels Per-pixel region ids in row-major order (`w * h` entries).
* `@param` w Image width in pixels.
* `@param` h Image height in pixels.
* `@param` eps Curve-fit tolerance applied to each canonical edge.
* `@return` Per-region closed boundary loops in corner coordinates.
*/
std::unordered_map<int32_t, std::vector<std::vector<QuadBezier>>>
build_shared_loops(const std::vector<int32_t>& labels, int w, int h, float eps);

#endif
36 changes: 36 additions & 0 deletions core/src/internal/bezier.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,39 @@ void fit_curve_reduction(
results.push_back(result);
}
}

// --- Junction-aware wrapper ---
// Splits each chain at its fixed (junction) points and fits the pieces
// separately. Because fitRecursive always keeps a segment's first and last point
// exactly, every junction becomes a pinned on-curve point the fit cannot move.
void fit_curve_reduction(
const std::vector<std::vector<Point>>& chains, const std::vector<std::vector<uint8_t>>& fixed,
std::vector<std::vector<QuadBezier>>& results, float tolerance
) {
for (size_t i = 0; i < chains.size(); ++i) {
const std::vector<Point>& chain = chains[i];
const int n = static_cast<int>(chain.size());
std::vector<QuadBezier> result;
if (n < 2) {
results.push_back(result);
continue;
}

// Segment boundaries: chain ends plus every interior junction point.
std::vector<int> bounds;
bounds.push_back(0);
for (int k = 1; k < n - 1; ++k)
if (k < static_cast<int>(fixed[i].size()) && fixed[i][k])
bounds.push_back(k);
bounds.push_back(n - 1);

// Fit each [bounds[s], bounds[s+1]] piece; consecutive pieces share the
// junction point, so the curve stays continuous and pinned there.
for (size_t s = 0; s + 1 < bounds.size(); ++s) {
const int a = bounds[s], b = bounds[s + 1];
std::vector<Point> seg(chain.begin() + a, chain.begin() + b + 1);
fitRecursive(seg, tolerance, result);
}
results.push_back(result);
}
}
Loading