Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -55,15 +55,14 @@ 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.
- `RcppExports.R` — auto-generated; do not edit.

## 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
Expand Down
8 changes: 4 additions & 4 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Description: Thinning (skeletonization) algorithms for binary raster
(1987) <doi:10.1145/12527.12531>. Also provides the medial axis
transform (Blum 1967) and a distance transform implementation
following Felzenszwalb and Huttenlocher (2012)
<doi:10.4086/toc.2012.v008a019>. 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.
<doi:10.4086/toc.2012.v008a019>. 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:
Expand Down
1 change: 0 additions & 1 deletion NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
export(distance_transform)
export(medial_axis)
export(thin)
export(thinImage)
importFrom(Rcpp,sourceCpp)
useDynLib(thinr, .registration = TRUE)
35 changes: 35 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions R/distance_transform.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion R/medial_axis.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 50 additions & 15 deletions R/thin.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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)))
Expand Down
26 changes: 0 additions & 26 deletions R/thin_image.R

This file was deleted.

13 changes: 7 additions & 6 deletions R/thinr-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<!-- badges: end -->

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

Expand All @@ -28,17 +28,14 @@ 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
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)
Expand All @@ -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. |
Expand All @@ -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.
2 changes: 1 addition & 1 deletion _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ reference:
- title: "Thinning"
contents:
- thin
- thinImage

- title: "Medial axis and distance transform"
contents:
Expand All @@ -22,6 +21,7 @@ articles:
- title: "Guides"
contents:
- choosing-a-method
- correctness-properties

navbar:
structure:
Expand Down
Loading
Loading