diff --git a/bindings/c/include/cimg2num.h b/bindings/c/include/cimg2num.h index 4d42b9e8a..ee464aff8 100644 --- a/bindings/c/include/cimg2num.h +++ b/bindings/c/include/cimg2num.h @@ -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; /// Color space flag. /// - 0 = CIE LAB (more perceptually accurate) @@ -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 diff --git a/bindings/c/src/cimg2num.cpp b/bindings/c/src/cimg2num.cpp index d82e48615..c472a8bbd 100644 --- a/bindings/c/src/cimg2num.cpp +++ b/bindings/c/src/cimg2num.cpp @@ -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 @@ -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 @@ -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(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; } diff --git a/bindings/js/src/wasm_wrapper.c b/bindings/js/src/wasm_wrapper.c index cdcf01df5..3124b2bc6 100644 --- a/bindings/js/src/wasm_wrapper.c +++ b/bindings/js/src/wasm_wrapper.c @@ -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(); @@ -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); diff --git a/bindings/py/src/img2num_pybind.cpp b/bindings/py/src/img2num_pybind.cpp index 27d933e6b..f3571e90e 100644 --- a/bindings/py/src/img2num_pybind.cpp +++ b/bindings/py/src/img2num_pybind.cpp @@ -121,17 +121,20 @@ PYBIND11_MODULE(_img2num, m) { "labels_to_svg", [](pybind11::array_t data, pybind11::array_t labels, int width, int height, - int min_area) { + int min_area, int min_thickness) { const uint8_t* data_ptr {static_cast(data.request().ptr)}; const int32_t* labels_ptr {static_cast(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 ---------------------- @@ -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(); + if (kwargs.contains("min_thickness")) + c->min_thickness = kwargs["min_thickness"].cast(); if (kwargs.contains("color_space")) c->color_space = kwargs["color_space"].cast(); @@ -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) { @@ -199,6 +205,7 @@ PYBIND11_MODULE(_img2num, m) { << "bilateral_filter: " << pybind11::repr(pybind11::cast(c.bilateral_filter)).cast() << ", " << "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() << "}>"; diff --git a/core/include/img2num.h b/core/include/img2num.h index e629cd3c3..f1c46d9b9 100644 --- a/core/include/img2num.h +++ b/core/include/img2num.h @@ -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; + /// Color space flag. /// - 0 = CIE LAB (more perceptually accurate) /// - 1 = sRGB (faster). @@ -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 diff --git a/core/include/internal/LABAPixel.h b/core/include/internal/LABAPixel.h index 8ebb44164..1b7c1a237 100644 --- a/core/include/internal/LABAPixel.h +++ b/core/include/internal/LABAPixel.h @@ -8,8 +8,7 @@ namespace ImageLib { #ifdef _MSC_VER #pragma pack(push, 1) #endif -template -struct LABAPixel : public ImageLib::LABPixel { +template struct LABAPixel : public ImageLib::LABPixel { // ----- Members ----- NumberT alpha; diff --git a/core/include/internal/LABPixel.h b/core/include/internal/LABPixel.h index 5222004ae..4ae54f711 100644 --- a/core/include/internal/LABPixel.h +++ b/core/include/internal/LABPixel.h @@ -16,8 +16,7 @@ namespace ImageLib { #ifdef _MSC_VER #pragma pack(push, 1) #endif -template -struct LABPixel : public Pixel { +template struct LABPixel : public Pixel { // ----- Members ----- NumberT l, a, b; diff --git a/core/include/internal/RGBAPixel.h b/core/include/internal/RGBAPixel.h index da4dcc7d7..90441918c 100644 --- a/core/include/internal/RGBAPixel.h +++ b/core/include/internal/RGBAPixel.h @@ -8,8 +8,7 @@ namespace ImageLib { #ifdef _MSC_VER #pragma pack(push, 1) #endif -template -struct RGBAPixel : public ImageLib::RGBPixel { +template struct RGBAPixel : public ImageLib::RGBPixel { // ----- Members ----- NumberT alpha; diff --git a/core/include/internal/RGBPixel.h b/core/include/internal/RGBPixel.h index adfc07d61..4be5e6f22 100644 --- a/core/include/internal/RGBPixel.h +++ b/core/include/internal/RGBPixel.h @@ -11,8 +11,7 @@ namespace ImageLib { #ifdef _MSC_VER #pragma pack(push, 1) #endif -template -struct RGBPixel : public Pixel { +template struct RGBPixel : public Pixel { // ----- Members ----- NumberT red, green, blue; diff --git a/core/include/internal/bezier.h b/core/include/internal/bezier.h index e68468331..17cbf39f7 100644 --- a/core/include/internal/bezier.h +++ b/core/include/internal/bezier.h @@ -7,4 +7,13 @@ void fit_curve_reduction( const std::vector>& chains, std::vector>& 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>& chains, const std::vector>& fixed, + std::vector>& results, float tolerance +); #endif diff --git a/core/include/internal/contours.h b/core/include/internal/contours.h index 8fecf708f..b404bd6f5 100644 --- a/core/include/internal/contours.h +++ b/core/include/internal/contours.h @@ -49,6 +49,23 @@ ContoursResult find_contours(const std::vector& binary, int width, int void stitch_smooth(std::vector& vecA, std::vector& vecB); void coupled_smooth(std::vector>& 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>& contours, Rect bounds, std::vector junctions, + int width +); + void pack_with_boundary_constraints( std::vector>& contours, Rect bounds, int iterations = 15 ); diff --git a/core/include/internal/douglas_peucker.h b/core/include/internal/douglas_peucker.h new file mode 100644 index 000000000..b1258d2f3 --- /dev/null +++ b/core/include/internal/douglas_peucker.h @@ -0,0 +1,31 @@ +#ifndef DOUGLAS_PEUCKER_H +#define DOUGLAS_PEUCKER_H + +#include "internal/contours.h" + +#include +#include + +/** + * `@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>& chains, const std::vector>& fixed, + std::vector>& results, float eps +); + +#endif diff --git a/core/include/internal/gpu.h b/core/include/internal/gpu.h index aae3dd650..41384e6f7 100644 --- a/core/include/internal/gpu.h +++ b/core/include/internal/gpu.h @@ -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 } diff --git a/core/include/internal/graph.h b/core/include/internal/graph.h index a8acaaa81..f6a9d7f50 100644 --- a/core/include/internal/graph.h +++ b/core/include/internal/graph.h @@ -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& 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]; + } + + /** + * `@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 analyzeJunctions(const std::vector& skel, int w, int h); + public: inline Graph(std::unique_ptr>& nodes, int width, int height) : m_nodes(std::move(nodes)) @@ -71,7 +101,7 @@ class Graph { void discover_edges( const std::vector& 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(); }; diff --git a/core/include/internal/shared_contours.h b/core/include/internal/shared_contours.h new file mode 100644 index 000000000..78b48ef9b --- /dev/null +++ b/core/include/internal/shared_contours.h @@ -0,0 +1,27 @@ +#ifndef SHARED_CONTOURS_H +#define SHARED_CONTOURS_H + +#include "internal/contours.h" // QuadBezier +#include "internal/Point.h" + +#include +#include +#include + +/** + * `@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>> +build_shared_loops(const std::vector& labels, int w, int h, float eps); + +#endif diff --git a/core/src/internal/bezier.cpp b/core/src/internal/bezier.cpp index 6de1963af..20c578e87 100644 --- a/core/src/internal/bezier.cpp +++ b/core/src/internal/bezier.cpp @@ -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>& chains, const std::vector>& fixed, + std::vector>& results, float tolerance +) { + for (size_t i = 0; i < chains.size(); ++i) { + const std::vector& chain = chains[i]; + const int n = static_cast(chain.size()); + std::vector result; + if (n < 2) { + results.push_back(result); + continue; + } + + // Segment boundaries: chain ends plus every interior junction point. + std::vector bounds; + bounds.push_back(0); + for (int k = 1; k < n - 1; ++k) + if (k < static_cast(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 seg(chain.begin() + a, chain.begin() + b + 1); + fitRecursive(seg, tolerance, result); + } + results.push_back(result); + } +} diff --git a/core/src/internal/contours.cpp b/core/src/internal/contours.cpp index 419dc2864..e378dae0c 100644 --- a/core/src/internal/contours.cpp +++ b/core/src/internal/contours.cpp @@ -679,6 +679,21 @@ createBoundaryMask(const std::vector>& contours, Rect bounds) return locked; } +void updateLockedMasks( + const std::vector>& contours, std::vector>& locked, + std::vector& junctions, int width +) { + for (size_t c = 0; c < contours.size(); ++c) { + for (size_t p = 0; p < contours[c].size(); ++p) { + Point pt = contours[c][p]; + int idx = pt.y * width + pt.x; + if (junctions[idx] > 0) { + locked[c][p] = true; + } + } + } +} + // --- Corner Detection (Feature Preservation) --- std::vector detectCorners(const std::vector& pts, float angleThresholdDeg = 150.0) { std::vector isCorner(pts.size(), false); @@ -714,7 +729,7 @@ void coupledSmooth( std::vector>& contours, const std::vector>& lockedMasks, float pairRadiusSq = 2.25f ) { - SavitzkyGolay sg(2, 2); // radius, polynomial order + SavitzkyGolay sg(3, 2); // radius, polynomial order // first fit std::vector> smoothedContours; @@ -816,6 +831,17 @@ void coupled_smooth(std::vector>& contours, Rect bounds) { coupledSmooth(contours, lockedMasks, 1.0f); } +void coupled_smooth_junctions( + std::vector>& contours, Rect bounds, std::vector junctions, + int width +) { + auto lockedMasks = createBoundaryMask(contours, bounds); + + updateLockedMasks(contours, lockedMasks, junctions, width); + + coupledSmooth(contours, lockedMasks, 1.0f); +} + // --- Main Solver --- void pack_with_boundary_constraints( std::vector>& contours, Rect bounds, int iterations diff --git a/core/src/internal/douglas_peucker.cpp b/core/src/internal/douglas_peucker.cpp new file mode 100644 index 000000000..5162ab66b --- /dev/null +++ b/core/src/internal/douglas_peucker.cpp @@ -0,0 +1,158 @@ +#include "internal/douglas_peucker.h" + +#include +#include +#include +#include + +// --- Signed perpendicular distance of P from the directed line A->B --- +// Positive = P lies to the "left" of the A->B travel direction (using the +// n = (-dy, dx) normal). Falls back to the point distance for a zero-length AB. +static inline float signed_left_dist(const Point& A, const Point& B, const Point& P) { + const float dx = B.x - A.x, dy = B.y - A.y; + const float l = std::sqrt(dx * dx + dy * dy); + if (l < 1e-9f) + return std::sqrt(Point::distSq(P, A)); + return ((P.y - A.y) * dx - (P.x - A.x) * dy) / l; +} + +/** + * @brief Non-retracting Douglas–Peucker simplification for a polyline segment. + * + * Recursively simplifies the polyline defined by pts[lo..hi] using a Douglas–Peucker + * strategy with an additional geometric constraint to prevent inward boundary collapse. + * + * The standard DP algorithm retains the point with maximum perpendicular deviation + * from the chord (lo, hi) if that deviation exceeds `eps`. This variant introduces an + * additional "non-retraction" rule: a candidate simplification is rejected if removing + * intermediate vertices would cause the boundary to move inward (toward the region + * interior) by more than `retract_eps` at any vertex. + * + * The sign of inward/outward movement is determined using `interior_sign`, which maps + * signed left-distance to an interior (+) / exterior (-) convention based on the polygon + * winding order. As a result: + * - Removing convex (exterior) bulges is treated as potential retraction. + * - Flattening concave (interior) dents is always considered safe, as it expands or + * preserves the region. + * + * This constraint ensures that simplification does not create excessive inward + * collapse, which could otherwise introduce gaps between neighboring regions or + * polygons separated by a limited overlap margin. + * + * @param pts Input polyline points. + * @param lo Start index of the segment (inclusive). + * @param hi End index of the segment (inclusive). + * @param eps Maximum allowed perpendicular deviation for DP simplification. + * @param retract_eps Maximum allowed inward boundary retraction per vertex. + * @param interior_sign Sign mapping used to classify inward vs outward deviation + * based on polygon winding. + * @param keep Output mask indicating which vertices are preserved. + */ +static void dp_reduce( + const std::vector& pts, int lo, int hi, float eps, float retract_eps, + float interior_sign, std::vector& keep +) { + if (hi - lo < 2) + return; // no interior vertices to drop + + const Point& A = pts[lo]; + const Point& B = pts[hi]; + + float max_abs_dev = 0.0f; // worst |deviation| -> fidelity / split choice + float max_retract = 0.0f; // worst inward move -> watertightness guard + int split = lo; + for (int k = lo + 1; k < hi; ++k) { + const float d = signed_left_dist(A, B, pts[k]); + const float adev = std::fabs(d); + if (adev > max_abs_dev) { + max_abs_dev = adev; + split = k; + } + // interior_sign * d > 0 => vertex is interior (concave, safe to flatten); + // < 0 => vertex is exterior (convex), dropping it retracts the boundary. + const float retract = -interior_sign * d; + if (retract > max_retract) + max_retract = retract; + } + + if (max_abs_dev <= eps && max_retract <= retract_eps) + return; // drop the run + + keep[split] = 1; + dp_reduce(pts, lo, split, eps, retract_eps, interior_sign, keep); + dp_reduce(pts, split, hi, eps, retract_eps, interior_sign, keep); +} + +/** + * `@brief` + * [Douglas-Peucker](https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm) + * 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 + * + * `@note` retract_eps (max inward boundary move) defaults to min(eps, 0.5px) + */ +void dp_curve_reduction( + const std::vector>& chains, const std::vector>& fixed, + std::vector>& results, float eps +) { + float retract_eps = std::min(eps, 0.5f); + + for (size_t i = 0; i < chains.size(); ++i) { + const std::vector& chain = chains[i]; + const int n = static_cast(chain.size()); + std::vector result; + if (n < 2) { + results.push_back(result); + continue; + } + + // Orientation: sign of the shoelace area decides which side of a chord is + // the region interior (see dp_reduce). Computed over the closed chain. + double sa = 0.0; + for (int k = 0; k < n; ++k) { + const Point& p = chain[k]; + const Point& q = chain[(k + 1) % n]; + sa += static_cast(p.x) * q.y - static_cast(q.x) * p.y; + } + const float interior_sign = (sa > 0.0) ? 1.0f : -1.0f; + + // Segment boundaries: chain ends plus every interior fixed point. DP runs + // inside each [bounds[s], bounds[s+1]] span with its ends pinned. + std::vector bounds; + bounds.push_back(0); + for (int k = 1; k < n - 1; ++k) + if (k < static_cast(fixed[i].size()) && fixed[i][k]) + bounds.push_back(k); + bounds.push_back(n - 1); + + std::vector keep(n, 0); + for (int b : bounds) + keep[b] = 1; + for (size_t s = 0; s + 1 < bounds.size(); ++s) + dp_reduce(chain, bounds[s], bounds[s + 1], eps, retract_eps, interior_sign, keep); + + // Emit kept points in order as straight-line quads. + int prev = -1; + for (int k = 0; k < n; ++k) { + if (!keep[k]) + continue; + if (prev >= 0) { + const Point& P = chain[prev]; + const Point& Q = chain[k]; + result.push_back({P, (P + Q) * 0.5f, Q}); + } + prev = k; + } + results.push_back(result); + } +} diff --git a/core/src/internal/graph.cpp b/core/src/internal/graph.cpp index 6e646a606..b78031686 100644 --- a/core/src/internal/graph.cpp +++ b/core/src/internal/graph.cpp @@ -1,9 +1,13 @@ #include "internal/graph.h" #include "internal/bezier.h" +#include "internal/douglas_peucker.h" #include "internal/Pixel.h" +#include "internal/shared_contours.h" #include +#include +#include #include #include #include @@ -214,90 +218,233 @@ void Graph::process_overlapping_edges() { } } -void Graph::compute_contours() { - // overlap edge pixels - // then compute contours - process_overlapping_edges(); - // ask each Node to compute contours - for (const Node_ptr& n : get_nodes()) { - if (n->area() == 0) - continue; - n->compute_contour(); +std::vector Graph::analyzeJunctions(const std::vector& skel, int w, int h) { + std::vector junction_map(w * h, 0); + + // 8-Neighbor Order (Clockwise) + // P9 P2 P3 + // P8 P1 P4 + // P7 P6 P5 + int dx[] = {0, 1, 1, 1, 0, -1, -1, -1}; + int dy[] = {-1, -1, 0, 1, 1, 1, 0, -1}; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + if (getPixel(skel, w, h, x, y) == 0) + continue; + + // 1. Get Neighbors in Circular Order + int p[8]; + for (int k = 0; k < 8; ++k) { + p[k] = getPixel(skel, w, h, x + dx[k], y + dy[k]) ? 1 : 0; + } + + // 2. Count Transitions (0 -> 1) + // This is the Crossing Number / 2 + int transitions = 0; + for (int k = 0; k < 8; ++k) { + if (p[k] == 0 && p[(k + 1) % 8] == 1) + transitions++; + } + + // 3. Count Total Neighbors (for Endpoint check) + int neighbors = 0; + for (int k = 0; k < 8; ++k) + neighbors += p[k]; + + // 4. Classify + if (transitions >= 3) { + junction_map[y * w + x] = 1; + } + } } + return junction_map; +} + +void Graph::compute_contours() { + + /* + Shared-edge mode: build region boundaries on the crack grid so + neighbouring contours are exactly coincident along shared edges -- no + overlap band, no gaps + */ + + float eps = 0.25f; - // smoothing - std::vector> all_contours; + std::vector labels(static_cast(m_width) * m_height, -1); for (const Node_ptr& n : get_nodes()) { if (n->area() == 0) continue; - - ColoredContours* c0 = &n->m_contours; - for (size_t i = 0; i < c0->contours.size(); ++i) { - all_contours.push_back(c0->contours[i]); - } + for (auto& p : n->get_pixels()) + labels[static_cast(p.position.y) * m_width + p.position.x] = n->id(); } - contours::coupled_smooth( - all_contours, Rect {0.0f, 0.0f, static_cast(m_width), static_cast(m_height)} - ); - - std::vector> all_curves; - fit_curve_reduction(all_contours, all_curves, 0.5f); + auto loops = build_shared_loops(labels, m_width, m_height, eps); - int j = 0; for (const Node_ptr& n : get_nodes()) { if (n->area() == 0) continue; + n->clear_contour(); + auto it = loops.find(n->id()); + if (it == loops.end()) + continue; + ImageLib::RGBPixel c = n->color(); + ImageLib::RGBAPixel col {c.red, c.green, c.blue, 255}; + for (std::vector& curve : it->second) { + std::vector anchors; // keep contours[] parallel to curves[] + anchors.reserve(curve.size() + 1); + for (const QuadBezier& q : curve) + anchors.push_back(q.p0); + if (!curve.empty()) + anchors.push_back(curve.back().p2); + n->m_contours.contours.push_back(std::move(anchors)); + n->m_contours.curves.push_back(std::move(curve)); + n->m_contours.colors.push_back(col); + n->m_contours.hierarchy.push_back({-1, -1, -1, -1}); + n->m_contours.is_hole.push_back(false); + } + } +} - ColoredContours* c0 = &n->m_contours; - for (size_t i = 0; i < c0->contours.size(); ++i) { - std::copy(all_contours[j].begin(), all_contours[j].end(), c0->contours[i].begin()); +namespace { + +// 1D squared-distance transform (Felzenszwalb & Huttenlocher): for every q, +// d[q] = min_p ( (q - p)^2 + f[p] ). O(n). +void dt_1d(const std::vector& f, std::vector& d, int n) { + constexpr float INF = 1e20f; + std::vector v(n); + std::vector z(n + 1); + int k = 0; + v[0] = 0; + z[0] = -INF; + z[1] = INF; + for (int q = 1; q < n; ++q) { + float s; + while (true) { + s = ((f[q] + static_cast(q) * q) - (f[v[k]] + static_cast(v[k]) * v[k])) / + (2.0f * static_cast(q - v[k])); + if (s <= z[k] && k > 0) { + --k; + } else { + break; + } + } + ++k; + v[k] = q; + z[k] = s; + z[k + 1] = INF; + } + k = 0; + for (int q = 0; q < n; ++q) { + while (z[k + 1] < static_cast(q)) + ++k; + const float dq = static_cast(q - v[k]); + d[q] = dq * dq + f[v[k]]; + } +} - c0->curves[i].resize(all_curves[j].size()); - std::copy(all_curves[j].begin(), all_curves[j].end(), c0->curves[i].begin()); - j++; +// Largest inscribed-disk radius (in pixels) of a region: the maximum over all +// region pixels of the Euclidean distance to the nearest non-region pixel. +// Computed with an exact squared Euclidean distance transform, so the result is +// independent of how long or how curved the region is -- a property that a +// bounding-box aspect ratio does not have. Thickness ~= 2 * this radius. +float max_inscribed_radius(const Node_ptr& n) { + std::vector mask; + const std::array xywh = n->create_binary_image(mask); // tight bbox + const int w = xywh[2]; + const int h = xywh[3]; + if (w <= 0 || h <= 0) + return 0.0f; + + // Pad by one pixel so the region's boundary against the exterior is treated + // as background by the distance transform. + const int pw = w + 2; + const int ph = h + 2; + constexpr float INF = 1e20f; + + std::vector grid(static_cast(pw) * ph); + for (int y = 0; y < ph; ++y) { + for (int x = 0; x < pw; ++x) { + const bool inside = x >= 1 && x <= w && y >= 1 && y <= h && + mask[static_cast(y - 1) * w + (x - 1)]; + grid[static_cast(y) * pw + x] = inside ? INF : 0.0f; } } + + // Separable two-pass transform: columns first, then rows. + std::vector in, out(std::max(pw, ph)); + in.resize(ph); + for (int x = 0; x < pw; ++x) { + for (int y = 0; y < ph; ++y) + in[y] = grid[static_cast(y) * pw + x]; + dt_1d(in, out, ph); + for (int y = 0; y < ph; ++y) + grid[static_cast(y) * pw + x] = out[y]; + } + in.resize(pw); + float max_d2 = 0.0f; + for (int y = 0; y < ph; ++y) { + for (int x = 0; x < pw; ++x) + in[x] = grid[static_cast(y) * pw + x]; + dt_1d(in, out, pw); + for (int x = 0; x < pw; ++x) + if (out[x] > max_d2) + max_d2 = out[x]; + } + return std::sqrt(max_d2); } -void Graph::merge_small_area_nodes(const int32_t min_area) { - int32_t counter {0}; - while (!all_areas_bigger_than(min_area)) { +} // namespace + +void Graph::merge_small_area_nodes(const int32_t min_area, const int32_t min_thickness) { + // Keep merging while any pass still makes progress. Using "did this pass + // merge anything?" as the loop guard (instead of re-testing every node) + // also avoids spinning forever on a node that is too small/thin but has no + // valid neighbour to merge into. + bool merged_any = true; + while (merged_any) { + merged_any = false; + for (const Node_ptr& n : get_nodes()) { - if (n->area() < min_area) { - std::vector neighbors; - neighbors.reserve(n->num_edges()); - std::copy(n->edges().begin(), n->edges().end(), std::back_inserter(neighbors)); - - ImageLib::RGBPixel col = n->color(); - - Node_ptr best_neighbor = nullptr; - float best_score = std::numeric_limits::max(); - for (const Node_ptr& ne : n->edges()) { - if (ne->area() > 0) { - float cdist = ImageLib::RGBPixel::colorDistance(ne->color(), col); - float score = static_cast(ne->area()) + 10.f * cdist; - if (score < best_score) { - best_score = score; - best_neighbor = ne; - } + if (n->area() == 0) + continue; + + bool needs_merge = n->area() < static_cast(min_area); + if (!needs_merge && min_thickness > 0) { + // too thin == no inscribed disk of radius min_thickness/2 fits. + needs_merge = 2.0f * max_inscribed_radius(n) < static_cast(min_thickness); + } + if (!needs_merge) + continue; + + ImageLib::RGBPixel col = n->color(); + + Node_ptr best_neighbor = nullptr; + float best_score = std::numeric_limits::max(); + for (const Node_ptr& ne : n->edges()) { + if (ne->area() > 0) { + float cdist = ImageLib::RGBPixel::colorDistance(ne->color(), col); + float score = static_cast(ne->area()) + 10.f * cdist; + if (score < best_score) { + best_score = score; + best_neighbor = ne; } } + } - // no valid neighbor found, skip this node - if (!best_neighbor) { - continue; - } + // no valid neighbor found, skip this node + if (!best_neighbor) { + continue; + } - if (best_neighbor->area() >= n->area()) { - merge_nodes(best_neighbor, n); - } else { - merge_nodes(n, best_neighbor); - } + if (best_neighbor->area() >= n->area()) { + merge_nodes(best_neighbor, n); + } else { + merge_nodes(n, best_neighbor); } + merged_any = true; } clear_unconnected_nodes(); - ++counter; } } diff --git a/core/src/internal/image_to_svg.cpp b/core/src/internal/image_to_svg.cpp index f4517360c..78f9eb252 100644 --- a/core/src/internal/image_to_svg.cpp +++ b/core/src/internal/image_to_svg.cpp @@ -23,8 +23,9 @@ std::string image_to_svg( img_data.data(), out_data.data(), out_labels.data(), width, height, config.kmeans.k, config.kmeans.max_iter, config.color_space ); - std::string svg { - labels_to_svg(data, out_labels.data(), width, height, config.min_cluster_area)}; + std::string svg {labels_to_svg( + data, out_labels.data(), width, height, config.min_cluster_area, config.min_thickness + )}; return svg; } diff --git a/core/src/internal/image_utils.cpp b/core/src/internal/image_utils.cpp index 48fd57efc..80509fc93 100644 --- a/core/src/internal/image_utils.cpp +++ b/core/src/internal/image_utils.cpp @@ -1,5 +1,11 @@ #include "internal/image_utils.h" +#include "img2num.h" +#include "internal/fft_iterative.h" +#include "internal/Image.h" +#include "internal/PixelConverters.h" +#include "internal/RGBAPixel.h" + #include #include #include @@ -7,12 +13,6 @@ #include #include -#include "img2num.h" -#include "internal/Image.h" -#include "internal/PixelConverters.h" -#include "internal/RGBAPixel.h" -#include "internal/fft_iterative.h" - // M_PI is not defined by default on MSVC #ifndef M_PI #define M_PI 3.14159265358979323846 diff --git a/core/src/internal/kmeans_gpu.cpp b/core/src/internal/kmeans_gpu.cpp index 4e46dd787..6cf39f740 100644 --- a/core/src/internal/kmeans_gpu.cpp +++ b/core/src/internal/kmeans_gpu.cpp @@ -65,7 +65,7 @@ __attribute__((packed)) struct CentroidParams { float r, g, b, a; uint32_t width; - uint32_t pad[3]; // Padding to align to 16 bytes + uint32_t pad[3]; // Padding to align to 16 bytes } #ifndef _MSC_VER __attribute__((packed)) diff --git a/core/src/internal/labels_to_svg.cpp b/core/src/internal/labels_to_svg.cpp index deef17a0a..a618da3f1 100644 --- a/core/src/internal/labels_to_svg.cpp +++ b/core/src/internal/labels_to_svg.cpp @@ -202,7 +202,7 @@ height : number of pixels in image = 1 : 1 : 1 */ 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 = 0 ) { const int32_t num_pixels {width * height}; std::vector labels_vector {labels, labels + num_pixels}; @@ -221,7 +221,7 @@ std::string labels_to_svg( G.discover_edges(region_labels, width, height); // 4. Merge small area nodes until all nodes are minArea or larger - G.merge_small_area_nodes(min_area); + G.merge_small_area_nodes(min_area, min_thickness); // 5. recolor image on new regions ImageLib::Image> results {width, height}; diff --git a/core/src/internal/shared_contours.cpp b/core/src/internal/shared_contours.cpp new file mode 100644 index 000000000..23d791651 --- /dev/null +++ b/core/src/internal/shared_contours.cpp @@ -0,0 +1,333 @@ +#include "internal/shared_contours.h" + +#include "internal/bezier.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr int SMOOTHING_ITERATIONS {5}; +constexpr int32_t OUTSIDE = std::numeric_limits::min(); // image exterior label + +// Endpoint- and border-preserving smoothing of a corner polyline. Points sitting +// on the image frame are locked so the canvas rectangle stays crisp. +void smooth_edge(std::vector& p, int w, int h, int iters) { + const int n = static_cast(p.size()); + if (n < 3) + return; + auto on_border = [&](const Point& q) { + return q.x <= 0.0f || q.y <= 0.0f || q.x >= w || q.y >= h; + }; + for (int it = 0; it < iters; ++it) { + std::vector q = p; + for (int i = 1; i < n - 1; ++i) { + if (on_border(p[i])) + continue; + const Point& a = p[i - 1]; + const Point& b = p[i]; + const Point& c = p[i + 1]; + q[i] = {0.25f * a.x + 0.5f * b.x + 0.25f * c.x, 0.25f * a.y + 0.5f * b.y + 0.25f * c.y}; + } + p.swap(q); + } +} + +// Smooth a corner chain (endpoints fixed) and fit it to quadratic beziers. Done +// once per canonical edge; both adjacent regions reuse the result, so the fitted +// curve is shared and the two regions stay exactly coincident. +std::vector fit_edge(const std::vector& corners, int w, int h, float eps) { + std::vector pts = corners; + smooth_edge(pts, w, h, SMOOTHING_ITERATIONS); + if (pts.size() < 2) + return {}; + std::vector> chain {pts}; + std::vector> res; + fit_curve_reduction(chain, res, eps); + return res.empty() ? std::vector {} : res[0]; +} + +void reverse_curve(std::vector& c) { + std::reverse(c.begin(), c.end()); + for (QuadBezier& q : c) + std::swap(q.p0, q.p2); +} + +std::unordered_map>> +build_shared_loops(const std::vector& labels, int w, int h, float eps) { + const int W1 = w + 1; // corner grid width + auto L = [&](int x, int y) -> int32_t { + if (x < 0 || x >= w || y < 0 || y >= h) + return OUTSIDE; + return labels[static_cast(y) * w + x]; + }; + auto cidx = [&](int cx, int cy) { + return cy * W1 + cx; + }; + auto cx_of = [&](int idx) { + return idx % W1; + }; + auto cy_of = [&](int idx) { + return idx / W1; + }; + auto pt_of = [&](int idx) { + return Point {static_cast(cx_of(idx)), static_cast(cy_of(idx))}; + }; + + // --- 1. Undirected crack adjacency over corners --------------------------- + std::unordered_map> adj; + auto add_crack = [&](int a, int b) { + adj[a].push_back(b); + adj[b].push_back(a); + }; + for (int cy = 0; cy <= h; ++cy) + for (int cx = 0; cx <= w; ++cx) { + if (cy < h && L(cx - 1, cy) != L(cx, cy)) + add_crack(cidx(cx, cy), cidx(cx, cy + 1)); + if (cx < w && L(cx, cy - 1) != L(cx, cy)) + add_crack(cidx(cx, cy), cidx(cx + 1, cy)); + } + + // A corner is a junction wherever its crack degree != 2: degree 3/4 are branch + // points (incl. diagonal pixel touches), degree 1 is a dangling end. Degree-2 + // corners are interior to a single two-region edge. + auto is_junction = [&](int idx) { + auto it = adj.find(idx); + return it == adj.end() ? false : it->second.size() != 2; + }; + + // --- 2. Extract canonical edges (junction -> junction chains) ------------- + struct Edge { + int a, b; // endpoint corners + bool closed; // junction-free loop + std::vector path; // full corner sequence (closed: ring, no repeat) + std::vector curve; // fitted curve (front@a .. back@b) + }; + std::vector edges; + std::map, int> crack_edge; // undirected crack -> edge id + auto ckey = [](int a, int b) { + return std::make_pair(std::min(a, b), std::max(a, b)); + }; + + auto store_edge = [&](std::vector seq, bool closed) { + std::vector cseq = seq; + if (closed) + cseq.push_back(seq.front()); // close the ring for geometry + Edge e; + e.closed = closed; + e.path = std::move(seq); + e.a = cseq.front(); + e.b = cseq.back(); + std::vector corners; + corners.reserve(cseq.size()); + for (int c : cseq) + corners.push_back(pt_of(c)); + e.curve = fit_edge(corners, w, h, eps); + int id = static_cast(edges.size()); + for (size_t i = 0; i + 1 < cseq.size(); ++i) + crack_edge[ckey(cseq[i], cseq[i + 1])] = id; + edges.push_back(std::move(e)); + }; + + auto walk_edge = [&](int start, int first_next) { + std::vector seq {start}; + int prev = start, cur = first_next; + while (true) { + seq.push_back(cur); + if (is_junction(cur)) + break; + int nxt = -1; + for (int nb : adj[cur]) + if (nb != prev) { + nxt = nb; + break; + } + if (nxt < 0 || cur == start) + break; + prev = cur; + cur = nxt; + } + return seq; + }; + + // 2a. edges between junctions + for (auto& kv : adj) { + if (!is_junction(kv.first)) + continue; + for (int nb : kv.second) + if (!crack_edge.count(ckey(kv.first, nb))) + store_edge(walk_edge(kv.first, nb), false); + } + // 2b. junction-free closed loops + for (auto& kv : adj) { + for (int nb : kv.second) { + if (crack_edge.count(ckey(kv.first, nb))) + continue; + std::vector seq {kv.first}; + int prev = kv.first, cur = nb; + const size_t cap = adj.size() + 4; + while (cur != kv.first && seq.size() < cap) { + seq.push_back(cur); + int nxt = -1; + for (int x : adj[cur]) + if (x != prev) { + nxt = x; + break; + } + if (nxt < 0) + break; + prev = cur; + cur = nxt; + } + // canonicalise start to smallest corner so both regions agree. + int mpos = 0; + for (size_t i = 0; i < seq.size(); ++i) + if (seq[i] < seq[mpos]) + mpos = static_cast(i); + const int mm = static_cast(seq.size()); + std::vector rot; + rot.reserve(mm); + for (int i = 0; i < mm; ++i) + rot.push_back(seq[(mpos + i) % mm]); + store_edge(rot, true); + } + } + + // --- 3. Per-region directed boundary cracks (region kept on the RIGHT) ---- + std::unordered_map>> region_dir; + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + int32_t r = L(x, y); + auto& dir = region_dir[r]; + const int c00 = cidx(x, y), c10 = cidx(x + 1, y); + const int c11 = cidx(x + 1, y + 1), c01 = cidx(x, y + 1); + if (L(x, y - 1) != r) + dir[c00].push_back(c10); // top + if (L(x + 1, y) != r) + dir[c10].push_back(c11); // right + if (L(x, y + 1) != r) + dir[c11].push_back(c01); // bottom + if (L(x - 1, y) != r) + dir[c01].push_back(c00); // left + } + + auto unit = [&](int from, int to, int& dx, int& dy) { + dx = cx_of(to) - cx_of(from); + dy = cy_of(to) - cy_of(from); + }; + + // --- 4. Assemble each region's loops from canonical shared curves --------- + std::unordered_map>> result; + for (auto& rkv : region_dir) { + int32_t r = rkv.first; + if (r == OUTSIDE) + continue; + std::map> dir = rkv.second; // erased as consumed + + // right-hand rule: prefer right, straight, left, back of incoming dir. + auto take_from = [&](int from, int dx_in, int dy_in) -> int { + auto it = dir.find(from); + if (it == dir.end() || it->second.empty()) + return -1; + auto& outs = it->second; + int pref[4][2] = {{-dy_in, dx_in}, {dx_in, dy_in}, {dy_in, -dx_in}, {-dx_in, -dy_in}}; + for (auto& pr : pref) + for (size_t k = 0; k < outs.size(); ++k) { + int dx, dy; + unit(from, outs[k], dx, dy); + if (dx == pr[0] && dy == pr[1]) { + int to = outs[k]; + outs.erase(outs.begin() + k); + return to; + } + } + int to = outs.back(); + outs.pop_back(); + return to; + }; + + std::vector> loops; + for (auto& it : dir) { + while (!it.second.empty()) { + int start = it.first; + int nxt = take_from(start, 1, 0); + if (nxt < 0) + break; + std::vector loop {start}; + int dx_in, dy_in; + unit(start, nxt, dx_in, dy_in); + int cur = nxt; + while (cur != start) { + loop.push_back(cur); + int nn = take_from(cur, dx_in, dy_in); + if (nn < 0) + break; + unit(cur, nn, dx_in, dy_in); + cur = nn; + } + loops.push_back(std::move(loop)); + } + } + + std::vector>& out_loops = result[r]; + for (auto& loop : loops) { + int m = static_cast(loop.size()); + if (m < 2) + continue; + // rotate so the loop starts at a junction (edges are entered at ends). + int js = -1; + for (int t = 0; t < m; ++t) + if (is_junction(loop[t])) { + js = t; + break; + } + if (js > 0) + std::rotate(loop.begin(), loop.begin() + js, loop.end()); + + std::vector curve; + int i = 0; + while (i < m) { + int from = loop[i]; + int to = loop[(i + 1) % m]; + auto eit = crack_edge.find(ckey(from, to)); + if (eit == crack_edge.end()) { + ++i; + continue; + } + const Edge& e = edges[eit->second]; + std::vector seg = e.curve; + if (e.closed) { + int mm = static_cast(e.path.size()); + int p = 0; + while (p < mm && e.path[p] != from) + ++p; + bool fwd = (p < mm) && (e.path[(p + 1) % mm] == to); + if (!fwd) + reverse_curve(seg); + for (const QuadBezier& q : seg) + curve.push_back(q); + i = m; + } else { + bool fwd = (from == e.a); + if (!fwd) + reverse_curve(seg); + for (const QuadBezier& q : seg) + curve.push_back(q); + int other = fwd ? e.b : e.a; + int j = i + 1; + while (j < m && loop[j] != other) + ++j; + i = j; + } + } + if (curve.size() >= 2) + out_loops.push_back(std::move(curve)); + } + } + + return result; +} diff --git a/docs/static/img/readme-demo/output-aerial-view-mountains_pexels-pixabay-51373.svg b/docs/static/img/readme-demo/output-aerial-view-mountains_pexels-pixabay-51373.svg index a2c863b3f..e6013e66f 100644 --- a/docs/static/img/readme-demo/output-aerial-view-mountains_pexels-pixabay-51373.svg +++ b/docs/static/img/readme-demo/output-aerial-view-mountains_pexels-pixabay-51373.svg @@ -1,805 +1,489 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/readme-demo/output-margate-garden.svg b/docs/static/img/readme-demo/output-margate-garden.svg index 2c6094d4f..e8a34a510 100644 --- a/docs/static/img/readme-demo/output-margate-garden.svg +++ b/docs/static/img/readme-demo/output-margate-garden.svg @@ -1,1088 +1,586 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/readme-demo/output-ring-on-hand.svg b/docs/static/img/readme-demo/output-ring-on-hand.svg index 6b0be59fa..9be0a9a4c 100644 --- a/docs/static/img/readme-demo/output-ring-on-hand.svg +++ b/docs/static/img/readme-demo/output-ring-on-hand.svg @@ -1,751 +1,576 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example-apps/console-c/main.c b/example-apps/console-c/main.c index 584776057..fde00446c 100644 --- a/example-apps/console-c/main.c +++ b/example-apps/console-c/main.c @@ -72,7 +72,7 @@ int main(int argc, char** argv) { // Apply kmeans (C API) img2num_kmeans(img_data, out_data, out_labels, width, height, 16, 100, 1); // Generate SVG - char* res_svg = img2num_labels_to_svg(img_data, out_labels, width, height, 100); + char* res_svg = img2num_labels_to_svg(img_data, out_labels, width, height, 100, 0); img2num_ImageToSvgConfig cfg = img2num_ImageToSvgConfig_default(); char* res_svg2 = img2num_image_to_svg(image_data_original, width, height, &cfg); diff --git a/example-apps/console-cpp/main.cpp b/example-apps/console-cpp/main.cpp index c14e8e9f9..0029f24fb 100644 --- a/example-apps/console-cpp/main.cpp +++ b/example-apps/console-cpp/main.cpp @@ -65,10 +65,11 @@ int main(int argc, char** argv) { // Apply kmeans img2num::kmeans(img_data, out_data, out_labels, width, height, 32, 100, 1); // Generate SVG - std::string res_svg {img2num::labels_to_svg(img_data, out_labels, width, height, 100)}; + std::string res_svg {img2num::labels_to_svg(img_data, out_labels, width, height, 100, 10)}; img2num::ImageToSvgConfig config; config.kmeans.k = 32; + config.min_thickness = 10; std::string res_svg2 {img2num::image_to_svg(img_data, width, height, config)}; // Save the blurred image diff --git a/example-apps/console-py/main.py b/example-apps/console-py/main.py index 461d91745..19b38406b 100644 --- a/example-apps/console-py/main.py +++ b/example-apps/console-py/main.py @@ -23,15 +23,15 @@ def main(): cv2.imwrite(os.path.join(OUTDIR, "bilateral_image.png"), cv2.cvtColor(img_bf, cv2.COLOR_RGBA2BGR)) # kmeans - img_kmeans, labels = img2num.kmeans(img_bf, 16, 100, 0) + img_kmeans, labels = img2num.kmeans(img_bf, 64, 100, 0) cv2.imwrite(os.path.join(OUTDIR, "kmeans_image.png"), cv2.cvtColor(img_kmeans, cv2.COLOR_RGBA2BGR)) # svg file - res_svg = img2num.labels_to_svg(img, labels, 100) + res_svg = img2num.labels_to_svg(img, labels, 100, 10) with open(os.path.join(OUTDIR, "result.svg"),"w") as f: f.writelines(res_svg) # res_svg2 should match res_svg - cfg = img2num.ImageToSvgConfig(kmeans = {"k": 16}) + cfg = img2num.ImageToSvgConfig(kmeans = {"k": 64}, min_thickness=10) print(cfg) res_svg2 = img2num.image_to_svg(img, config=cfg) diff --git a/packages/js/safeWasmWrappers.js b/packages/js/safeWasmWrappers.js index 504c43556..801ea8c14 100644 --- a/packages/js/safeWasmWrappers.js +++ b/packages/js/safeWasmWrappers.js @@ -184,17 +184,18 @@ export const kmeans = async ({ * @param {number} options.width - Image width. * @param {number} options.height - Image height. * @param {number} [options.min_area=100] - Minimum area of a region to be considered a contour. - * @returns {Promise<{svg: string>} Generated SVG. + * @param {number} [options.min_thickness=10] - Minimum thickness of a region to be considered a contour. + * @returns {Promise<{svg: string}>} Generated SVG. * @throws {Error} If the WASM function fails or input labels are invalid. * @example * const { svg } = await findContours({ pixels, labels, width, height }); * @variation Converts labeled (from a clustering algorithm, e.g. K-Means) image into an SVG. * @since 0.0.0 */ -export const findContours = async ({ pixels, labels, width, height, min_area = 100 }) => { +export const findContours = async ({ pixels, labels, width, height, min_area = 100, min_thickness = 10 }) => { const result = await callWasm({ funcName: "labels_to_svg", - args: { pixels, labels, width, height, min_area }, + args: { pixels, labels, width, height, min_area, min_thickness }, bufferKeys: [ { key: "pixels", type: "Uint8ClampedArray" }, { key: "labels", type: "Int32Array" }, @@ -221,6 +222,7 @@ export const findContours = async ({ pixels, labels, width, height, min_area = 1 * @param {number} [options.num_colors=16] - Number of color clusters. * @param {number} [options.max_iter=100] - Maximum number of iterations. * @param {number} [options.min_area=100] - Minimum area of a region to be considered a contour. + * @param {number} [options.min_thickness=10] - Minimum thickness of a region to be considered a contour. * @param {number} [options.color_space=0] - Color space mode. * @returns {Promise<{svg: string}>} Generated SVG. * @throws {Error} If the WASM function fails or input labels are invalid. @@ -229,10 +231,10 @@ export const findContours = async ({ pixels, labels, width, height, min_area = 1 * @variation Convert a raster image (e.g., PNG, JPG) into an SVG. * @since 0.0.0 */ -export const imageToSvg = async ({ pixels, width, height, sigma_spatial = 3, sigma_range = 50, num_colors = 16, max_iter = 100, min_area = 100, color_space = 0 }) => { +export const imageToSvg = async ({ pixels, width, height, sigma_spatial = 3, sigma_range = 50, num_colors = 16, max_iter = 100, min_area = 100, min_thickness = 10, color_space = 0 }) => { const result = await callWasm({ funcName: "image_to_svg", - args: { pixels, width, height, sigma_spatial, sigma_range, num_colors, max_iter, min_area, color_space }, + args: { pixels, width, height, sigma_spatial, sigma_range, num_colors, max_iter, min_area, min_thickness, color_space }, bufferKeys: [{ key: "pixels", type: "Uint8ClampedArray" }], returnType: "string", }); diff --git a/packages/py/img2num/api.py b/packages/py/img2num/api.py index 09b995941..f1e5c06b9 100644 --- a/packages/py/img2num/api.py +++ b/packages/py/img2num/api.py @@ -61,8 +61,8 @@ def kmeans(data: npt.NDArray[np.uint8], k: int, max_iter: int, color_space: int, return _kmeans(data, width, height, k, max_iter, color_space) @_inject_dims("data") -def labels_to_svg(data: npt.NDArray[np.uint8], labels: npt.NDArray[int], min_area: int, *, width: int, height: int) -> str: - return _labels_to_svg(data, labels, width, height, min_area) +def labels_to_svg(data: npt.NDArray[np.uint8], labels: npt.NDArray[int], min_area: int, min_thickness: int, *, width: int, height: int) -> str: + return _labels_to_svg(data, labels, width, height, min_area, min_thickness) @_inject_dims("image") def image_to_svg(image: npt.NDArray[np.uint8], *, width: int, height: int, config=None) -> str: diff --git a/test.jpg b/test.jpg deleted file mode 100755 index 9af4a552e..000000000 Binary files a/test.jpg and /dev/null differ