Skip to content

Commit 551e54a

Browse files
billdenneyclaude
andauthored
Merge pull request #8 from humanpred/review-backlog/thinr
Document + pin F017: zhang_suen erases isolated 2x2 blocks An isolated 2x2 block is a genuine divergence point between the parallel algorithms and matters for small-blob masks (marker remnants, dotted-line dashes, segmentation specks). All four block pixels satisfy the zhang_suen deletion gate (B == 3, A == 1, both sub-iteration corner products zero) in the same sub-iteration, so the default method erases the block entirely, while guo_hall keeps exactly one pixel. Document the property in vignette("choosing-a-method") -- a caveat on the zhang_suen/guo_hall bullets plus a new "Small isolated blobs" subsection with a runnable example and the mitigation (use guo_hall or size-filter blobs before thinning). Add a tripwire-style pinned test asserting zhang_suen -> empty and guo_hall -> one pixel; if either count changes, the vignette guidance is wrong and must be updated with it. Also records the F014 hilditch fix in NEWS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 48905a3 + 11086fd commit 551e54a

4 files changed

Lines changed: 127 additions & 10 deletions

File tree

NEWS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,24 @@ new `vignette("correctness-properties")` for the guarantees they restore.
2828
input with a clear error at the coercion boundary instead of silently
2929
turning it into `NA_integer_` (`INT_MIN`) in the C++ kernels (#F016).
3030

31+
* `thin(method = "hilditch")` now thins junctions to the published parallel
32+
form. The look-ahead conditions 3 and 4 skip deleting a pixel only when a
33+
cardinal neighbour has crossing number `A == 1` on the current image; the
34+
kernel had compared the look-ahead crossing number (computed with the
35+
centre already removed) against 1, which is strictly stronger and also
36+
spared junction neighbours where `A >= 2`, leaving a redundant pixel beside
37+
the junction. Skeletons are now equal to or thinner than before, never
38+
thicker (verified against a reference implementation of the published form
39+
over random images) (#F014).
40+
3141
* Added a connectivity-preservation property test across all seven methods
3242
and tightened the Holt straight-line test (#F013).
3343

44+
* Documented and pinned the isolated-2×2-block behaviour: the default
45+
`zhang_suen` erases an isolated 2×2 block entirely while `guo_hall` keeps
46+
one pixel. See `vignette("choosing-a-method")` for guidance on small-blob
47+
masks (#F017).
48+
3449
* Removed `thinImage()`. Use `thin()` (Zhang-Suen is the default method).
3550

3651
# thinr 0.2.0

src/hilditch.cpp

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

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

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

115141
mark(r, c) = 1;

tests/testthat/test-thin.R

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,62 @@ describe("exact skeletons on small known shapes", {
509509
expected[3, 3] <- 1L
510510
expect_identical(thin(img, method = "guo_hall"), expected)
511511
})
512+
513+
it("hilditch uses the published look-ahead condition sense at junctions", {
514+
# Regression pin for the Hilditch condition-3/4 look-ahead sense.
515+
# The published parallel form skips deleting the centre p1 when a
516+
# cardinal neighbour p2/p4 has crossing number A == 1 on the CURRENT
517+
# image. An earlier implementation compared the look-ahead crossing
518+
# number (computed with p1 removed) against 1, which is strictly
519+
# stronger and also spared junction neighbours where A(p2) >= 2,
520+
# leaving a redundant pixel beside the junction. Here the redundant
521+
# pixel is [4, 3]: the corrected condition deletes it, the old one
522+
# kept it. Cross-checked exhaustively against a pure-R implementation
523+
# of the published form.
524+
img <- matrix(c(
525+
0, 1, 0, 0, 0, 0,
526+
0, 0, 1, 0, 0, 0,
527+
1, 1, 1, 1, 1, 1,
528+
1, 1, 1, 1, 0, 1,
529+
0, 1, 0, 0, 0, 0,
530+
1, 0, 1, 1, 0, 0
531+
), nrow = 6, ncol = 6, byrow = TRUE)
532+
expected <- matrix(c(
533+
0, 1, 0, 0, 0, 0,
534+
0, 0, 1, 0, 0, 0,
535+
0, 0, 1, 1, 1, 1,
536+
0, 1, 0, 0, 0, 0,
537+
0, 1, 0, 0, 0, 0,
538+
1, 0, 1, 1, 0, 0
539+
), nrow = 6, ncol = 6, byrow = TRUE)
540+
expect_identical(thin(img, method = "hilditch"), expected)
541+
})
542+
})
543+
544+
describe("isolated 2x2 block: method-dependent survival", {
545+
# An isolated 2x2 block is a genuine point of divergence between the
546+
# parallel algorithms, and it matters for small-blob masks (a marker
547+
# remnant, a dotted-line dash). All four of its pixels satisfy the
548+
# zhang_suen deletion gate (B == 3, A == 1, both sub-iteration corner
549+
# products zero) simultaneously, so zhang_suen -- the default method --
550+
# erases the block entirely. guo_hall's sub-iteration m-condition keeps
551+
# exactly one pixel. This is documented in vignette("choosing-a-method")
552+
# and pinned here as a tripwire: if either count changes, the vignette
553+
# guidance is now wrong and must be updated with it.
554+
it("zhang_suen (the default) erases an isolated 2x2 block", {
555+
img <- matrix(0L, nrow = 6, ncol = 6)
556+
img[3:4, 3:4] <- 1L
557+
sk <- thin(img, method = "zhang_suen")
558+
expect_identical(sum(sk), 0L)
559+
expect_identical(sk, matrix(0L, nrow = 6, ncol = 6))
560+
})
561+
562+
it("guo_hall keeps exactly one pixel of an isolated 2x2 block", {
563+
img <- matrix(0L, nrow = 6, ncol = 6)
564+
img[3:4, 3:4] <- 1L
565+
sk <- thin(img, method = "guo_hall")
566+
expect_identical(sum(sk), 1L)
567+
})
512568
})
513569

514570
describe("shapes touching the matrix edge are thinned like interior shapes", {

vignettes/choosing-a-method.Rmd

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,34 @@ The thinning algorithms produce broadly similar skeletons on this V — they all
8484

8585
## When to use which
8686

87-
- **`zhang_suen`** — the default. Most predictable behavior. Use for general purpose thinning.
88-
- **`guo_hall`** — try this if your skeletons have lots of diagonal features and Zhang-Suen is breaking them at corners.
87+
- **`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).
88+
- **`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.
8989
- **`lee`** — when you want directional processing (four sub-iterations per pass, one per cardinal direction). Sometimes produces cleaner skeletons on asymmetric inputs.
9090
- **`k3m`** — strongest corner preservation in published comparative studies, at the cost of being slower (six phases per outer iteration vs. two for Zhang-Suen).
9191
- **`hilditch`** — well-cited historical algorithm; the look-ahead crossing-number check makes its connectivity slightly different from the other parallel algorithms.
9292
- **`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).
9393
- **`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.
9494

95+
### Small isolated blobs
96+
97+
The methods disagree on the smallest shapes. An isolated 2×2 block is the
98+
notable case: all four of its pixels pass the Zhang-Suen deletion gate in the
99+
same sub-iteration, so the default method erases the block completely, while
100+
Guo-Hall keeps one pixel.
101+
102+
```{r}
103+
block <- matrix(0L, 6, 6)
104+
block[3:4, 3:4] <- 1L
105+
sum(thin(block, method = "zhang_suen")) # 0 — the block is erased
106+
sum(thin(block, method = "guo_hall")) # 1 — one pixel survives
107+
```
108+
109+
This matters when a mask can contain tiny blobs — a marker remnant, a
110+
dotted-line dash, a speck left after segmentation. If such blobs must not
111+
vanish, use `guo_hall` (or filter blobs by size before thinning) rather than
112+
the default. Larger isolated shapes (3×3 and up) survive under every method;
113+
only the 2×2 block is fully erased.
114+
95115
## Medial axis transform
96116

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

0 commit comments

Comments
 (0)