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
15 changes: 15 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,24 @@ new `vignette("correctness-properties")` for the guarantees they restore.
input with a clear error at the coercion boundary instead of silently
turning it into `NA_integer_` (`INT_MIN`) in the C++ kernels (#F016).

* `thin(method = "hilditch")` now thins junctions to the published parallel
form. The look-ahead conditions 3 and 4 skip deleting a pixel only when a
cardinal neighbour has crossing number `A == 1` on the current image; the
kernel had compared the look-ahead crossing number (computed with the
centre already removed) against 1, which is strictly stronger and also
spared junction neighbours where `A >= 2`, leaving a redundant pixel beside
the junction. Skeletons are now equal to or thinner than before, never
thicker (verified against a reference implementation of the published form
over random images) (#F014).

* Added a connectivity-preservation property test across all seven methods
and tightened the Holt straight-line test (#F013).

* Documented and pinned the isolated-2×2-block behaviour: the default
`zhang_suen` erases an isolated 2×2 block entirely while `guo_hall` keeps
one pixel. See `vignette("choosing-a-method")` for guidance on small-blob
masks (#F017).

* Removed `thinImage()`. Use `thin()` (Zhang-Suen is the default method).

# thinr 0.2.0
Expand Down
42 changes: 34 additions & 8 deletions src/hilditch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// - Lam, Lee & Suen (1992), "Thinning Methodologies - A Comprehensive
// Survey", IEEE TPAMI 14(9):869-885. The parallel form R1-R4 is
// described on page 876; this implementation matches that form.
// The look-ahead conditions 3 and 4 use "A(p2) != 1" / "A(p4) != 1"
// evaluated on the *current* image (see the per-condition notes
// below), not the stricter "== 1 with the centre already removed".
//
// Important: the implementation here is the **parallel form**
// commonly labelled "Hilditch" in modern image-processing references
Expand All @@ -20,10 +23,13 @@
//
// Distinctive feature of this form vs. Zhang-Suen: the look-ahead
// crossing-number check on cardinal neighbours - when conditions 3
// and 4 trigger, the algorithm computes A(p2) (or A(p4)) under the
// assumption that the centre pixel has been removed, and refuses the
// removal if that would change the topological character of the
// neighbour.
// and 4 trigger, the algorithm inspects the crossing number A(p2)
// (or A(p4)) of the cardinal neighbour and refuses the removal only
// when deleting the centre would leave that neighbour non-simple
// (A == 1 on the current image). The helpers below compute the
// crossing number with the centre pixel forced to 0; the deletion
// tests convert that look-ahead value back to the current-image
// crossing number (see the per-condition comments).
//
// Implementation note: the look-ahead requires reading rows r-2 /
// r+2 and columns c-2 / c+2. Out-of-bounds reads are treated as
Expand Down Expand Up @@ -94,22 +100,42 @@ IntegerMatrix hilditch_cpp(IntegerMatrix img, int max_iter) {
int A = thinr::crossing_number(p2, p3, p4, p5, p6, p7, p8, p9);
if (A != 1) continue;

// Hilditch condition 3: p2 * p4 * p8 == 0 OR A(p2)|_{p1=0} == 1.
// Hilditch condition 3: keep p1 (skip deletion) when
// p2 * p4 * p8 == 1 AND A(p2) == 1 on the CURRENT image.
//
// A(p2) here is the crossing number of p2 evaluated with p1 (the
// centre) at its present value of 1. crossing_at_north computes
// A(p2) with p1 forced to 0; under this gate (p4 == p8 == 1) that
// look-ahead value is exactly A(p2)|current + 1, because the only
// p1-dependent transition terms are (p4==0 && p1==1), which is 0
// since p4==1, and (p1==0 && p8==1), which flips from 0 (p1==1) to
// 1 (p1==0) since p8==1. So A(p2)|current == 1 <=> A_p2 == 2, and
// the published parallel form's "OR A(p2) != 1" disjunct becomes
// "skip only when A_p2 == 2". Requiring A_p2 == 1 (the earlier form)
// was strictly stronger: it also refused deletion at junction
// neighbours where A(p2)|current >= 2, leaving redundant pixels
// beside skeleton junctions (verified against the published form
// over random images: current-form skeletons were never thinner
// and were strictly thicker in ~8% of cases).
if (p2 == 1 && p4 == 1 && p8 == 1) {
int qn = get(r - 2, c);
int qne = get(r - 2, c + 1);
int qnw = get(r - 2, c - 1);
int A_p2 = crossing_at_north(qn, qne, p3, p4, 0, p8, p9, qnw);
if (A_p2 != 1) continue;
if (A_p2 == 2) continue;
}

// Hilditch condition 4: p2 * p4 * p6 == 0 OR A(p4)|_{p1=0} == 1.
// Hilditch condition 4: mirror of condition 3 for the east
// neighbour p4. Skip deletion when p2 * p4 * p6 == 1 AND
// A(p4) == 1 on the current image; crossing_at_east computes
// A(p4)|p1=0 == A(p4)|current + 1 under this gate, so the test is
// A_p4 == 2.
if (p2 == 1 && p4 == 1 && p6 == 1) {
int qen = get(r - 1, c + 2);
int qee = get(r, c + 2);
int qes = get(r + 1, c + 2);
int A_p4 = crossing_at_east(p3, qen, qee, qes, p5, p6, 0, p2);
if (A_p4 != 1) continue;
if (A_p4 == 2) continue;
}

mark(r, c) = 1;
Expand Down
56 changes: 56 additions & 0 deletions tests/testthat/test-thin.R
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,62 @@ describe("exact skeletons on small known shapes", {
expected[3, 3] <- 1L
expect_identical(thin(img, method = "guo_hall"), expected)
})

it("hilditch uses the published look-ahead condition sense at junctions", {
# Regression pin for the Hilditch condition-3/4 look-ahead sense.
# The published parallel form skips deleting the centre p1 when a
# cardinal neighbour p2/p4 has crossing number A == 1 on the CURRENT
# image. An earlier implementation compared the look-ahead crossing
# number (computed with p1 removed) against 1, which is strictly
# stronger and also spared junction neighbours where A(p2) >= 2,
# leaving a redundant pixel beside the junction. Here the redundant
# pixel is [4, 3]: the corrected condition deletes it, the old one
# kept it. Cross-checked exhaustively against a pure-R implementation
# of the published form.
img <- matrix(c(
0, 1, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0,
1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 0, 1,
0, 1, 0, 0, 0, 0,
1, 0, 1, 1, 0, 0
), nrow = 6, ncol = 6, byrow = TRUE)
expected <- matrix(c(
0, 1, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0,
0, 0, 1, 1, 1, 1,
0, 1, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0,
1, 0, 1, 1, 0, 0
), nrow = 6, ncol = 6, byrow = TRUE)
expect_identical(thin(img, method = "hilditch"), expected)
})
})

describe("isolated 2x2 block: method-dependent survival", {
# An isolated 2x2 block is a genuine point of divergence between the
# parallel algorithms, and it matters for small-blob masks (a marker
# remnant, a dotted-line dash). All four of its pixels satisfy the
# zhang_suen deletion gate (B == 3, A == 1, both sub-iteration corner
# products zero) simultaneously, so zhang_suen -- the default method --
# erases the block entirely. guo_hall's sub-iteration m-condition keeps
# exactly one pixel. This is documented in vignette("choosing-a-method")
# and pinned here as a tripwire: if either count changes, the vignette
# guidance is now wrong and must be updated with it.
it("zhang_suen (the default) erases an isolated 2x2 block", {
img <- matrix(0L, nrow = 6, ncol = 6)
img[3:4, 3:4] <- 1L
sk <- thin(img, method = "zhang_suen")
expect_identical(sum(sk), 0L)
expect_identical(sk, matrix(0L, nrow = 6, ncol = 6))
})

it("guo_hall keeps exactly one pixel of an isolated 2x2 block", {
img <- matrix(0L, nrow = 6, ncol = 6)
img[3:4, 3:4] <- 1L
sk <- thin(img, method = "guo_hall")
expect_identical(sum(sk), 1L)
})
})

describe("shapes touching the matrix edge are thinned like interior shapes", {
Expand Down
24 changes: 22 additions & 2 deletions vignettes/choosing-a-method.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,34 @@ 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.
- **`guo_hall`** — try this if your skeletons have lots of diagonal features and Zhang-Suen is breaking them at corners.
- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning. One caveat: an *isolated* 2×2 block is erased entirely (see "Small isolated blobs" below).
- **`guo_hall`** — try this if your skeletons have lots of diagonal features and Zhang-Suen is breaking them at corners. Keeps one pixel of an isolated 2×2 block where Zhang-Suen erases it.
- **`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).
- **`hilditch`** — well-cited historical algorithm; the look-ahead crossing-number check makes its connectivity slightly different from the other parallel algorithms.
- **`opta`** — one-pass safe-point algorithm. Its `N2` condition protects two-4-adjacent-pixel diagonal patterns, which can leave stray pixels at bar corners (a documented property of SPTA).
- **`holt`** — when 2-pixel-wide lines should be preserved. The algorithm uses edge information from neighbouring pixels in a 5x5 window, allowing a single subcycle.

### Small isolated blobs

The methods disagree on the smallest shapes. An isolated 2×2 block is the
notable case: all four of its pixels pass the Zhang-Suen deletion gate in the
same sub-iteration, so the default method erases the block completely, while
Guo-Hall keeps one pixel.

```{r}
block <- matrix(0L, 6, 6)
block[3:4, 3:4] <- 1L
sum(thin(block, method = "zhang_suen")) # 0 — the block is erased
sum(thin(block, method = "guo_hall")) # 1 — one pixel survives
```

This matters when a mask can contain tiny blobs — a marker remnant, a
dotted-line dash, a speck left after segmentation. If such blobs must not
vanish, use `guo_hall` (or filter blobs by size before thinning) rather than
the default. Larger isolated shapes (3×3 and up) survive under every method;
only the 2×2 block is fully erased.

## Medial axis transform

The thinning algorithms above all produce binary 1-pixel-wide skeletons without width information. For tasks where local thickness matters, use `medial_axis()`:
Expand Down
Loading