Skip to content

Commit 65e6fa8

Browse files
committed
perf: faster 3D thinning with parallel scanning and removal tracking
1 parent b7451f5 commit 65e6fa8

1 file changed

Lines changed: 164 additions & 28 deletions

File tree

vesskel/thin_3d.py

Lines changed: 164 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
import numpy as np
2323
from numba import njit, prange
2424

25+
# ---------------------------------------------------------------------------
26+
# Lookup tables for Euler characteristic computation (Lee94 Table 2).
27+
# _EULER_ARR maps the 256 possible 3x3x3 configurations to their Euler
28+
# delta values. Only odd indices (center=1) are populated; evens are 0.
29+
# ---------------------------------------------------------------------------
2530
_EULER_ARR = np.array(
2631
[
2732
1,
@@ -159,6 +164,12 @@
159164
_EULER_LUT = np.zeros(256, dtype=np.int32)
160165
_EULER_LUT[1::2] = _EULER_ARR
161166

167+
# ---------------------------------------------------------------------------
168+
# Octant masks for Euler-invariant check.
169+
# Each row lists 7 neighbour indices (in the flat 27-element neighbourhood)
170+
# that form one octant of the 3x3x3 cube. The octant's bits are packed into
171+
# a number used to index _EULER_LUT.
172+
# ---------------------------------------------------------------------------
162173
_OCTANTS = np.array(
163174
[
164175
[2, 1, 11, 10, 5, 4, 14],
@@ -173,8 +184,10 @@
173184
dtype=np.int64,
174185
)
175186

187+
# Six border directions processed in order: -z, z, -y, y, -x, x.
176188
_BORDERS = np.array([4, 3, 2, 1, 5, 6], dtype=np.int64)
177189

190+
# Offsets for all 26 neighbours of a voxel (excluding the centre).
178191
_OFFSETS_26 = np.array(
179192
[
180193
(-1, -1, -1),
@@ -207,6 +220,15 @@
207220
dtype=np.int8,
208221
)
209222

223+
# ---------------------------------------------------------------------------
224+
# Pre-computed 26-neighbour adjacency graph.
225+
# _ADJ26[i, j] == 1 iff voxels i and j (in the 26-neighbour set) are
226+
# themselves 26-adjacent.
227+
# _ADJ26_LIST[i, :k] holds the list of adjacency neighbours for voxel i.
228+
# _ADJ26_COUNT[i] is the number of such neighbours (= k).
229+
#
230+
# These are used by _is_simple_point to run DFS on the 26-neighbour graph.
231+
# ---------------------------------------------------------------------------
210232
_ADJ26 = np.zeros((26, 26), dtype=np.uint8)
211233
for _i in range(26):
212234
for _j in range(26):
@@ -228,6 +250,7 @@
228250
_count += 1
229251
_ADJ26_COUNT[_i] = _count
230252

253+
# Offsets for the six face-neighbour directions (and identity at index 0).
231254
_BORDER_OFFSETS = np.array(
232255
[
233256
(0, 0, 0),
@@ -242,8 +265,12 @@
242265
)
243266

244267

268+
# ======================== Low-level predicates ===========================
269+
270+
245271
@njit(cache=True)
246272
def _get_neighborhood(img, p, r, c, neighborhood):
273+
"""Fill ``neighborhood`` with the 27 voxels of the 3×3×3 cube at (p,r,c)."""
247274
idx = 0
248275
for dp in range(-1, 2):
249276
for dr in range(-1, 2):
@@ -254,6 +281,7 @@ def _get_neighborhood(img, p, r, c, neighborhood):
254281

255282
@njit(cache=True)
256283
def _is_endpoint(neighbors):
284+
"""A voxel is an endpoint if exactly 2 of its 27 neighbours are foreground."""
257285
s = 0
258286
for j in range(27):
259287
s += neighbors[j]
@@ -262,6 +290,9 @@ def _is_endpoint(neighbors):
262290

263291
@njit(cache=True)
264292
def _is_euler_invariant(neighbors):
293+
"""Return True if removing the centre voxel preserves the Euler
294+
characteristic. Packs each octant into a bit mask, then looks up the
295+
Euler delta from _EULER_LUT."""
265296
euler_char = 0
266297
for octant in range(8):
267298
n = 1
@@ -274,17 +305,24 @@ def _is_euler_invariant(neighbors):
274305

275306

276307
@njit(cache=True)
277-
def _is_simple_point(neighbors):
278-
cube = np.empty(26, dtype=np.uint8)
308+
def _is_simple_point(neighbors, cube, visited, stack):
309+
"""Return True if the centre voxel is a *simple point* - i.e. removing it
310+
does not change the topology of the foreground.
311+
312+
Works by extracting the 26 exterior voxels into ``cube``, then counting
313+
connected components on the 26-adjacency graph via DFS. If there is
314+
exactly one connected component among the foreground neighbours, the
315+
point is simple.
316+
317+
``cube``, ``visited``, ``stack`` are pre-allocated scratch buffers."""
279318
j = 0
280319
for i in range(27):
281320
if i == 13:
282321
continue
283322
cube[j] = neighbors[i]
284323
j += 1
285324

286-
visited = np.zeros(26, dtype=np.uint8)
287-
stack = np.empty(26, dtype=np.int64)
325+
visited[:] = 0
288326
components = 0
289327

290328
for i in range(26):
@@ -314,31 +352,66 @@ def _is_simple_point(neighbors):
314352
return True
315353

316354

317-
@njit(cache=True)
355+
# ====================== Candidate finding and marking =====================
356+
357+
358+
@njit(cache=True, parallel=True)
318359
def _find_simple_point_candidates(img, curr_border, candidates):
319-
count = 0
360+
"""Scan the volume for foreground voxels on the face given by
361+
``_BORDER_OFFSETS[curr_border]`` and write their coordinates into
362+
``candidates``.
363+
364+
Two-pass parallel strategy (avoids a shared counter across threads):
365+
1. Each z-slice counts its candidates in parallel.
366+
2. A prefix sum computes write offsets.
367+
3. Each z-slice fills its contiguous segment of ``candidates``.
368+
"""
320369
dp = int(_BORDER_OFFSETS[curr_border, 0])
321370
dr = int(_BORDER_OFFSETS[curr_border, 1])
322371
dc = int(_BORDER_OFFSETS[curr_border, 2])
323372

324-
for p in range(1, img.shape[0] - 1):
325-
for r in range(1, img.shape[1] - 1):
326-
for c in range(1, img.shape[2] - 1):
327-
if img[p, r, c] != 1:
328-
continue
329-
if img[p + dp, r + dr, c + dc] != 0:
330-
continue
331-
332-
candidates[count, 0] = p
333-
candidates[count, 1] = r
334-
candidates[count, 2] = c
335-
count += 1
336-
337-
return count
373+
P = img.shape[0] - 1
374+
R = img.shape[1] - 1
375+
C = img.shape[2] - 1
376+
num_slices = P - 1
377+
378+
slice_counts = np.zeros(num_slices, dtype=np.int64)
379+
for p in prange(1, P):
380+
local_count = 0
381+
for r in range(1, R):
382+
for c in range(1, C):
383+
if img[p, r, c] == 1 and img[p + dp, r + dr, c + dc] == 0:
384+
local_count += 1
385+
slice_counts[p - 1] = local_count
386+
387+
offsets = np.zeros(num_slices + 1, dtype=np.int64)
388+
for p_idx in range(num_slices):
389+
offsets[p_idx + 1] = offsets[p_idx] + slice_counts[p_idx]
390+
total = offsets[num_slices]
391+
392+
for p in prange(1, P):
393+
idx = offsets[p - 1]
394+
for r in range(1, R):
395+
for c in range(1, C):
396+
if img[p, r, c] == 1 and img[p + dp, r + dr, c + dc] == 0:
397+
candidates[idx, 0] = p
398+
candidates[idx, 1] = r
399+
candidates[idx, 2] = c
400+
idx += 1
401+
402+
return total
338403

339404

340405
@njit(cache=True, parallel=True)
341406
def _mark_removable_candidates(img, candidates, num_candidates, removable):
407+
"""For each candidate voxel, check the three Lee94 criteria in order:
408+
1. not an endpoint,
409+
2. Euler-characteristic invariant,
410+
3. simple point.
411+
412+
Voxels that pass all three are marked as removable (1). The checks
413+
short-circuit: failure on any earlier criterion skips the later ones.
414+
"""
342415
for i in prange(num_candidates):
343416
p = candidates[i, 0]
344417
r = candidates[i, 1]
@@ -347,18 +420,44 @@ def _mark_removable_candidates(img, candidates, num_candidates, removable):
347420
neighborhood = np.empty(27, dtype=np.uint8)
348421
_get_neighborhood(img, p, r, c, neighborhood)
349422

423+
cube = np.empty(26, dtype=np.uint8)
424+
visited = np.zeros(26, dtype=np.uint8)
425+
stack = np.empty(26, dtype=np.int64)
426+
350427
can_remove = (
351428
(not _is_endpoint(neighborhood))
352429
and _is_euler_invariant(neighborhood)
353-
and _is_simple_point(neighborhood)
430+
and _is_simple_point(neighborhood, cube, visited, stack)
354431
)
355432
removable[i] = 1 if can_remove else 0
356433

357434

435+
# ======================== Sequential removal pass =========================
436+
437+
358438
@njit(cache=True)
359-
def _apply_removals(img, candidates, num_candidates, removable):
439+
def _apply_removals(img, candidates, num_candidates, removable, removed_epoch, epoch):
440+
"""Sequentially remove voxels marked as removable, re-checking simplicity
441+
only when a neighbour was *already removed in the same batch*.
442+
443+
The naive approach would re-read the neighbourhood and re-run
444+
_is_simple_point for *every* candidate (which is expensive). However,
445+
_mark_removable_candidates already verified that each candidate is simple
446+
*before any removals in this batch*. If none of a candidate's 26
447+
neighbours have been removed yet in this batch, the old verification
448+
is still valid and we can skip the re-check.
449+
450+
``removed_epoch`` is a 3-D stamp array - an epoch counter written at the
451+
position of every voxel removed in this call. Checking whether a
452+
neighbour was removed is an O(26) stamp lookup, not an O(k) scan over
453+
previously removed coordinates. The array is never reset; epochs are
454+
monotonically increasing so stale stamps are invisible.
455+
"""
360456
removed = 0
361457
neighborhood = np.empty(27, dtype=np.uint8)
458+
cube = np.empty(26, dtype=np.uint8)
459+
visited = np.zeros(26, dtype=np.uint8)
460+
stack = np.empty(26, dtype=np.int64)
362461
for i in range(num_candidates):
363462
if removable[i] == 0:
364463
continue
@@ -367,20 +466,48 @@ def _apply_removals(img, candidates, num_candidates, removable):
367466
c = candidates[i, 2]
368467
if img[p, r, c] != 1:
369468
continue
370-
_get_neighborhood(img, p, r, c, neighborhood)
371-
if _is_simple_point(neighborhood):
372-
img[p, r, c] = 0
373-
removed += 1
469+
470+
neighbor_removed = False
471+
for dp in range(-1, 2):
472+
for dr in range(-1, 2):
473+
for dc in range(-1, 2):
474+
if dp == 0 and dr == 0 and dc == 0:
475+
continue
476+
if removed_epoch[p + dp, r + dr, c + dc] == epoch:
477+
neighbor_removed = True
478+
break
479+
if neighbor_removed:
480+
break
481+
if neighbor_removed:
482+
break
483+
484+
if neighbor_removed:
485+
_get_neighborhood(img, p, r, c, neighborhood)
486+
if not _is_simple_point(neighborhood, cube, visited, stack):
487+
continue
488+
489+
img[p, r, c] = 0
490+
removed_epoch[p, r, c] = epoch
491+
removed += 1
374492
return removed
375493

376494

377495
@njit(cache=True)
378496
def _compute_thin_image(img):
497+
"""Iteratively peel surface voxels from a padded 3D binary volume until
498+
a 1-voxel-thin skeleton remains.
499+
500+
The outer loop processes all six face directions sequentially. A border
501+
direction is considered "stable" when no removable voxel was found on
502+
that face during the pass. When all six are stable the skeleton is done.
503+
"""
379504
num_borders = 6
380505
unchanged_borders = 0
381506

382507
candidates = np.empty((img.size, 3), dtype=np.int32)
383508
removable = np.empty(img.size, dtype=np.uint8)
509+
removed_epoch = np.zeros(img.shape, dtype=np.uint32)
510+
epoch = 0
384511

385512
while unchanged_borders < num_borders:
386513
unchanged_borders = 0
@@ -393,7 +520,15 @@ def _compute_thin_image(img):
393520
continue
394521

395522
_mark_removable_candidates(img, candidates, num_candidates, removable)
396-
removed = _apply_removals(img, candidates, num_candidates, removable)
523+
epoch += 1
524+
removed = _apply_removals(
525+
img,
526+
candidates,
527+
num_candidates,
528+
removable,
529+
removed_epoch,
530+
epoch,
531+
)
397532

398533
if removed == 0:
399534
unchanged_borders += 1
@@ -424,7 +559,8 @@ def thin_3d(img):
424559

425560
work = (img > 0).astype(np.uint8, copy=False)
426561
padded = np.zeros(
427-
(work.shape[0] + 2, work.shape[1] + 2, work.shape[2] + 2), dtype=np.uint8
562+
(work.shape[0] + 2, work.shape[1] + 2, work.shape[2] + 2),
563+
dtype=np.uint8,
428564
)
429565
padded[1:-1, 1:-1, 1:-1] = work
430566

0 commit comments

Comments
 (0)