2222import numpy as np
2323from 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 ,
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 ],
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 ),
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 )
211233for _i in range (26 ):
212234 for _j in range (26 ):
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 ),
242265)
243266
244267
268+ # ======================== Low-level predicates ===========================
269+
270+
245271@njit (cache = True )
246272def _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 )
256283def _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 )
264292def _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 )
318359def _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 )
341406def _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 )
378496def _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