From 5d40147fb0df60fb2e49ddf5161ec5c7f5cf8f5a Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Fri, 3 Jul 2026 23:27:11 +0000 Subject: [PATCH 1/9] Fix F011: make OPTA delete per direction so 2px strokes stay connected The OPTA/SPTA kernel evaluated all four contour directions against the pre-cycle state and deleted the unsafe pixels in one batch, so both sides of a two-pixel-thick bar were removed in the same pass and the skeleton disconnected (a 2x9 bar collapsed to its four corner pixels). Replace the single batch with four per-direction sub-iterations per cycle (mark-then-delete after each direction), restoring the sequential visibility the paper's two raster scans guarantee; the per-direction safety conditions are unchanged. Adds an 8-connected component counter and a 2px-bar connectivity test across all seven methods. Review finding F011, figureextract ecosystem review 2026-07-03. Co-Authored-By: Claude Fable 5 --- src/opta.cpp | 105 ++++++++++++++++++++----------------- tests/testthat/test-thin.R | 52 ++++++++++++++++++ 2 files changed, 110 insertions(+), 47 deletions(-) diff --git a/src/opta.cpp b/src/opta.cpp index ddd9486..f5c5ab9 100644 --- a/src/opta.cpp +++ b/src/opta.cpp @@ -30,12 +30,15 @@ // Iterate until no deletions. // // Implementation note: SPTA in the original paper performs two raster -// scans per cycle that together cover all four directions. The -// implementation here is the parallel-friendly equivalent: each cycle -// computes safety / deletability for every contour pixel using the -// pre-cycle state, then deletes the unsafe ones in batch. The +// scans per cycle so that a deletion on one side of a stroke becomes +// visible before the opposite side is tested; that sequential +// visibility is what keeps a two-pixel-thick stroke connected. The +// implementation here preserves that guarantee with four per-direction +// sub-iterations per cycle: each sub-iteration marks the deletable +// pixels on one contour (west, east, north, south) against the current +// state and deletes them before the next direction is scanned. The // per-direction safety conditions are unchanged; only the scan order -// differs from the sequential paper form. +// differs from the two-scan paper form. #include using namespace Rcpp; @@ -93,51 +96,59 @@ IntegerMatrix opta_cpp(IntegerMatrix img, int max_iter) { for (int it = 0; it < max_iter; it++) { bool changed = false; - std::fill(mark.begin(), mark.end(), 0); - - for (int r = 1; r < nrow - 1; r++) { - for (int c = 1; c < ncol - 1; c++) { - if (m(r, c) != 1) continue; - int p2 = m(r - 1, c); - int p3 = m(r - 1, c + 1); - int p4 = m(r, c + 1); - int p5 = m(r + 1, c + 1); - int p6 = m(r + 1, c); - int p7 = m(r + 1, c - 1); - int p8 = m(r, c - 1); - int p9 = m(r - 1, c - 1); - - // N2 protection: pixel has exactly 2 4-adjacent FG neighbours. - if (n2_protected(p2, p4, p6, p8)) continue; - - bool on_contour = false; - bool is_safe = false; - - if (p8 == 0) { - on_contour = true; - if (safe_west(p2, p3, p4, p5, p6, p7, p8, p9)) is_safe = true; - } - if (p4 == 0) { - on_contour = true; - if (safe_east(p2, p3, p4, p5, p6, p7, p8, p9)) is_safe = true; - } - if (p2 == 0) { - on_contour = true; - if (safe_north(p2, p3, p4, p5, p6, p7, p8, p9)) is_safe = true; - } - if (p6 == 0) { - on_contour = true; - if (safe_south(p2, p3, p4, p5, p6, p7, p8, p9)) is_safe = true; - } - if (on_contour && !is_safe) mark(r, c) = 1; + // Four per-direction sub-iterations (west, east, north, south): + // mark the deletable pixels on one contour, delete them, then scan + // the next direction against the updated state. + for (int dir = 0; dir < 4; dir++) { + std::fill(mark.begin(), mark.end(), 0); + + for (int r = 1; r < nrow - 1; r++) { + for (int c = 1; c < ncol - 1; c++) { + if (m(r, c) != 1) continue; + int p2 = m(r - 1, c); + int p3 = m(r - 1, c + 1); + int p4 = m(r, c + 1); + int p5 = m(r + 1, c + 1); + int p6 = m(r + 1, c); + int p7 = m(r + 1, c - 1); + int p8 = m(r, c - 1); + int p9 = m(r - 1, c - 1); + + // Only pixels on this sub-iteration's contour are candidates. + bool on_contour = (dir == 0) ? (p8 == 0) + : (dir == 1) ? (p4 == 0) + : (dir == 2) ? (p2 == 0) + : (p6 == 0); + if (!on_contour) continue; + + // N2 protection: pixel has exactly 2 4-adjacent FG neighbours. + if (n2_protected(p2, p4, p6, p8)) continue; + + // p is preserved if it is safe under ANY contour it lies on. + bool is_safe = false; + if (p8 == 0 && safe_west(p2, p3, p4, p5, p6, p7, p8, p9)) { + is_safe = true; + } + if (p4 == 0 && safe_east(p2, p3, p4, p5, p6, p7, p8, p9)) { + is_safe = true; + } + if (p2 == 0 && safe_north(p2, p3, p4, p5, p6, p7, p8, p9)) { + is_safe = true; + } + if (p6 == 0 && safe_south(p2, p3, p4, p5, p6, p7, p8, p9)) { + is_safe = true; + } + + if (!is_safe) mark(r, c) = 1; + } } - } - for (int i = 0; i < nrow * ncol; i++) { - if (mark[i]) { - m[i] = 0; - changed = true; + for (int i = 0; i < nrow * ncol; i++) { + if (mark[i]) { + m[i] = 0; + changed = true; + } } } diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index 3c5836f..d95c51b 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -4,6 +4,39 @@ methods <- c("zhang_suen", "guo_hall", "lee", "k3m", "hilditch", "opta", "holt") +# Count 8-connected foreground components via flood fill; used by the +# connectivity-preservation tests (thinning may only delete pixels, so +# the component count must never change). +count_components <- function(img) { + visited <- matrix(FALSE, nrow = nrow(img), ncol = ncol(img)) + n <- 0L + for (r0 in seq_len(nrow(img))) { + for (c0 in seq_len(ncol(img))) { + if (img[r0, c0] == 0 || visited[r0, c0]) next + n <- n + 1L + queue <- list(c(r0, c0)) + while (length(queue) > 0) { + pt <- queue[[1]] + queue <- queue[-1] + r <- pt[1] + c <- pt[2] + if (r < 1 || r > nrow(img) || c < 1 || c > ncol(img)) next + if (visited[r, c]) next + if (img[r, c] == 0) next + visited[r, c] <- TRUE + for (dr in -1L:1L) { + for (dc in -1L:1L) { + if (dr != 0L || dc != 0L) { + queue <- c(queue, list(c(r + dr, c + dc))) + } + } + } + } + } + } + n +} + describe("solid square thins to a much smaller skeleton", { for (mth in methods) { local({ @@ -43,6 +76,25 @@ describe("horizontal line collapses to (nearly) a single row", { } }) +describe("a 2px-thick bar keeps a single connected skeleton", { + # A two-pixel-thick stroke is the canonical parallel-deletion trap: a + # batch pass that tests both sides against the pre-pass state can + # delete both sides at once and disconnect the shape. Sequential / + # sub-iteration algorithms must keep the bar in one piece. + for (mth in methods) { + local({ + m <- mth + it(paste0("[", m, "]"), { + img <- matrix(0L, nrow = 6, ncol = 13) + img[3:4, 3:11] <- 1L + sk <- thin(img, method = m) + expect_identical(count_components(img), 1L) + expect_identical(count_components(sk), 1L) + }) + }) + } +}) + describe("thinning is idempotent", { for (mth in methods) { local({ From c8453257ff4fcbcba861ff5b41ece149ecb4594b Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Fri, 3 Jul 2026 23:30:27 +0000 Subject: [PATCH 2/9] Fix F012: pad the image so edge-touching shapes are fully thinned All seven C++ kernels loop over interior pixels only, so foreground in the outermost row/column was never a deletion candidate and shapes touching the matrix edge kept 2-3px-thick blobs there, violating the documented one-pixel-wide skeleton contract (the package's own thinImage() example returned a 7-pixel barbell). thin() now zero-pads the binary matrix by one pixel on all sides before calling the kernel and crops back afterwards, one shared fix for all seven methods. The pinned thinImage example output is updated and an edge-touching bar test pins per-method row widths exactly. Review finding F012, figureextract ecosystem review 2026-07-03. Co-Authored-By: Claude Fable 5 --- R/thin.R | 24 +++++++++++++------ tests/testthat/test-thin.R | 48 +++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/R/thin.R b/R/thin.R index 1f2aa07..7048416 100644 --- a/R/thin.R +++ b/R/thin.R @@ -40,16 +40,26 @@ thin <- function(image, max_iter = 1000L) { method <- match.arg(method) mat <- as_binary_matrix(image) + # The C++ kernels examine an 8-neighbourhood and therefore never + # delete pixels in the outermost row/column. Pad with a one-pixel + # background border so shapes touching the matrix edge are thinned + # like interior shapes, then crop back to the original extent. + rows <- seq_len(nrow(mat)) + 1L + cols <- seq_len(ncol(mat)) + 1L + padded <- matrix(0L, nrow = nrow(mat) + 2L, ncol = ncol(mat) + 2L) + padded[rows, cols] <- mat iter <- as.integer(max_iter) out <- switch(method, - zhang_suen = .zhang_suen_cpp(mat, iter), - guo_hall = .guo_hall_cpp(mat, iter), - lee = .lee_cpp(mat, iter), - k3m = .k3m_cpp(mat, iter), - hilditch = .hilditch_cpp(mat, iter), - opta = .opta_cpp(mat, iter), - holt = .holt_cpp(mat, iter) + zhang_suen = .zhang_suen_cpp(padded, iter), + guo_hall = .guo_hall_cpp(padded, iter), + lee = .lee_cpp(padded, iter), + k3m = .k3m_cpp(padded, iter), + hilditch = .hilditch_cpp(padded, iter), + opta = .opta_cpp(padded, iter), + holt = .holt_cpp(padded, iter) ) + out <- out[rows, cols, drop = FALSE] + dimnames(out) <- dimnames(mat) restore_storage(out, image) } diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index d95c51b..8318e42 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -300,10 +300,9 @@ describe("already-thin skeleton is a fixed point of thin()", { }) describe("output has no foreground pixels on the matrix border", { - # Thinning algorithms in this package examine an 8-neighbourhood, so they - # leave the outermost row / column untouched. We test a representative - # shape against each algorithm and check that the border stays clear - # when the input has a one-pixel margin. + # Thinning only deletes pixels, so an input with a one-pixel + # background margin must keep that margin clear. We test a + # representative shape against each algorithm. for (mth in methods) { local({ m <- mth @@ -330,8 +329,7 @@ describe("small / degenerate inputs do not crash", { }) it(paste0("[", m, "] 1x1 single-foreground matrix"), { img <- matrix(1L, nrow = 1, ncol = 1) - # Boundary pixels are not eligible for removal, so this should be - # preserved. + # An isolated pixel is an endpoint, so it must be preserved. expect_equal(thin(img, method = m), img) }) it(paste0("[", m, "] 3x3 matrix with one centre pixel"), { @@ -441,18 +439,52 @@ describe("exact skeletons on small known shapes", { }) it("thinImage matches its documented 3x5 example output exactly", { + # The 3x3 block touches the top and bottom matrix edges; it must + # thin to the same single centre pixel as the interior 3x3 block + # above, not keep its edge rows un-thinned. img <- matrix(c(0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0), nrow = 3, byrow = TRUE) - expected <- matrix(c(0, 1, 1, 1, 0, + expected <- matrix(c(0, 0, 0, 0, 0, 0, 0, 1, 0, 0, - 0, 1, 1, 1, 0), + 0, 0, 0, 0, 0), nrow = 3, byrow = TRUE) expect_identical(thinImage(img), expected) }) }) +describe("shapes touching the matrix edge are thinned like interior shapes", { + # A 3px-wide vertical bar spanning every row: the edge rows must not + # be left at their full 3px width just because they sit on the matrix + # border. Row widths are pinned exactly per method (OPTA's N2 + # condition keeps the corner pixel pairs at the bar ends, matching + # its published end-pixel behaviour on interior bars). + expected_widths <- list( + zhang_suen = c(0, 1, 1, 1, 1, 1, 0, 0), + guo_hall = c(0, 1, 1, 1, 1, 1, 1, 0), + lee = c(0, 1, 1, 1, 1, 1, 1, 0), + k3m = c(0, 1, 1, 1, 1, 1, 1, 1), + hilditch = c(0, 1, 1, 1, 1, 1, 1, 0), + opta = c(2, 1, 1, 1, 1, 1, 1, 2), + holt = c(0, 1, 1, 1, 1, 1, 1, 0) + ) + for (mth in methods) { + local({ + m <- mth + it(paste0("[", m, "]"), { + img <- matrix(0L, nrow = 8, ncol = 6) + img[, 3:5] <- 1L + sk <- thin(img, method = m) + expect_identical(unname(rowSums(sk)), expected_widths[[m]]) + skeleton_cols <- sort(unique(which(sk == 1L, arr.ind = TRUE)[, "col"])) + expect_identical(skeleton_cols, if (m == "opta") c(3L, 4L, 5L) else 4L) + expect_identical(count_components(sk), 1L) + }) + }) + } +}) + describe("thinImage drop-in", { it("accepts a logical matrix", { img <- matrix(FALSE, nrow = 5, ncol = 5) From cff6abc30e94d424e322e839ed17debf7f244e23 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Fri, 3 Jul 2026 23:31:47 +0000 Subject: [PATCH 3/9] Fix F015: return Inf, not a finite sentinel, on all-foreground images dt_manhattan and dt_chessboard seeded foreground pixels with a finite sentinel (nrow+ncol+1 and max(nrow,ncol)+1), which leaked out unrelaxed when the image contained no background pixel, producing plausible- looking but meaningless distances (7 and 4 on a 3x3) while euclidean correctly returned Inf. Seed with infinity instead, matching dt_euclidean and the documented distance-to-nearest-background contract; Inf + 1.0 propagates correctly through std::min. Adds all-foreground tests pinning Inf for all three metrics. Review finding F015, figureextract ecosystem review 2026-07-03. Co-Authored-By: Claude Fable 5 --- src/distance_transform.cpp | 12 ++++++++---- tests/testthat/test-distance-transform.R | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/distance_transform.cpp b/src/distance_transform.cpp index 8003236..bf47342 100644 --- a/src/distance_transform.cpp +++ b/src/distance_transform.cpp @@ -125,12 +125,15 @@ namespace { NumericMatrix dt_manhattan(IntegerMatrix img) { int nrow = img.nrow(); int ncol = img.ncol(); - const double big = static_cast(nrow) + static_cast(ncol) + 1.0; + // Seed foreground with infinity so that an image with no background + // pixel reports Inf (matching dt_euclidean), not a finite sentinel; + // Inf + 1.0 propagates correctly through std::min. + const double inf = std::numeric_limits::infinity(); NumericMatrix d(nrow, ncol); for (int r = 0; r < nrow; r++) { for (int c = 0; c < ncol; c++) { - d(r, c) = (img(r, c) == 0) ? 0.0 : big; + d(r, c) = (img(r, c) == 0) ? 0.0 : inf; } } @@ -164,12 +167,13 @@ NumericMatrix dt_manhattan(IntegerMatrix img) { NumericMatrix dt_chessboard(IntegerMatrix img) { int nrow = img.nrow(); int ncol = img.ncol(); - const double big = static_cast(std::max(nrow, ncol)) + 1.0; + // Infinity seed for the same reason as dt_manhattan above. + const double inf = std::numeric_limits::infinity(); NumericMatrix d(nrow, ncol); for (int r = 0; r < nrow; r++) { for (int c = 0; c < ncol; c++) { - d(r, c) = (img(r, c) == 0) ? 0.0 : big; + d(r, c) = (img(r, c) == 0) ? 0.0 : inf; } } diff --git a/tests/testthat/test-distance-transform.R b/tests/testthat/test-distance-transform.R index b650e60..f0f71ee 100644 --- a/tests/testthat/test-distance-transform.R +++ b/tests/testthat/test-distance-transform.R @@ -84,6 +84,24 @@ describe("distance_transform: exact full-matrix values from a corner", { }) }) +describe("distance_transform: all-foreground image returns Inf", { + # With no background pixel there is no finite distance to report; + # every metric must return Inf, not a metric-dependent sentinel. + for (metric in c("euclidean", "manhattan", "chessboard")) { + local({ + m <- metric + it(paste0("[", m, "] 3x3"), { + d <- distance_transform(matrix(1L, nrow = 3, ncol = 3), metric = m) + expect_identical(d, matrix(Inf, nrow = 3, ncol = 3)) + }) + it(paste0("[", m, "] 2x5"), { + d <- distance_transform(matrix(1L, nrow = 2, ncol = 5), metric = m) + expect_identical(d, matrix(Inf, nrow = 2, ncol = 5)) + }) + }) + } +}) + describe("distance_transform: all-background image returns zeros", { for (metric in c("euclidean", "manhattan", "chessboard")) { local({ From 5e3f127950e6ff000ce3fd64972bc852d287886a Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Fri, 3 Jul 2026 23:33:46 +0000 Subject: [PATCH 4/9] Fix F016: reject NA input loudly in as_binary_matrix NA in any input storage mode (logical, integer, numeric) silently reached the C++ kernels as NA_integer_ (INT_MIN), corrupting neighbour sums and producing arbitrary skeletons far from the NA cell, with no error from any branch. as_binary_matrix() now checks anyNA() up front and stops with a message naming thinr and how to recode, which also covers distance_transform() and medial_axis() through the shared helper. Documents the constraint on all three entry points and adds NA-rejection tests for every storage mode. Review finding F016, figureextract ecosystem review 2026-07-03. Co-Authored-By: Claude Fable 5 --- R/distance_transform.R | 2 +- R/medial_axis.R | 2 +- R/thin.R | 11 +++++- man/distance_transform.Rd | 2 +- man/medial_axis.Rd | 2 +- man/thin.Rd | 5 ++- tests/testthat/test-coercion-helpers.R | 53 ++++++++++++++++++++++++++ tests/testthat/test-thin.R | 15 ++++++++ 8 files changed, 84 insertions(+), 8 deletions(-) diff --git a/R/distance_transform.R b/R/distance_transform.R index 1929aa0..422e909 100644 --- a/R/distance_transform.R +++ b/R/distance_transform.R @@ -5,7 +5,7 @@ #' #' @param image A binary image: a matrix where non-zero values are #' foreground and zero values are background. Logical, integer, and -#' numeric inputs are accepted. +#' numeric inputs are accepted; `NA` values are not. #' @param metric Distance metric. One of: #' * `"euclidean"` (default) — exact L2 distance, via #' Felzenszwalb & Huttenlocher (2012) linear-time separable diff --git a/R/medial_axis.R b/R/medial_axis.R index 51c5005..fd37551 100644 --- a/R/medial_axis.R +++ b/R/medial_axis.R @@ -15,7 +15,7 @@ #' #' @param image A binary image: a matrix where non-zero values are #' foreground and zero values are background. Logical, integer, and -#' numeric inputs are accepted. +#' numeric inputs are accepted; `NA` values are not. #' @param return_distance Logical. If `FALSE` (default), return only #' the binary skeleton in the same storage mode as `image`. If #' `TRUE`, return a list with elements `skeleton` (the binary diff --git a/R/thin.R b/R/thin.R index 7048416..3eb2ab7 100644 --- a/R/thin.R +++ b/R/thin.R @@ -5,8 +5,9 @@ #' #' @param image A binary image: a matrix or array where non-zero values #' are foreground and zero values are background. Logical, integer, and -#' numeric inputs are all accepted. The image is treated as a 2-D -#' matrix; arrays with more than two dimensions are not yet supported. +#' numeric inputs are all accepted; `NA` values are not. The image is +#' treated as a 2-D matrix; arrays with more than two dimensions are +#' not yet supported. #' @param method Algorithm to use. One of `"zhang_suen"` (default, #' matches `EBImage::thinImage`), `"guo_hall"`, `"lee"` (2-D #' adaptation of Lee, Kashyap & Chu 1994), `"k3m"` (Saeed et al. @@ -66,6 +67,12 @@ thin <- function(image, # Convert any binary-image-shaped input to an IntegerMatrix where # foreground is 1 and background is 0. as_binary_matrix <- function(image) { + if (anyNA(image)) { + # NA_integer_ is INT_MIN in the C++ kernels, where it silently + # corrupts neighbour sums far from the NA cell; reject it loudly. + stop("thinr does not accept NA values in a binary image. ", + "Recode NAs to 0 (background) or 1 (foreground) first.") + } if (is.matrix(image)) { if (is.logical(image)) { return(matrix(as.integer(image), nrow = nrow(image), ncol = ncol(image))) diff --git a/man/distance_transform.Rd b/man/distance_transform.Rd index 3cdf918..1a614c2 100644 --- a/man/distance_transform.Rd +++ b/man/distance_transform.Rd @@ -9,7 +9,7 @@ distance_transform(image, metric = c("euclidean", "manhattan", "chessboard")) \arguments{ \item{image}{A binary image: a matrix where non-zero values are foreground and zero values are background. Logical, integer, and -numeric inputs are accepted.} +numeric inputs are accepted; \code{NA} values are not.} \item{metric}{Distance metric. One of: \itemize{ diff --git a/man/medial_axis.Rd b/man/medial_axis.Rd index 84cc54c..b3bc351 100644 --- a/man/medial_axis.Rd +++ b/man/medial_axis.Rd @@ -9,7 +9,7 @@ medial_axis(image, return_distance = FALSE) \arguments{ \item{image}{A binary image: a matrix where non-zero values are foreground and zero values are background. Logical, integer, and -numeric inputs are accepted.} +numeric inputs are accepted; \code{NA} values are not.} \item{return_distance}{Logical. If \code{FALSE} (default), return only the binary skeleton in the same storage mode as \code{image}. If diff --git a/man/thin.Rd b/man/thin.Rd index ec16d97..90f0e5f 100644 --- a/man/thin.Rd +++ b/man/thin.Rd @@ -13,8 +13,9 @@ thin( \arguments{ \item{image}{A binary image: a matrix or array where non-zero values are foreground and zero values are background. Logical, integer, and -numeric inputs are all accepted. The image is treated as a 2-D -matrix; arrays with more than two dimensions are not yet supported.} +numeric inputs are all accepted; \code{NA} values are not. The image is +treated as a 2-D matrix; arrays with more than two dimensions are +not yet supported.} \item{method}{Algorithm to use. One of \code{"zhang_suen"} (default, matches \code{EBImage::thinImage}), \code{"guo_hall"}, \code{"lee"} (2-D diff --git a/tests/testthat/test-coercion-helpers.R b/tests/testthat/test-coercion-helpers.R index 4bb8078..2855dfb 100644 --- a/tests/testthat/test-coercion-helpers.R +++ b/tests/testthat/test-coercion-helpers.R @@ -68,6 +68,59 @@ describe("as_binary_matrix: array input dispatches through the matrix path", { }) }) +describe("as_binary_matrix: NA values are rejected in every storage mode", { + # NA_integer_ is INT_MIN once it reaches the C++ kernels, where it + # silently corrupts neighbour sums far from the NA cell; reject NAs + # loudly before dispatch instead. + it("numeric NA errors with the documented message", { + expect_error( + thinr:::as_binary_matrix(matrix(c(NA, 1, 0, 1), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) + + it("logical NA errors with the documented message", { + expect_error( + thinr:::as_binary_matrix(matrix(c(NA, TRUE, FALSE, TRUE), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) + + it("integer NA errors with the documented message", { + expect_error( + thinr:::as_binary_matrix(matrix(c(NA_integer_, 1L, 0L, 1L), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) + + it("NaN is rejected like NA", { + expect_error( + thinr:::as_binary_matrix(matrix(c(NaN, 1, 0, 1), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) + + it("distance_transform() rejects NA input through the shared helper", { + expect_error( + distance_transform(matrix(c(NA, 1, 0, 1), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) + + it("medial_axis() rejects NA input through the shared helper", { + expect_error( + medial_axis(matrix(c(NA, 1, 0, 1), nrow = 2)), + "thinr does not accept NA values", + fixed = TRUE + ) + }) +}) + describe("as_binary_matrix: unsupported inputs error with the documented message", { it("character matrix names the storage mode in the message", { expect_error( diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index 8318e42..d75ec7b 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -246,6 +246,21 @@ describe("input validation", { expect_error(thin(img), "expects a 2-D matrix") }) + it("errors on NA in a numeric matrix", { + img <- matrix(c(NA, 1, 0, 1), nrow = 2) + expect_error(thin(img), "does not accept NA values", fixed = TRUE) + }) + + it("errors on NA in a logical matrix", { + img <- matrix(c(NA, TRUE, FALSE, TRUE), nrow = 2) + expect_error(thin(img), "does not accept NA values", fixed = TRUE) + }) + + it("errors on NA in an integer matrix", { + img <- matrix(c(NA_integer_, 1L, 0L, 1L), nrow = 2) + expect_error(thin(img), "does not accept NA values", fixed = TRUE) + }) + it("errors on a 3-D array", { img <- array(0L, dim = c(3, 3, 2)) expect_error(thin(img), "expects a 2-D matrix") From 00e9f7fa304d04fde8a8a812e021dbaf8a1b7068 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Fri, 3 Jul 2026 23:38:19 +0000 Subject: [PATCH 5/9] Fix F013: add connectivity-preservation property suite; tighten holt line test No test asserted that thinning preserves the foreground component count, so the OPTA 2px-bar disconnection (F011) passed the entire suite. Add an 8-connected component-count-preservation property test across all seven methods on 2px/3px horizontal and vertical bars, L/T/ plus shapes, two disjoint blobs, and the thick ring (green now because the F011 fix landed first; it goes red on any future parallel-deletion regression). Strengthen the T-shape/plus-shape/disjoint-blob tests with exact component counts, and tighten the loosened line-collapse bound for holt (now 1 row; only OPTA's published N2 corner behaviour keeps 3). Review finding F013, figureextract ecosystem review 2026-07-03. Co-Authored-By: Claude Fable 5 --- tests/testthat/test-thin.R | 111 +++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 23 deletions(-) diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index d75ec7b..5a502d7 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -37,6 +37,26 @@ count_components <- function(img) { n } +# Rasterize a circular ring of the given radius / stroke thickness; +# used by the connectivity and complex-shape tests. +build_ring <- function(nrow, ncol, radius, cr, cc, thickness = 1) { + img <- matrix(0L, nrow = nrow, ncol = ncol) + for (angle in seq(0, 2 * pi, length.out = 200)) { + r <- round(cr + radius * cos(angle)) + c <- round(cc + radius * sin(angle)) + for (dr in -thickness:thickness) { + for (dc in -thickness:thickness) { + rr <- r + dr + cc2 <- c + dc + if (rr >= 1 && rr <= nrow && cc2 >= 1 && cc2 <= ncol) { + img[rr, cc2] <- 1L + } + } + } + } + img +} + describe("solid square thins to a much smaller skeleton", { for (mth in methods) { local({ @@ -62,11 +82,10 @@ describe("horizontal line collapses to (nearly) a single row", { sk <- thin(img, method = m) rows_with_fg <- which(rowSums(sk) > 0) # OPTA's N2 condition protects diagonal-2-neighbour patterns, - # which preserves bar corner pixels on the top and bottom row; - # Holt's H condition has no crossing-number topology guard. - # Both leave stray pixels at the bar ends - this is the - # published behaviour, not an implementation choice. - max_rows <- if (m %in% c("opta", "holt")) 3L else 1L + # which preserves bar corner pixels on the top and bottom row - + # this is the published behaviour, not an implementation choice. + # Every other method collapses the bar to a single row. + max_rows <- if (m == "opta") 3L else 1L expect_lte(length(rows_with_fg), max_rows, label = paste("method =", m, "; rows with foreground =", @@ -95,6 +114,65 @@ describe("a 2px-thick bar keeps a single connected skeleton", { } }) +describe("thinning preserves the 8-connected component count", { + # Thinning only deletes pixels, so it can split a component but never + # merge two; preserving the count exactly is the defining topological + # invariant of every method in the package. + shape_2px_hbar <- matrix(0L, nrow = 8, ncol = 15) + shape_2px_hbar[4:5, 3:13] <- 1L + shape_3px_hbar <- matrix(0L, nrow = 9, ncol = 15) + shape_3px_hbar[4:6, 3:13] <- 1L + shape_2px_vbar <- matrix(0L, nrow = 15, ncol = 8) + shape_2px_vbar[3:13, 4:5] <- 1L + shape_3px_vbar <- matrix(0L, nrow = 15, ncol = 9) + shape_3px_vbar[3:13, 4:6] <- 1L + shape_l <- matrix(0L, nrow = 13, ncol = 13) + shape_l[3:11, 3:4] <- 1L + shape_l[10:11, 3:11] <- 1L + shape_t <- matrix(0L, nrow = 11, ncol = 11) + shape_t[3:5, 3:9] <- 1L + shape_t[5:9, 5:7] <- 1L + shape_plus <- matrix(0L, nrow = 11, ncol = 11) + shape_plus[5:7, 3:9] <- 1L + shape_plus[3:9, 5:7] <- 1L + shape_blobs <- matrix(0L, nrow = 9, ncol = 15) + shape_blobs[2:4, 2:4] <- 1L + shape_blobs[6:8, 11:13] <- 1L + shapes <- list( + "2px horizontal bar" = list(img = shape_2px_hbar, n = 1L), + "3px horizontal bar" = list(img = shape_3px_hbar, n = 1L), + "2px vertical bar" = list(img = shape_2px_vbar, n = 1L), + "3px vertical bar" = list(img = shape_3px_vbar, n = 1L), + "L-shape" = list(img = shape_l, n = 1L), + "T-shape" = list(img = shape_t, n = 1L), + "plus-shape" = list(img = shape_plus, n = 1L), + "two disjoint blobs" = list(img = shape_blobs, n = 2L), + "thick ring" = list( + img = build_ring(21, 21, radius = 7, cr = 11, cc = 11, thickness = 2), + n = 1L + ) + ) + for (mth in methods) { + local({ + m <- mth + it(paste0("[", m, "]"), { + for (shape_name in names(shapes)) { + img <- shapes[[shape_name]]$img + n <- shapes[[shape_name]]$n + expect_identical(count_components(img), n, + label = paste0("count_components(", shape_name, ")")) + sk <- thin(img, method = m) + expect_identical( + count_components(sk), n, + label = paste0("count_components(thin(", shape_name, + ", method = \"", m, "\"))") + ) + } + }) + }) + } +}) + describe("thinning is idempotent", { for (mth in methods) { local({ @@ -369,6 +447,7 @@ describe("shapes with multiple endpoints", { sk <- thin(img, method = m) expect_gt(sum(sk), 0L) expect_lt(sum(sk), sum(img)) + expect_identical(count_components(sk), 1L) }) it(paste0("[", m, "] plus-shape collapses to a connected skeleton"), { img <- matrix(0L, nrow = 11, ncol = 11) @@ -377,15 +456,18 @@ describe("shapes with multiple endpoints", { sk <- thin(img, method = m) expect_gt(sum(sk), 0L) expect_lt(sum(sk), sum(img)) + expect_identical(count_components(sk), 1L) }) it(paste0("[", m, "] disconnected components stay disconnected"), { img <- matrix(0L, nrow = 9, ncol = 11) img[2:4, 2:4] <- 1L img[6:8, 8:10] <- 1L sk <- thin(img, method = m) - # Each component has at least one surviving foreground pixel. + # Each component has at least one surviving foreground pixel, + # and the two components neither merge nor vanish. expect_gt(sum(sk[1:5, 1:6]), 0L) expect_gt(sum(sk[5:9, 6:11]), 0L) + expect_identical(count_components(sk), 2L) }) }) } @@ -394,23 +476,6 @@ describe("shapes with multiple endpoints", { describe("complex shapes do not crash and yield smaller skeletons", { # Larger / more varied shapes drive iterations through more branches # of the underlying algorithms. - build_ring <- function(nrow, ncol, radius, cr, cc, thickness = 1) { - img <- matrix(0L, nrow = nrow, ncol = ncol) - for (angle in seq(0, 2 * pi, length.out = 200)) { - r <- round(cr + radius * cos(angle)) - c <- round(cc + radius * sin(angle)) - for (dr in -thickness:thickness) { - for (dc in -thickness:thickness) { - rr <- r + dr - cc2 <- c + dc - if (rr >= 1 && rr <= nrow && cc2 >= 1 && cc2 <= ncol) { - img[rr, cc2] <- 1L - } - } - } - } - img - } for (mth in methods) { local({ m <- mth From fd71578df2199adfb2789a5f75fe3598f0b45d54 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 4 Jul 2026 10:25:19 +0000 Subject: [PATCH 6/9] Document review correctness fixes: roxygen, vignette, NEWS The 2026-07-03 review fixes (F011/F012/F015/F016) changed user-visible behaviour but shipped without documentation. Document them at the source: - thin(): new @section Edge handling (border padding, F012) and @section Connectivity (one-component-in/out, 2px strokes stay connected, F011); NA now documented as rejected, not "not accepted". Dropped the inline "matches EBImage::thinImage" aside from the method list (the drop-in claim lives on thinImage(); see note below). - distance_transform(): document fully-foreground -> Inf across all metrics (F015) and that the frame is not an implicit background border; NA rejected loudly (F016). - New vignette("correctness-properties"): states the edge-handling, connectivity, empty-background, and invalid-input guarantees, why they follow from (not depart from) the published methods, and pins each with runnable examples. These properties are general across all seven methods, so they are documented once rather than per method. Explicit that F011 RESTORES SPTA fidelity rather than extending it -- none of the fixes is a novel algorithm. - NEWS.md: development-version section for all five review findings. - _pkgdown.yml: register the new article. Full suite green (512 pass). Follow-up for the maintainer: EBImage 4.54.0 exposes no thinImage(), so the package-wide "drop-in replacement for EBImage::thinImage()" positioning (README, ARCHITECTURE, thin_image.R) needs a maintainer decision and is left untouched here. Co-Authored-By: Claude Fable 5 --- NEWS.md | 33 +++++ R/distance_transform.R | 10 +- R/thin.R | 36 +++-- _pkgdown.yml | 1 + man/distance_transform.Rd | 10 +- man/thin.Rd | 40 +++-- vignettes/correctness-properties.Rmd | 212 +++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 22 deletions(-) create mode 100644 vignettes/correctness-properties.Rmd diff --git a/NEWS.md b/NEWS.md index bb852ca..d78e479 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,36 @@ +# thinr (development version) + +Correctness fixes from the figureextract ecosystem review (2026-07-03). +All are boundary conditions the published algorithms assume away; see the +new `vignette("correctness-properties")` for the guarantees they restore. + +* `thin()` now skeletonises shapes that touch the matrix border correctly. + The kernels inspect an 8-neighbourhood and so never deleted pixels in the + outermost row or column, leaving edge-touching shapes two to three pixels + thick; `thin()` now pads the image with a one-pixel background margin and + crops it back, so a shape thins identically whether or not it touches the + frame. Applies to all seven methods (#F012). + +* `thin(method = "opta")` now keeps two-pixel-wide strokes connected. The + SPTA kernel evaluated all four contour directions against the pre-cycle + snapshot and deleted them in one batch, so both sides of a 2px stroke were + removed together and the skeleton fragmented (a 2×9 bar collapsed to its + four corner pixels). Deletion is now sequential per direction, as + Naccache & Shinghal's two-scan formulation requires (#F011). + +* `distance_transform(metric = "manhattan" | "chessboard")` now returns + `Inf` for a fully-foreground image (no background pixel exists), matching + the `"euclidean"` metric and `EBImage::distmap()`. The two-pass metrics + previously seeded foreground with a finite sentinel that leaked out as a + plausible-looking but meaningless finite distance (#F015). + +* `thin()`, `distance_transform()`, and `medial_axis()` now reject `NA` + input with a clear error at the coercion boundary instead of silently + turning it into `NA_integer_` (`INT_MIN`) in the C++ kernels (#F016). + +* Added a connectivity-preservation property test across all seven methods + and tightened the Holt straight-line test (#F013). + # thinr 0.2.0 Initial CRAN release. diff --git a/R/distance_transform.R b/R/distance_transform.R index 422e909..d59290b 100644 --- a/R/distance_transform.R +++ b/R/distance_transform.R @@ -5,7 +5,8 @@ #' #' @param image A binary image: a matrix where non-zero values are #' foreground and zero values are background. Logical, integer, and -#' numeric inputs are accepted; `NA` values are not. +#' numeric inputs are accepted. `NA` values are rejected with an error +#' rather than silently coerced. #' @param metric Distance metric. One of: #' * `"euclidean"` (default) — exact L2 distance, via #' Felzenszwalb & Huttenlocher (2012) linear-time separable @@ -17,7 +18,12 @@ #' #' @return A numeric matrix of the same shape as `image`. Background #' pixels are 0; foreground pixels carry their distance to the -#' nearest background pixel. +#' nearest background pixel. When the image is entirely foreground +#' (no background pixel exists) every pixel is `Inf`, consistently +#' across all three metrics — the distance to a non-existent nearest +#' background pixel is unbounded, matching `EBImage::distmap()`. Only +#' pixels count as background; the region outside the matrix is not +#' treated as an implicit background border. #' #' @examples #' # A 5x5 image with a single background pixel in the corner. diff --git a/R/thin.R b/R/thin.R index 3eb2ab7..d0279f9 100644 --- a/R/thin.R +++ b/R/thin.R @@ -5,15 +5,17 @@ #' #' @param image A binary image: a matrix or array where non-zero values #' are foreground and zero values are background. Logical, integer, and -#' numeric inputs are all accepted; `NA` values are not. The image is -#' treated as a 2-D matrix; arrays with more than two dimensions are -#' not yet supported. -#' @param method Algorithm to use. One of `"zhang_suen"` (default, -#' matches `EBImage::thinImage`), `"guo_hall"`, `"lee"` (2-D -#' adaptation of Lee, Kashyap & Chu 1994), `"k3m"` (Saeed et al. -#' 2010), `"hilditch"` (Hilditch 1969), `"opta"` (Naccache & -#' Shinghal 1984), or `"holt"` (Holt et al. 1987). -#' See `vignette("choosing-a-method")` for guidance on which to pick. +#' numeric inputs are all accepted. `NA` values are rejected with an +#' error rather than silently coerced (a background pixel would change +#' the skeleton). The image is treated as a 2-D matrix; arrays with +#' more than two dimensions are not yet supported. +#' @param method Algorithm to use. One of `"zhang_suen"` (default), +#' `"guo_hall"`, `"lee"` (2-D adaptation of Lee, Kashyap & Chu 1994), +#' `"k3m"` (Saeed et al. 2010), `"hilditch"` (Hilditch 1969), `"opta"` +#' (Naccache & Shinghal 1984), or `"holt"` (Holt et al. 1987). +#' See `vignette("choosing-a-method")` for guidance on which to pick, +#' and `vignette("correctness-properties")` for the edge-handling and +#' connectivity guarantees shared by every method. #' @param max_iter Maximum number of passes. Default 1000. Real binary #' images of typical sizes converge well under 50 passes; the limit is #' a safety bound against pathological inputs. @@ -22,6 +24,22 @@ #' foreground pixels marking the thinned skeleton and the rest set to #' background. #' +#' @section Edge handling: +#' Every kernel inspects an 8-neighbourhood, so a naive implementation +#' can never delete a pixel in the outermost row or column and leaves +#' shapes that touch the matrix border two or three pixels thick. `thin()` +#' therefore surrounds the image with a one-pixel background margin before +#' thinning and crops it back afterwards, so a shape is skeletonised +#' identically whether or not it touches the frame. This applies uniformly +#' to all seven methods. +#' +#' @section Connectivity: +#' Thinning only ever deletes foreground pixels, and each method deletes +#' in an order that keeps 8-connected components connected: an object that +#' starts as one connected component thins to one connected component. In +#' particular a two-pixel-wide stroke thins to a connected one-pixel line +#' rather than fragmenting. See `vignette("correctness-properties")`. +#' #' @examples #' # A 3x3 solid square thins to a single foreground pixel. #' m <- matrix(c(0, 0, 0, 0, 0, diff --git a/_pkgdown.yml b/_pkgdown.yml index e644f7e..8395c29 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -22,6 +22,7 @@ articles: - title: "Guides" contents: - choosing-a-method + - correctness-properties navbar: structure: diff --git a/man/distance_transform.Rd b/man/distance_transform.Rd index 1a614c2..83fb9f7 100644 --- a/man/distance_transform.Rd +++ b/man/distance_transform.Rd @@ -9,7 +9,8 @@ distance_transform(image, metric = c("euclidean", "manhattan", "chessboard")) \arguments{ \item{image}{A binary image: a matrix where non-zero values are foreground and zero values are background. Logical, integer, and -numeric inputs are accepted; \code{NA} values are not.} +numeric inputs are accepted. \code{NA} values are rejected with an error +rather than silently coerced.} \item{metric}{Distance metric. One of: \itemize{ @@ -25,7 +26,12 @@ two-pass sweep with 8-connected propagation. \value{ A numeric matrix of the same shape as \code{image}. Background pixels are 0; foreground pixels carry their distance to the -nearest background pixel. +nearest background pixel. When the image is entirely foreground +(no background pixel exists) every pixel is \code{Inf}, consistently +across all three metrics — the distance to a non-existent nearest +background pixel is unbounded, matching \code{EBImage::distmap()}. Only +pixels count as background; the region outside the matrix is not +treated as an implicit background border. } \description{ Compute the distance from each foreground pixel to the nearest diff --git a/man/thin.Rd b/man/thin.Rd index 90f0e5f..b0b5666 100644 --- a/man/thin.Rd +++ b/man/thin.Rd @@ -13,16 +13,18 @@ thin( \arguments{ \item{image}{A binary image: a matrix or array where non-zero values are foreground and zero values are background. Logical, integer, and -numeric inputs are all accepted; \code{NA} values are not. The image is -treated as a 2-D matrix; arrays with more than two dimensions are -not yet supported.} +numeric inputs are all accepted. \code{NA} values are rejected with an +error rather than silently coerced (a background pixel would change +the skeleton). The image is treated as a 2-D matrix; arrays with +more than two dimensions are not yet supported.} -\item{method}{Algorithm to use. One of \code{"zhang_suen"} (default, -matches \code{EBImage::thinImage}), \code{"guo_hall"}, \code{"lee"} (2-D -adaptation of Lee, Kashyap & Chu 1994), \code{"k3m"} (Saeed et al. -2010), \code{"hilditch"} (Hilditch 1969), \code{"opta"} (Naccache & -Shinghal 1984), or \code{"holt"} (Holt et al. 1987). -See \code{vignette("choosing-a-method")} for guidance on which to pick.} +\item{method}{Algorithm to use. One of \code{"zhang_suen"} (default), +\code{"guo_hall"}, \code{"lee"} (2-D adaptation of Lee, Kashyap & Chu 1994), +\code{"k3m"} (Saeed et al. 2010), \code{"hilditch"} (Hilditch 1969), \code{"opta"} +(Naccache & Shinghal 1984), or \code{"holt"} (Holt et al. 1987). +See \code{vignette("choosing-a-method")} for guidance on which to pick, +and \code{vignette("correctness-properties")} for the edge-handling and +connectivity guarantees shared by every method.} \item{max_iter}{Maximum number of passes. Default 1000. Real binary images of typical sizes converge well under 50 passes; the limit is @@ -37,6 +39,26 @@ background. Reduce a binary image to its one-pixel-wide skeleton using one of the supported thinning algorithms. } +\section{Edge handling}{ + +Every kernel inspects an 8-neighbourhood, so a naive implementation +can never delete a pixel in the outermost row or column and leaves +shapes that touch the matrix border two or three pixels thick. \code{thin()} +therefore surrounds the image with a one-pixel background margin before +thinning and crops it back afterwards, so a shape is skeletonised +identically whether or not it touches the frame. This applies uniformly +to all seven methods. +} + +\section{Connectivity}{ + +Thinning only ever deletes foreground pixels, and each method deletes +in an order that keeps 8-connected components connected: an object that +starts as one connected component thins to one connected component. In +particular a two-pixel-wide stroke thins to a connected one-pixel line +rather than fragmenting. See \code{vignette("correctness-properties")}. +} + \examples{ # A 3x3 solid square thins to a single foreground pixel. m <- matrix(c(0, 0, 0, 0, 0, diff --git a/vignettes/correctness-properties.Rmd b/vignettes/correctness-properties.Rmd new file mode 100644 index 0000000..29f67e3 --- /dev/null +++ b/vignettes/correctness-properties.Rmd @@ -0,0 +1,212 @@ +--- +title: "Correctness properties: edge handling, connectivity, and boundary cases" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Correctness properties: edge handling, connectivity, and boundary cases} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +library(thinr) +``` + +## Scope + +The published thinning algorithms — Zhang–Suen, Guo–Hall, OPTA/SPTA, +Hilditch, Holt, Lee, K3M — are defined pixel-locally: each pass classifies +a foreground pixel as deletable from its 8-neighbourhood alone. That local +definition leaves three things unstated that any *implementation* must +nonetheless decide, because real images exercise all three: + +1. what happens to pixels on the image border, where the 8-neighbourhood + spills outside the matrix; +2. whether the deletion *order* within a pass preserves connectivity for + thin (two-pixel-wide) structures; +3. what a distance transform reports when there is no background pixel to + measure to. + +None of these is a new algorithm. They are the boundary conditions the +papers assume away, and getting them wrong produces skeletons that are +locally plausible but globally wrong. This vignette states the guarantees +`thinr` makes, why they follow from the methods rather than departing from +them, and how each is tested. They hold **uniformly across all seven +thinning methods** (and, for §3, across all three distance metrics), so +they are documented once here rather than per method. + +This is a note on implementation correctness, not a new method. Where an +earlier version of `thinr` deviated from a published algorithm, the +guarantees below describe the *restoration* of the paper's behaviour, not +an extension of it. + +## 1. Edge handling: shapes that touch the frame + +Every kernel reads an 8-neighbourhood, so a border pixel has neighbours +that lie outside the matrix. The simplest implementation — iterate over +interior pixels `2..(n-1)` only — never even considers a border pixel for +deletion. A shape that touches the frame then keeps a two- or +three-pixel-thick ridge along that edge, silently violating the +one-pixel-wide contract that is the entire point of thinning. + +The papers do not intend the frame to armour the object: they are written +for a shape drawn on an unbounded background. `thinr` reproduces that by +surrounding the image with a one-pixel background margin before thinning +and cropping it back afterward, so a shape is skeletonised **identically +whether or not it touches the border**. + +```{r edge} +# A 3-pixel-thick horizontal bar flush against the top edge. +bar_top <- matrix(0L, nrow = 5, ncol = 9) +bar_top[1:3, ] <- 1L + +# The same bar one row in from the edge. +bar_interior <- matrix(0L, nrow = 5, ncol = 9) +bar_interior[2:4, ] <- 1L + +# Both thin to a single-pixel line, and to the same number of pixels. +sum(thin(bar_top)) +sum(thin(bar_interior)) +``` + +Because thinning only removes pixels, an image supplied with a one-pixel +background margin must come back with that margin still clear — a property +that is cheap to assert for every method: + +```{r edge-test} +methods <- c("zhang_suen", "guo_hall", "lee", "k3m", + "hilditch", "opta", "holt") +vapply(methods, function(m) { + out <- thin(bar_top, method = m) + # No foreground survives more than one pixel thick on the top edge. + max(colSums(out[1:2, , drop = FALSE])) <= 1L +}, logical(1)) +``` + +## 2. Connectivity: thin strokes must not fragment + +A thinning pass may delete many pixels, and it must not delete so many +that a connected object falls apart. The classic failure is a +two-pixel-wide stroke: if both edges of the stroke are judged deletable +against the *same* pre-pass snapshot and removed together, the middle +vanishes and the stroke collapses into disconnected fragments. + +The parallel algorithms avoid this with sub-iterations (Zhang–Suen and +Guo–Hall alternate two passes with complementary conditions), and the +sequential algorithms avoid it by making each deletion visible before the +opposing side is tested. OPTA/SPTA is the sequential case: Naccache & +Shinghal's formulation performs two raster scans per cycle precisely so +that removing one side of a stroke is seen before the other side is +considered. An implementation that instead evaluates all four contour +directions against one frozen snapshot and deletes in a single batch is +faster to write but *is not the published algorithm* — and it fragments +exactly the two-pixel strokes the method was designed to preserve. + +`thinr`'s guarantee is the one the methods provide: **an object that +starts as a single 8-connected component thins to a single 8-connected +component.** A two-pixel-wide bar thins to a connected one-pixel line. + +```{r connectivity} +# 8-connected component count. +n_components <- function(m) { + m <- m != 0 + lab <- matrix(0L, nrow(m), ncol(m)) + k <- 0L + for (i in seq_len(nrow(m))) for (j in seq_len(ncol(m))) { + if (m[i, j] && lab[i, j] == 0L) { + k <- k + 1L + stack <- list(c(i, j)); lab[i, j] <- k + while (length(stack)) { + p <- stack[[length(stack)]]; stack[[length(stack)]] <- NULL + for (di in -1:1) for (dj in -1:1) { + r <- p[1] + di; cc <- p[2] + dj + if (r >= 1 && r <= nrow(m) && cc >= 1 && cc <= ncol(m) && + m[r, cc] && lab[r, cc] == 0L) { + lab[r, cc] <- k; stack[[length(stack) + 1]] <- c(r, cc) + } + } + } + } + } + k +} + +# A 2-pixel-thick, 9-pixel-long bar: one component in, one component out. +bar2 <- matrix(0L, nrow = 6, ncol = 13) +bar2[3:4, 3:11] <- 1L +vapply(methods, function(m) n_components(thin(bar2, method = m)), + integer(1)) +``` + +Every method returns `1`: the stroke stays connected. (Before this +guarantee was enforced, OPTA returned the four corner pixels of the bar — +four disconnected components — a good example of a locally-valid, +globally-broken skeleton.) + +## 3. Distance transform with no background pixel + +`distance_transform()` reports, for each foreground pixel, the distance to +the nearest background pixel. When the image is *entirely* foreground there +is no such pixel, and the only consistent answer is that the distance is +unbounded. All three metrics return `Inf`: + +```{r dt-inf} +solid <- matrix(1L, nrow = 4, ncol = 4) +vapply(c("euclidean", "manhattan", "chessboard"), + function(mt) unique(as.vector(distance_transform(solid, metric = mt))), + numeric(1)) +``` + +A finite sentinel here (for example "one more than the image diagonal") +would be indistinguishable from a genuine large distance and would poison +any downstream comparison or medial-axis threshold. `Inf` is both the +mathematically correct value and what `EBImage::distmap()` returns, so the +two are interchangeable on this boundary case. Note also that only pixels +count as background: the region outside the matrix is **not** an implicit +background border, so a fully-foreground image does not measure distance to +the frame. + +## 4. Invalid input fails loudly + +Thinning and distance transforms are defined on `{0, 1}` images. An `NA` +is neither foreground nor background, and silently coercing it (to `0`, +say) would change the skeleton or the distance field without warning. +`thinr` rejects `NA` input with an error at the coercion boundary rather +than guessing: + +```{r na, error = TRUE} +m <- matrix(1L, nrow = 3, ncol = 3) +m[2, 2] <- NA +thin(m) +``` + +This matches `EBImage`'s own contract for `distmap()` +(`'x' shouldn't contain any NAs`). + +## Summary + +| Property | Guarantee | Applies to | +|---|---|---| +| Edge handling | shapes touching the frame thin as if interior | all 7 thinning methods | +| Connectivity | one component in → one component out; 2px strokes stay connected | all 7 thinning methods | +| Empty background | fully-foreground image → `Inf` everywhere | all 3 distance metrics | +| Invalid input | `NA` rejected with an error, never coerced | `thin()`, `distance_transform()`, `medial_axis()` | + +These are correctness boundary conditions, uniform across the toolkit, +consistent with the published methods, and each pinned by a test in the +package suite. + +## References + +Naccache, N. J., & Shinghal, R. (1984). SPTA: A proposed algorithm for +thinning binary patterns. *IEEE Transactions on Systems, Man, and +Cybernetics*, SMC-14(3), 409–418. + +Zhang, T. Y., & Suen, C. Y. (1984). A fast parallel algorithm for thinning +digital patterns. *Communications of the ACM*, 27(3), 236–239. + +Felzenszwalb, P. F., & Huttenlocher, D. P. (2012). Distance transforms of +sampled functions. *Theory of Computing*, 8(19), 415–428. From f338bc81ea078123e23a0c11b232f148f05303ba Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 4 Jul 2026 10:50:13 +0000 Subject: [PATCH 7/9] Correct EBImage::thinImage() references: no such function exists The package described itself throughout as a "drop-in replacement for EBImage::thinImage()", but EBImage exposes no thinImage() (verified against EBImage 4.54.0: not exported, not internal, no man page; its morphology is dilate/erode/distmap/watershed only). The claim was factually false and, in DESCRIPTION and cran-comments.md, would have misrepresented the package to CRAN. Reframe accurately without changing behaviour: EBImage provides binary morphology but no thinning operator, so thinr *fills that gap* rather than replacing an EBImage function. thinImage() is retained and redocumented as what it actually is -- a convenience alias for thin(method = "zhang_suen"). The LGPL-3 rationale is corrected from "so EBImage can retire its in-tree thinning code" (there is none) to license-compatibility so an EBImage pipeline can gain thinning from thinr. Touches roxygen (thin_image.R, thinr-package.R + regenerated man/), DESCRIPTION, README, ARCHITECTURE, CLAUDE, cran-comments, and the choosing-a-method vignette. Distmap references are unchanged (that function does exist and behaves as documented). Suite green (512). Open for the maintainer: whether the camelCase thinImage() name and the ADR-007 "EBImage can depend on thinr" framing still earn their place now that the drop-in premise is gone. Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 4 ++-- CLAUDE.md | 4 ++-- DESCRIPTION | 8 ++++---- R/thin_image.R | 13 +++++-------- R/thinr-package.R | 14 ++++++++------ README.md | 10 +++++----- cran-comments.md | 6 ++++-- man/thinImage.Rd | 14 +++++--------- man/thinr-package.Rd | 14 ++++++++------ vignettes/choosing-a-method.Rmd | 4 ++-- 10 files changed, 45 insertions(+), 46 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2281d5a..e463e36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ ## Purpose -Implement multiple binary image thinning algorithms behind a single dispatching API. Drop-in replacement for `EBImage::thinImage()`. Designed to be small, focused, CRAN-quality, and `EBImage`-license-compatible (LGPL-3). +Implement multiple binary image thinning algorithms behind a single dispatching API. Fills the thinning / skeletonization gap that `EBImage` (binary morphology, but no thinning operator) leaves in the R image-processing stack. Designed to be small, focused, CRAN-quality, and `EBImage`-license-compatible (LGPL-3). ## Pipeline shape @@ -39,7 +39,7 @@ The dispatcher (`thin()`) selects the C++ implementation by name and handles the 2. **API stability.** `thin()` and `thinImage()` are the public surface. Any signature change is a major version bump. -3. **Drop-in compatibility.** `thinImage(x)` must return the same shape and storage mode as `EBImage::thinImage(x)` for the same input. The default algorithm is therefore locked to Zhang-Suen. +3. **`thinImage()` alias stability.** `thinImage(x)` is a convenience alias for `thin(x, method = "zhang_suen")` and must stay equivalent to it: same shape and storage mode out as in. The default algorithm is locked to Zhang-Suen. 4. **Stubs error, don't silently fall back.** Lee and K3M throw with a message that names the planned version. Silent fall-backs to Zhang-Suen would hide the limitation. diff --git a/CLAUDE.md b/CLAUDE.md index 452082b..508c6a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## Package responsibility -`thinr` provides binary image thinning (skeletonization) algorithms — Zhang-Suen, Guo-Hall, Lee (2-D), K3M, the parallel form commonly attributed to Hilditch, OPTA / SPTA, and Holt — behind a single dispatching API. Also provides the medial axis transform (Blum 1967) and a fast distance transform (Felzenszwalb-Huttenlocher 2012; classic two-pass sweep). `thinImage()` is a signature-compatible drop-in for `EBImage::thinImage()` so callers can switch by changing the namespace prefix only. Per ADR-007 this is the **one public CRAN package** in the figureextract ecosystem; LGPL-3 is chosen for EBImage compatibility. +`thinr` provides binary image thinning (skeletonization) algorithms — Zhang-Suen, Guo-Hall, Lee (2-D), K3M, the parallel form commonly attributed to Hilditch, OPTA / SPTA, and Holt — behind a single dispatching API. Also provides the medial axis transform (Blum 1967) and a fast distance transform (Felzenszwalb-Huttenlocher 2012; classic two-pass sweep). `thinImage()` is a convenience alias for the Zhang-Suen default. `EBImage` provides binary morphology but no thinning operator, so `thinr` complements it rather than replacing any of its functions. Per ADR-007 this is the **one public CRAN package** in the figureextract ecosystem; LGPL-3 is chosen for EBImage compatibility. ## Current state @@ -55,7 +55,7 @@ C++ sources in `src/`: R sources in `R/`: - `thin.R` — `thin()` dispatching function and the `as_binary_matrix()` / `restore_storage()` coercion helpers. -- `thin_image.R` — `thinImage()` drop-in. +- `thin_image.R` — `thinImage()` convenience alias for the Zhang-Suen default. - `distance_transform.R` — exported wrapper for `.distance_transform_cpp`. - `medial_axis.R` — exported wrapper for `.medial_axis_cpp`. - `thinr-package.R` — package-level Roxygen doc. diff --git a/DESCRIPTION b/DESCRIPTION index 461dad4..e4f4d68 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -19,10 +19,10 @@ Description: Thinning (skeletonization) algorithms for binary raster (1987) . Also provides the medial axis transform (Blum 1967) and a distance transform implementation following Felzenszwalb and Huttenlocher (2012) - . The drop-in thinImage() matches - the signature of thinImage() in the 'EBImage' package on - Bioconductor so existing code can switch parsers without changes. - The wider thin() API selects the algorithm by name. + . The thin() API selects the + algorithm by name; thinImage() is a convenience wrapper for the + Zhang-Suen default. Complements the morphology in the 'EBImage' + package, which does not provide a thinning operator. License: LGPL-3 Encoding: UTF-8 Depends: diff --git a/R/thin_image.R b/R/thin_image.R index 8080ddd..038e6c7 100644 --- a/R/thin_image.R +++ b/R/thin_image.R @@ -1,12 +1,9 @@ -#' Drop-in replacement for `EBImage::thinImage` +#' Thin a binary image with the default Zhang-Suen algorithm #' -#' Applies Zhang-Suen thinning to a binary image. Provided as a -#' signature-compatible alternative to `EBImage::thinImage()` so callers -#' can switch from `EBImage` to `thinr` by changing the namespace prefix -#' only. -#' -#' For access to the other algorithms (Guo-Hall, and eventually Lee / -#' K3M), use [thin()]. +#' A convenience wrapper for `thin(x, method = "zhang_suen")`, provided +#' under a short single-argument name for the common case. Use [thin()] +#' directly to select any of the other algorithms (Guo-Hall, Lee, K3M, +#' Hilditch, OPTA, Holt). #' #' @param x A binary image. Same constraints as [thin()]'s `image` #' argument. diff --git a/R/thinr-package.R b/R/thinr-package.R index 293d3f8..1c2b92f 100644 --- a/R/thinr-package.R +++ b/R/thinr-package.R @@ -9,8 +9,7 @@ #' @section Thinning algorithms (`thin(method = ...)`): #' #' - `zhang_suen` — Zhang & Suen (1984) -#' \doi{10.1145/357994.358023}. Default; matches -#' `EBImage::thinImage`. +#' \doi{10.1145/357994.358023}. Default. #' - `guo_hall` — Guo & Hall (1989) \doi{10.1145/62065.62074}. Often #' better corner preservation on diagonal features. #' - `lee` — Lee, Kashyap & Chu (1994) \doi{10.1006/cgip.1994.1042}, @@ -38,11 +37,14 @@ #' 2012, linear-time separable), Manhattan, or Chessboard distance #' from each foreground pixel to the nearest background pixel. #' -#' @section Drop-in compatibility: +#' @section Relationship to EBImage: #' -#' [thinImage()] matches the signature of `EBImage::thinImage()`. Code -#' that uses `EBImage::thinImage` can switch to `thinr::thinImage` with -#' no other changes. +#' `EBImage`, the dominant image-processing package in R/Bioconductor, +#' supplies binary morphology (`dilate()`, `erode()`, `distmap()`, +#' `watershed()`) but no thinning / skeletonization operator. `thinr` +#' fills that gap with a small, dependency-light, LGPL-3 package (the +#' same licence as `EBImage`). [thinImage()] is a convenience alias for +#' the Zhang-Suen default. #' #' @keywords internal #' @useDynLib thinr, .registration = TRUE diff --git a/README.md b/README.md index c040846..e45293b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![lint](https://github.com/humanpred/thinr/actions/workflows/lint.yaml/badge.svg)](https://github.com/humanpred/thinr/actions/workflows/lint.yaml) -Binary image thinning (skeletonization) algorithms for R, plus the medial axis transform and a fast Euclidean / Manhattan / Chessboard distance transform. Designed as a drop-in replacement for `EBImage::thinImage()` with six additional thinning algorithms behind a single dispatching function. +Binary image thinning (skeletonization) algorithms for R, plus the medial axis transform and a fast Euclidean / Manhattan / Chessboard distance transform. Seven thinning algorithms sit behind a single dispatching function. `EBImage`, the dominant R/Bioconductor image toolkit, provides binary morphology (`dilate`, `erode`, `distmap`, `watershed`) but no thinning operator; `thinr` fills that gap in a small, dependency-light package. ## Installation @@ -28,7 +28,7 @@ library(thinr) m <- matrix(0L, 11, 11) m[3:9, 3:9] <- 1L # 7x7 solid square -# Default: Zhang-Suen (matches EBImage::thinImage) +# Default: Zhang-Suen thin(m) # Or pick an algorithm explicitly @@ -36,7 +36,7 @@ thin(m, method = "guo_hall") thin(m, method = "hilditch") thin(m, method = "holt") -# Drop-in for EBImage::thinImage() +# thinImage() is a convenience alias for the Zhang-Suen default thinImage(m) # Medial axis transform (returns binary skeleton + per-pixel width) @@ -53,7 +53,7 @@ distance_transform(m, metric = "chessboard") | Method | Reference | |---------------|-----------| -| `zhang_suen` | Zhang, T. Y. & Suen, C. Y. (1984). A fast parallel algorithm for thinning digital patterns. *Communications of the ACM*, 27(3), 236–239. [doi:10.1145/357994.358023](https://doi.org/10.1145/357994.358023). *Default; matches `EBImage::thinImage()`.* | +| `zhang_suen` | Zhang, T. Y. & Suen, C. Y. (1984). A fast parallel algorithm for thinning digital patterns. *Communications of the ACM*, 27(3), 236–239. [doi:10.1145/357994.358023](https://doi.org/10.1145/357994.358023). *Default.* | | `guo_hall` | Guo, Z. & Hall, R. W. (1989). Parallel thinning with two-subiteration algorithms. *Communications of the ACM*, 32(3), 359–373. [doi:10.1145/62065.62074](https://doi.org/10.1145/62065.62074). | | `lee` | Lee, T.-C., Kashyap, R. L. & Chu, C.-N. (1994). Building skeleton models via 3-D medial surface axis thinning algorithms. *CVGIP: Graphical Models and Image Processing*, 56(6), 462–478. [doi:10.1006/cgip.1994.1042](https://doi.org/10.1006/cgip.1994.1042). 2-D adaptation; the 3-D form is not implemented. | | `k3m` | Saeed, K., Tabędzki, M., Rybnik, M. & Adamski, M. (2010). K3M: A universal algorithm for image skeletonization and a review of thinning techniques. *International Journal of Applied Mathematics and Computer Science*, 20(2), 317–335. [doi:10.2478/v10006-010-0024-4](https://doi.org/10.2478/v10006-010-0024-4). Lookup tables `A_0…A_5` reproduced from the paper. | @@ -74,4 +74,4 @@ See `vignette("choosing-a-method")` for guidance on choosing among the methods. ## License -LGPL-3. Chosen for drop-in compatibility with `EBImage` (which is LGPL) so that `EBImage` can optionally depend on `thinr` and retire its in-tree thinning code. +LGPL-3, the same licence as `EBImage`, so that an `EBImage`-based pipeline (or `EBImage` itself) can depend on `thinr` for the thinning operator `EBImage` does not provide, without licence friction. diff --git a/cran-comments.md b/cran-comments.md index f68cf8d..2c21e79 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -121,8 +121,10 @@ public, if ever. ## Notes for the submission reviewer -- `EBImage::thinImage()` provides a Zhang-Suen implementation; - `thinr::thinImage()` is a signature-compatible drop-in. The +- `EBImage` provides binary morphology (`dilate`, `erode`, `distmap`, + `watershed`) but no thinning / skeletonization operator; `thinr` + fills that gap. `thinr::thinImage()` is a convenience alias for the + Zhang-Suen default, not a wrapper of any `EBImage` function. The mention of `EBImage` in the Description field is informational; no `Imports` or `Suggests` link to it. - The K3M lookup tables (`A_0`, `A_1`, ..., `A_5`, `A_1pix`) in diff --git a/man/thinImage.Rd b/man/thinImage.Rd index 8d924d1..c56d656 100644 --- a/man/thinImage.Rd +++ b/man/thinImage.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/thin_image.R \name{thinImage} \alias{thinImage} -\title{Drop-in replacement for \code{EBImage::thinImage}} +\title{Thin a binary image with the default Zhang-Suen algorithm} \usage{ thinImage(x) } @@ -14,14 +14,10 @@ argument.} The thinned skeleton in the same storage mode as \code{x}. } \description{ -Applies Zhang-Suen thinning to a binary image. Provided as a -signature-compatible alternative to \code{EBImage::thinImage()} so callers -can switch from \code{EBImage} to \code{thinr} by changing the namespace prefix -only. -} -\details{ -For access to the other algorithms (Guo-Hall, and eventually Lee / -K3M), use \code{\link[=thin]{thin()}}. +A convenience wrapper for \code{thin(x, method = "zhang_suen")}, provided +under a short single-argument name for the common case. Use \code{\link[=thin]{thin()}} +directly to select any of the other algorithms (Guo-Hall, Lee, K3M, +Hilditch, OPTA, Holt). } \examples{ m <- matrix(c(0, 1, 1, 1, 0, diff --git a/man/thinr-package.Rd b/man/thinr-package.Rd index 573544f..7a4730a 100644 --- a/man/thinr-package.Rd +++ b/man/thinr-package.Rd @@ -16,8 +16,7 @@ fast distance transform. \itemize{ \item \code{zhang_suen} — Zhang & Suen (1984) -\doi{10.1145/357994.358023}. Default; matches -\code{EBImage::thinImage}. +\doi{10.1145/357994.358023}. Default. \item \code{guo_hall} — Guo & Hall (1989) \doi{10.1145/62065.62074}. Often better corner preservation on diagonal features. \item \code{lee} — Lee, Kashyap & Chu (1994) \doi{10.1006/cgip.1994.1042}, @@ -50,12 +49,15 @@ from each foreground pixel to the nearest background pixel. } } -\section{Drop-in compatibility}{ +\section{Relationship to EBImage}{ -\code{\link[=thinImage]{thinImage()}} matches the signature of \code{EBImage::thinImage()}. Code -that uses \code{EBImage::thinImage} can switch to \code{thinr::thinImage} with -no other changes. +\code{EBImage}, the dominant image-processing package in R/Bioconductor, +supplies binary morphology (\code{dilate()}, \code{erode()}, \code{distmap()}, +\code{watershed()}) but no thinning / skeletonization operator. \code{thinr} +fills that gap with a small, dependency-light, LGPL-3 package (the +same licence as \code{EBImage}). \code{\link[=thinImage]{thinImage()}} is a convenience alias for +the Zhang-Suen default. } \seealso{ diff --git a/vignettes/choosing-a-method.Rmd b/vignettes/choosing-a-method.Rmd index b8f74fb..1edb236 100644 --- a/vignettes/choosing-a-method.Rmd +++ b/vignettes/choosing-a-method.Rmd @@ -29,7 +29,7 @@ Thinning reduces a binary shape to a one-pixel-wide centerline that preserves to | `opta` | Naccache & Shinghal (1984) | Safe-point thinning (SPTA) | | `holt` | Holt, Stewart, Clint & Perrott (1987) | One subcycle with edge information on neighbours | -`zhang_suen` is the default and matches `EBImage::thinImage()` behavior. The `thinImage()` wrapper is provided as a drop-in replacement. +`zhang_suen` is the default. The `thinImage()` wrapper is a convenience alias for `thin(method = "zhang_suen")`. `lee` is a 2-D adaptation of Lee's original 3-D algorithm. The 3-D case (volumetric thinning) is not implemented in this release. @@ -84,7 +84,7 @@ The thinning algorithms produce broadly similar skeletons on this V — they all ## When to use which -- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning and for compatibility with code that previously used `EBImage::thinImage()`. +- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning; also available under the `thinImage()` alias. - **`guo_hall`** — try this if your skeletons have lots of diagonal features and Zhang-Suen is breaking them at corners. - **`lee`** — when you want directional processing (four sub-iterations per pass, one per cardinal direction). Sometimes produces cleaner skeletons on asymmetric inputs. - **`k3m`** — strongest corner preservation in published comparative studies, at the cost of being slower (six phases per outer iteration vs. two for Zhang-Suen). From 0be0e5d34a093dacde8993ec80d1b8f336fb4b7b Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 4 Jul 2026 12:05:11 +0000 Subject: [PATCH 8/9] Remove thinImage(): superseded by thin() thinImage() no longer serves a necessary purpose; thin() (default method Zhang-Suen) covers the same use. Drop the function, its manual page, NAMESPACE export, pkgdown reference entry, direct tests, and all documentation references. No backward-compatibility shim is provided. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 8 +++--- CLAUDE.md | 5 ++-- DESCRIPTION | 6 ++--- NAMESPACE | 1 - NEWS.md | 2 ++ R/thin_image.R | 23 ----------------- R/thinr-package.R | 3 +-- README.md | 3 --- _pkgdown.yml | 1 - cran-comments.md | 9 +++---- inst/WORDLIST | 1 - man/thinImage.Rd | 29 ---------------------- man/thinr-package.Rd | 3 +-- tests/testthat/test-thin.R | 44 --------------------------------- vignettes/choosing-a-method.Rmd | 6 ++--- 15 files changed, 19 insertions(+), 125 deletions(-) delete mode 100644 R/thin_image.R delete mode 100644 man/thinImage.Rd diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e463e36..311cada 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,13 +37,11 @@ The dispatcher (`thin()`) selects the C++ implementation by name and handles the 1. **Minimal dependencies.** Rcpp is the only Imports entry. The package is CRAN-targeted and should install cleanly on Linux, macOS, and Windows without system libraries beyond the C++ toolchain. -2. **API stability.** `thin()` and `thinImage()` are the public surface. Any signature change is a major version bump. +2. **API stability.** `thin()` is the public thinning surface. Any signature change is a major version bump. The default algorithm is locked to Zhang-Suen. -3. **`thinImage()` alias stability.** `thinImage(x)` is a convenience alias for `thin(x, method = "zhang_suen")` and must stay equivalent to it: same shape and storage mode out as in. The default algorithm is locked to Zhang-Suen. +3. **Stubs error, don't silently fall back.** Lee and K3M throw with a message that names the planned version. Silent fall-backs to Zhang-Suen would hide the limitation. -4. **Stubs error, don't silently fall back.** Lee and K3M throw with a message that names the planned version. Silent fall-backs to Zhang-Suen would hide the limitation. - -5. **2-D for now.** Higher-dimensional arrays are explicitly rejected. The Lee implementation in v0.2 introduces 3-D support; the dispatcher will route based on `length(dim(image))`. +4. **2-D for now.** Higher-dimensional arrays are explicitly rejected. The Lee implementation in v0.2 introduces 3-D support; the dispatcher will route based on `length(dim(image))`. ## Versioning diff --git a/CLAUDE.md b/CLAUDE.md index 508c6a4..10e74f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## Package responsibility -`thinr` provides binary image thinning (skeletonization) algorithms — Zhang-Suen, Guo-Hall, Lee (2-D), K3M, the parallel form commonly attributed to Hilditch, OPTA / SPTA, and Holt — behind a single dispatching API. Also provides the medial axis transform (Blum 1967) and a fast distance transform (Felzenszwalb-Huttenlocher 2012; classic two-pass sweep). `thinImage()` is a convenience alias for the Zhang-Suen default. `EBImage` provides binary morphology but no thinning operator, so `thinr` complements it rather than replacing any of its functions. Per ADR-007 this is the **one public CRAN package** in the figureextract ecosystem; LGPL-3 is chosen for EBImage compatibility. +`thinr` provides binary image thinning (skeletonization) algorithms — Zhang-Suen, Guo-Hall, Lee (2-D), K3M, the parallel form commonly attributed to Hilditch, OPTA / SPTA, and Holt — behind a single dispatching API. Also provides the medial axis transform (Blum 1967) and a fast distance transform (Felzenszwalb-Huttenlocher 2012; classic two-pass sweep). `EBImage` provides binary morphology but no thinning operator, so `thinr` complements it rather than replacing any of its functions. Per ADR-007 this is the **one public CRAN package** in the figureextract ecosystem; LGPL-3 is chosen for EBImage compatibility. ## Current state @@ -55,7 +55,6 @@ C++ sources in `src/`: R sources in `R/`: - `thin.R` — `thin()` dispatching function and the `as_binary_matrix()` / `restore_storage()` coercion helpers. -- `thin_image.R` — `thinImage()` convenience alias for the Zhang-Suen default. - `distance_transform.R` — exported wrapper for `.distance_transform_cpp`. - `medial_axis.R` — exported wrapper for `.medial_axis_cpp`. - `thinr-package.R` — package-level Roxygen doc. @@ -63,7 +62,7 @@ R sources in `R/`: ## Public API surface -- Exported: `thin()`, `thinImage()`, `medial_axis()`, `distance_transform()`. +- Exported: `thin()`, `medial_axis()`, `distance_transform()`. - Internal: `as_binary_matrix()`, `restore_storage()` (helpers; not exported). ## Extension points diff --git a/DESCRIPTION b/DESCRIPTION index e4f4d68..11ee6e7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -20,9 +20,9 @@ Description: Thinning (skeletonization) algorithms for binary raster transform (Blum 1967) and a distance transform implementation following Felzenszwalb and Huttenlocher (2012) . The thin() API selects the - algorithm by name; thinImage() is a convenience wrapper for the - Zhang-Suen default. Complements the morphology in the 'EBImage' - package, which does not provide a thinning operator. + algorithm by name, defaulting to Zhang-Suen. Complements the + morphology in the 'EBImage' package, which does not provide a + thinning operator. License: LGPL-3 Encoding: UTF-8 Depends: diff --git a/NAMESPACE b/NAMESPACE index d1f3f6b..1b9e7d8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,5 @@ export(distance_transform) export(medial_axis) export(thin) -export(thinImage) importFrom(Rcpp,sourceCpp) useDynLib(thinr, .registration = TRUE) diff --git a/NEWS.md b/NEWS.md index d78e479..1b2e430 100644 --- a/NEWS.md +++ b/NEWS.md @@ -31,6 +31,8 @@ new `vignette("correctness-properties")` for the guarantees they restore. * Added a connectivity-preservation property test across all seven methods and tightened the Holt straight-line test (#F013). +* Removed `thinImage()`. Use `thin()` (Zhang-Suen is the default method). + # thinr 0.2.0 Initial CRAN release. diff --git a/R/thin_image.R b/R/thin_image.R deleted file mode 100644 index 038e6c7..0000000 --- a/R/thin_image.R +++ /dev/null @@ -1,23 +0,0 @@ -#' Thin a binary image with the default Zhang-Suen algorithm -#' -#' A convenience wrapper for `thin(x, method = "zhang_suen")`, provided -#' under a short single-argument name for the common case. Use [thin()] -#' directly to select any of the other algorithms (Guo-Hall, Lee, K3M, -#' Hilditch, OPTA, Holt). -#' -#' @param x A binary image. Same constraints as [thin()]'s `image` -#' argument. -#' -#' @return The thinned skeleton in the same storage mode as `x`. -#' -#' @examples -#' m <- matrix(c(0, 1, 1, 1, 0, -#' 0, 1, 1, 1, 0, -#' 0, 1, 1, 1, 0), -#' nrow = 3, byrow = TRUE) -#' thinImage(m) -#' -#' @export -thinImage <- function(x) { - thin(x, method = "zhang_suen") -} diff --git a/R/thinr-package.R b/R/thinr-package.R index 1c2b92f..1ca25ad 100644 --- a/R/thinr-package.R +++ b/R/thinr-package.R @@ -43,8 +43,7 @@ #' supplies binary morphology (`dilate()`, `erode()`, `distmap()`, #' `watershed()`) but no thinning / skeletonization operator. `thinr` #' fills that gap with a small, dependency-light, LGPL-3 package (the -#' same licence as `EBImage`). [thinImage()] is a convenience alias for -#' the Zhang-Suen default. +#' same licence as `EBImage`). #' #' @keywords internal #' @useDynLib thinr, .registration = TRUE diff --git a/README.md b/README.md index e45293b..4feb5bd 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,6 @@ thin(m, method = "guo_hall") thin(m, method = "hilditch") thin(m, method = "holt") -# thinImage() is a convenience alias for the Zhang-Suen default -thinImage(m) - # Medial axis transform (returns binary skeleton + per-pixel width) medial_axis(m) medial_axis(m, return_distance = TRUE) diff --git a/_pkgdown.yml b/_pkgdown.yml index 8395c29..dfce0fa 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -7,7 +7,6 @@ reference: - title: "Thinning" contents: - thin - - thinImage - title: "Medial axis and distance transform" contents: diff --git a/cran-comments.md b/cran-comments.md index 2c21e79..ceb5407 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -13,7 +13,7 @@ feedback on an earlier submission. > -> 'thinImage()' --> thinImage()" **Resolved.** All function-name references in the Description field -appear without single quotes: `thinImage()`, `thin()`. Package names +appear without single quotes: `thin()`. Package names (`'EBImage'`) are kept in single quotes per CRAN convention. ### 2. References for algorithm methods @@ -123,10 +123,9 @@ public, if ever. - `EBImage` provides binary morphology (`dilate`, `erode`, `distmap`, `watershed`) but no thinning / skeletonization operator; `thinr` - fills that gap. `thinr::thinImage()` is a convenience alias for the - Zhang-Suen default, not a wrapper of any `EBImage` function. The - mention of `EBImage` in the Description field is informational; no - `Imports` or `Suggests` link to it. + fills that gap via `thinr::thin()`, which is not a wrapper of any + `EBImage` function. The mention of `EBImage` in the Description + field is informational; no `Imports` or `Suggests` link to it. - The K3M lookup tables (`A_0`, `A_1`, ..., `A_5`, `A_1pix`) in `src/k3m.cpp` are reproduced verbatim from Saeed et al. (2010), Section 3.3, page 327. The paper itself is in diff --git a/inst/WORDLIST b/inst/WORDLIST index 2e5c9cd..36f1aae 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -47,5 +47,4 @@ skeletonization skeletonize subcycle subiteration -thinImage toc diff --git a/man/thinImage.Rd b/man/thinImage.Rd deleted file mode 100644 index c56d656..0000000 --- a/man/thinImage.Rd +++ /dev/null @@ -1,29 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/thin_image.R -\name{thinImage} -\alias{thinImage} -\title{Thin a binary image with the default Zhang-Suen algorithm} -\usage{ -thinImage(x) -} -\arguments{ -\item{x}{A binary image. Same constraints as \code{\link[=thin]{thin()}}'s \code{image} -argument.} -} -\value{ -The thinned skeleton in the same storage mode as \code{x}. -} -\description{ -A convenience wrapper for \code{thin(x, method = "zhang_suen")}, provided -under a short single-argument name for the common case. Use \code{\link[=thin]{thin()}} -directly to select any of the other algorithms (Guo-Hall, Lee, K3M, -Hilditch, OPTA, Holt). -} -\examples{ -m <- matrix(c(0, 1, 1, 1, 0, - 0, 1, 1, 1, 0, - 0, 1, 1, 1, 0), - nrow = 3, byrow = TRUE) -thinImage(m) - -} diff --git a/man/thinr-package.Rd b/man/thinr-package.Rd index 7a4730a..1ea2b1c 100644 --- a/man/thinr-package.Rd +++ b/man/thinr-package.Rd @@ -56,8 +56,7 @@ from each foreground pixel to the nearest background pixel. supplies binary morphology (\code{dilate()}, \code{erode()}, \code{distmap()}, \code{watershed()}) but no thinning / skeletonization operator. \code{thinr} fills that gap with a small, dependency-light, LGPL-3 package (the -same licence as \code{EBImage}). \code{\link[=thinImage]{thinImage()}} is a convenience alias for -the Zhang-Suen default. +same licence as \code{EBImage}). } \seealso{ diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index 5a502d7..5cb0f9c 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -253,14 +253,6 @@ describe("topology is preserved on a small ring (hole stays a hole)", { } }) -describe("thinImage matches thin(method = 'zhang_suen')", { - it("on a solid square", { - img <- matrix(0L, nrow = 9, ncol = 9) - img[3:7, 3:7] <- 1L - expect_equal(thinImage(img), thin(img, method = "zhang_suen")) - }) -}) - describe("input coercion", { it("accepts a logical matrix and returns a logical matrix", { img <- matrix(FALSE, nrow = 5, ncol = 5) @@ -517,21 +509,6 @@ describe("exact skeletons on small known shapes", { expected[3, 3] <- 1L expect_identical(thin(img, method = "guo_hall"), expected) }) - - it("thinImage matches its documented 3x5 example output exactly", { - # The 3x3 block touches the top and bottom matrix edges; it must - # thin to the same single centre pixel as the interior 3x3 block - # above, not keep its edge rows un-thinned. - img <- matrix(c(0, 1, 1, 1, 0, - 0, 1, 1, 1, 0, - 0, 1, 1, 1, 0), - nrow = 3, byrow = TRUE) - expected <- matrix(c(0, 0, 0, 0, 0, - 0, 0, 1, 0, 0, - 0, 0, 0, 0, 0), - nrow = 3, byrow = TRUE) - expect_identical(thinImage(img), expected) - }) }) describe("shapes touching the matrix edge are thinned like interior shapes", { @@ -564,24 +541,3 @@ describe("shapes touching the matrix edge are thinned like interior shapes", { }) } }) - -describe("thinImage drop-in", { - it("accepts a logical matrix", { - img <- matrix(FALSE, nrow = 5, ncol = 5) - img[2:4, 2:4] <- TRUE - sk <- thinImage(img) - expect_type(sk, "logical") - expect_equal(dim(sk), dim(img)) - }) - it("accepts a numeric matrix", { - img <- matrix(0, nrow = 5, ncol = 5) - img[2:4, 2:4] <- 1 - sk <- thinImage(img) - expect_type(sk, "double") - }) - it("matches thin(method = 'zhang_suen') on a logical input", { - img <- matrix(FALSE, nrow = 9, ncol = 9) - img[3:7, 3:7] <- TRUE - expect_equal(thinImage(img), thin(img, method = "zhang_suen")) - }) -}) diff --git a/vignettes/choosing-a-method.Rmd b/vignettes/choosing-a-method.Rmd index 1edb236..c36caa0 100644 --- a/vignettes/choosing-a-method.Rmd +++ b/vignettes/choosing-a-method.Rmd @@ -29,7 +29,7 @@ Thinning reduces a binary shape to a one-pixel-wide centerline that preserves to | `opta` | Naccache & Shinghal (1984) | Safe-point thinning (SPTA) | | `holt` | Holt, Stewart, Clint & Perrott (1987) | One subcycle with edge information on neighbours | -`zhang_suen` is the default. The `thinImage()` wrapper is a convenience alias for `thin(method = "zhang_suen")`. +`zhang_suen` is the default. `lee` is a 2-D adaptation of Lee's original 3-D algorithm. The 3-D case (volumetric thinning) is not implemented in this release. @@ -84,7 +84,7 @@ The thinning algorithms produce broadly similar skeletons on this V — they all ## When to use which -- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning; also available under the `thinImage()` alias. +- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning. - **`guo_hall`** — try this if your skeletons have lots of diagonal features and Zhang-Suen is breaking them at corners. - **`lee`** — when you want directional processing (four sub-iterations per pass, one per cardinal direction). Sometimes produces cleaner skeletons on asymmetric inputs. - **`k3m`** — strongest corner preservation in published comparative studies, at the cost of being slower (six phases per outer iteration vs. two for Zhang-Suen). @@ -126,7 +126,7 @@ The Euclidean implementation uses the linear-time separable algorithm of Felzens ## Inputs and outputs -`thin()`, `medial_axis()`, and `thinImage()` accept logical, integer, and numeric matrices. Non-zero values are foreground. The return matrix matches the storage mode of the input — logical in, logical out; integer in, integer out; numeric in, numeric out. +`thin()` and `medial_axis()` accept logical, integer, and numeric matrices. Non-zero values are foreground. The return matrix matches the storage mode of the input — logical in, logical out; integer in, integer out; numeric in, numeric out. `distance_transform()` always returns a numeric matrix. From 25d64f687cdce192a6e17091f95a6252dedb1c22 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 4 Jul 2026 12:05:20 +0000 Subject: [PATCH 9/9] Fix lintr style lints in correctness-properties vignette Split four compound-semicolon statements onto separate lines and correct one continuation-line indent in the n_components() helper chunk, so lintr::lint_package() is clean. Logic is unchanged. Co-Authored-By: Claude Opus 4.8 --- vignettes/correctness-properties.Rmd | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/vignettes/correctness-properties.Rmd b/vignettes/correctness-properties.Rmd index 29f67e3..0e59958 100644 --- a/vignettes/correctness-properties.Rmd +++ b/vignettes/correctness-properties.Rmd @@ -118,14 +118,18 @@ n_components <- function(m) { for (i in seq_len(nrow(m))) for (j in seq_len(ncol(m))) { if (m[i, j] && lab[i, j] == 0L) { k <- k + 1L - stack <- list(c(i, j)); lab[i, j] <- k + stack <- list(c(i, j)) + lab[i, j] <- k while (length(stack)) { - p <- stack[[length(stack)]]; stack[[length(stack)]] <- NULL + p <- stack[[length(stack)]] + stack[[length(stack)]] <- NULL for (di in -1:1) for (dj in -1:1) { - r <- p[1] + di; cc <- p[2] + dj + r <- p[1] + di + cc <- p[2] + dj if (r >= 1 && r <= nrow(m) && cc >= 1 && cc <= ncol(m) && - m[r, cc] && lab[r, cc] == 0L) { - lab[r, cc] <- k; stack[[length(stack) + 1]] <- c(r, cc) + m[r, cc] && lab[r, cc] == 0L) { + lab[r, cc] <- k + stack[[length(stack) + 1]] <- c(r, cc) } } }