diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2281d5a..311cada 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 @@ -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. **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. **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 452082b..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 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). `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()` drop-in. - `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 461dad4..11ee6e7 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, 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 bb852ca..1b2e430 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,38 @@ +# 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). + +* Removed `thinImage()`. Use `thin()` (Zhang-Suen is the default method). + # thinr 0.2.0 Initial CRAN release. diff --git a/R/distance_transform.R b/R/distance_transform.R index 1929aa0..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. +#' 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/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 1f2aa07..d0279f9 100644 --- a/R/thin.R +++ b/R/thin.R @@ -5,14 +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. 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. @@ -21,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, @@ -40,22 +59,38 @@ 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) } # 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/R/thin_image.R b/R/thin_image.R deleted file mode 100644 index 8080ddd..0000000 --- a/R/thin_image.R +++ /dev/null @@ -1,26 +0,0 @@ -#' Drop-in replacement for `EBImage::thinImage` -#' -#' 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()]. -#' -#' @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 293d3f8..1ca25ad 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,13 @@ #' 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`). #' #' @keywords internal #' @useDynLib thinr, .registration = TRUE diff --git a/README.md b/README.md index c040846..4feb5bd 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,9 +36,6 @@ thin(m, method = "guo_hall") thin(m, method = "hilditch") thin(m, method = "holt") -# Drop-in for EBImage::thinImage() -thinImage(m) - # Medial axis transform (returns binary skeleton + per-pixel width) medial_axis(m) medial_axis(m, return_distance = TRUE) @@ -53,7 +50,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 +71,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/_pkgdown.yml b/_pkgdown.yml index e644f7e..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: @@ -22,6 +21,7 @@ articles: - title: "Guides" contents: - choosing-a-method + - correctness-properties navbar: structure: diff --git a/cran-comments.md b/cran-comments.md index f68cf8d..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 @@ -121,10 +121,11 @@ public, if ever. ## Notes for the submission reviewer -- `EBImage::thinImage()` provides a Zhang-Suen implementation; - `thinr::thinImage()` is a signature-compatible drop-in. The - mention of `EBImage` in the Description field is informational; no - `Imports` or `Suggests` link to it. +- `EBImage` provides binary morphology (`dilate`, `erode`, `distmap`, + `watershed`) but no thinning / skeletonization operator; `thinr` + 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/distance_transform.Rd b/man/distance_transform.Rd index 3cdf918..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.} +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/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..b0b5666 100644 --- a/man/thin.Rd +++ b/man/thin.Rd @@ -13,15 +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. 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 @@ -36,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/man/thinImage.Rd b/man/thinImage.Rd deleted file mode 100644 index 8d924d1..0000000 --- a/man/thinImage.Rd +++ /dev/null @@ -1,33 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/thin_image.R -\name{thinImage} -\alias{thinImage} -\title{Drop-in replacement for \code{EBImage::thinImage}} -\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{ -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()}}. -} -\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 573544f..1ea2b1c 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,14 @@ 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}). } \seealso{ 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/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-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-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({ diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index 3c5836f..5cb0f9c 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -4,6 +4,59 @@ 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 +} + +# 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({ @@ -29,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 =", @@ -43,6 +95,84 @@ 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 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({ @@ -123,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) @@ -194,6 +316,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") @@ -248,10 +385,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 @@ -278,8 +414,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"), { @@ -304,6 +439,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) @@ -312,15 +448,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) }) }) } @@ -329,23 +468,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 @@ -387,37 +509,35 @@ 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", { - 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, - 0, 0, 1, 0, 0, - 0, 1, 1, 1, 0), - nrow = 3, byrow = TRUE) - expect_identical(thinImage(img), expected) - }) }) -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")) - }) +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) + }) + }) + } }) diff --git a/vignettes/choosing-a-method.Rmd b/vignettes/choosing-a-method.Rmd index b8f74fb..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 and matches `EBImage::thinImage()` behavior. The `thinImage()` wrapper is provided as a drop-in replacement. +`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 and for compatibility with code that previously used `EBImage::thinImage()`. +- **`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. diff --git a/vignettes/correctness-properties.Rmd b/vignettes/correctness-properties.Rmd new file mode 100644 index 0000000..0e59958 --- /dev/null +++ b/vignettes/correctness-properties.Rmd @@ -0,0 +1,216 @@ +--- +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.