-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathskeletonize_worms.py
More file actions
4088 lines (3692 loc) · 188 KB
/
Copy pathskeletonize_worms.py
File metadata and controls
4088 lines (3692 loc) · 188 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
C. elegans Centerline Skeletonization Script
=============================================
Fits a smooth centerline to each worm mask produced by a YOLO segmentation model.
Pipeline per mask:
binary mask → fill holes → medial axis + distance transform
→ skan skeleton graph → longest path (by arc length)
→ parametric B-spline smoothing
→ ordered centerline (row, col) + local body width profile
Outputs:
- Annotated images with semi-transparent mask overlay and green centerline
Reuses utilities from visualize_predictions.py (get_image_files) to avoid
code duplication.
"""
import argparse
import csv
import itertools
import os
import signal
import warnings
from pathlib import Path
import cv2
import networkx as nx
import numpy as np
import torch
from scipy.interpolate import splprep, splev
from scipy.ndimage import binary_fill_holes, distance_transform_edt
from skimage.measure import label as sk_label
from skimage.morphology import medial_axis
from skan import Skeleton
from tqdm import tqdm
from ultralytics import YOLO
# Allow cuDNN to benchmark and select the fastest convolution algorithm for
# the current hardware. This improves GPU throughput at the cost of
# non-deterministic algorithm selection between runs (borderline mask pixels
# may occasionally differ). Set deterministic=True / benchmark=False if
# exact run-to-run reproducibility is required.
torch.backends.cudnn.deterministic = False
torch.backends.cudnn.benchmark = True
# Reuse the image-discovery helper defined in the visualization module
from visualize_predictions import get_image_files
from speed_meter import SpeedMeter, device_label
# ---------------------------------------------------------------------------
# Core skeletonization helpers
# ---------------------------------------------------------------------------
def _break_cycles_at_minimum_width(
G: nx.MultiGraph,
skel_obj: Skeleton,
paths_list: list,
) -> None:
"""
Detect and remove one edge per cycle in *G*, choosing the edge whose
skeleton pixels have the **smallest minimum distance-transform value**.
This handles self-overlapping worm configurations ("O" or "6" shapes)
where the medial axis forms a closed loop. Removing the edge at the
narrowest point cuts the loop at the most likely self-overlap region —
the spot where the two body sections lie closest together and local width
is smallest.
*G* must be a ``nx.MultiGraph`` so that parallel arcs between the same
pair of junction nodes (both sides of a ring) are preserved as distinct
edges. ``nx.find_cycle`` returns ``(u, v, key)`` triples for MultiGraph,
and ``G.remove_edge(u, v, key)`` removes only the targeted edge.
The graph is modified **in-place**. After all cycles are removed it is a
forest, so the normal degree-1 endpoint search can proceed unchanged.
Normal (non-looping) worms have no cycles and are completely unaffected.
"""
max_iterations = G.number_of_edges() # safety cap
for _ in range(max_iterations):
try:
cycle_edges = nx.find_cycle(G)
except nx.NetworkXNoCycle:
break
# Cut the shortest arc in the cycle (fewest skeleton pixels).
# Arc *length* is a more reliable discriminator than minimum distance-
# transform width for the parallel-arc ring case:
# - The crossing stub (which should be removed) is always short.
# - The main ring arc (which should be kept) is always long.
# Minimum-width could wrongly cut the long arc if the worm body is
# narrow anywhere along its length.
min_len = np.inf
cut_edge = None # (u, v, key)
for edge in cycle_edges: # each element is (u, v, key) for MultiGraph
u, v = edge[0], edge[1]
key = edge[2] if len(edge) > 2 else 0
if not G.has_edge(u, v, key):
continue
arc_len = G[u][v][key].get("length", np.inf)
if arc_len < min_len:
min_len = arc_len
cut_edge = (u, v, key)
if cut_edge is None:
# Fallback: no length stored — remove the first edge in the cycle
e0 = cycle_edges[0]
cut_edge = (e0[0], e0[1], e0[2] if len(e0) > 2 else 0)
G.remove_edge(cut_edge[0], cut_edge[1], cut_edge[2])
def _arc_len_index(coords: np.ndarray, target_len: float, from_end: bool = False) -> int:
"""Walk along *coords* accumulating arc length until >= *target_len*.
Parameters
----------
coords : (N, 2) array of (row, col) skeleton points.
target_len : Desired path length in pixels.
from_end : If ``True``, walk backward from ``coords[-1]`` and return the
offset ``wi`` such that ``coords[-1 - wi]`` is the anchor for
an *incoming* direction vector.
If ``False``, walk forward from ``coords[0]`` and return the
direct index ``wo`` such that ``coords[wo]`` is the anchor for
an *outgoing* direction vector.
Returns
-------
int
Index offset (``from_end=True``) or direct index (``from_end=False``).
Always in ``[0, len(coords) - 1]``. Returns 0 when *coords* has fewer
than 2 points.
"""
if len(coords) < 2:
return 0
if from_end:
cum = 0.0
for i in range(len(coords) - 1, 0, -1):
cum += float(np.linalg.norm(coords[i] - coords[i - 1]))
if cum >= target_len:
return len(coords) - i # wi: coords[-1-wi] == coords[i-1]
return len(coords) - 1 # whole array shorter than target_len
else:
cum = 0.0
for i in range(len(coords) - 1):
cum += float(np.linalg.norm(coords[i + 1] - coords[i]))
if cum >= target_len:
return i + 1 # wo: coords[wo] is the anchor
return len(coords) - 1 # whole array shorter than target_len
def _longest_endpoint_path_coords(
skel_obj: Skeleton,
dir_vector_len_px: float = 20.0,
min_bridge_arc_len_px: float = 20.0,
img_name: str = "",
) -> np.ndarray | None:
"""
Build a networkx graph from all skan skeleton segments and return the
ordered (N, 2) (row, col) coordinate array for the longest
endpoint-to-endpoint path.
Why a graph traversal is needed
--------------------------------
``skan`` splits the skeleton into *segments* — edges between junction
nodes and degree-1 leaf nodes. ``path_lengths()`` gives the length of
each individual segment, not the full head-to-tail path. When the medial
axis has *any* junction (from mask noise, thick regions, etc.) the
longest single segment stops at that junction, truncating the centerline
at mid-body. Walking the full graph finds the true longest path from one
tip to the other.
Self-overlapping worms ("O" / "6" shapes)
------------------------------------------
When the worm curls back onto itself the medial axis becomes a closed
loop, producing a cycle in the graph and **no** degree-1 endpoints.
``_break_cycles_at_minimum_width`` removes one edge per cycle at the
narrowest skeleton point (minimum distance-transform value), which
corresponds to the self-overlap crossing. This converts the loop into an
open path so the normal endpoint search can proceed. For a "6" shape the
free tail remains as one endpoint and the cut point in the loop becomes
the other, yielding a full head-to-tail traversal.
Supported graph topologies (6 cases)
-------------------------------------
The function handles the following skeleton graph structures. Cases are
evaluated in the order listed; the first match wins.
Case 1 — Normal worm (no loops)
Graph: ep1 — [optional intermediate junctions] — ep2
Detect: no self-loop nodes; graph is a forest (acyclic).
Build: all-pairs longest shortest-path search over degree-1
endpoints; arc segments stitched in order.
Case 2 — Pure "O" shape (full ring, no free tails)
Graph: J ↺ (single junction node with one self-loop edge)
Detect: self-loop node found; after removing the self-loop, no
degree-1 endpoints remain (endpoint_branches is empty).
Build: ring pixel array rotated so the narrowest body point
(minimum distance-transform value along the ring) becomes
both start and end, opening the loop into a linear path.
Case 3 — "6" shape (one free tail + one self-loop junction)
Graph: ep1 — J ↺
Detect: self-loop node found; after removing the self-loop, exactly
one degree-1 endpoint is reachable from the self-loop node
(len(endpoint_branches) == 1).
Build: branch reversed to ep1 → J, then ring pixels appended from
index [1:] (ring cut at the natural junction seam, avoiding
spatial jumps or backtracking kinks).
Case 4 — Two free tails flanking a self-loop junction
Graph: ep1 — J ↺ — ep2
Detect: self-loop node found; after removing the self-loop, two or
more degree-1 endpoints are reachable (len(endpoint_branches)
>= 2). The two longest branches are kept.
Build: branch_A reversed (ep1 → J) + ring interior + branch_B (J → ep2).
The ring interior is traversed in whichever direction (forward
ring_coords[1:-1] or reversed ring_coords[-2:0:-1]) produces
the smoothest path: the direction is chosen to maximise the sum
of cos(turning angle) at both branch–ring junctions.
Case 5 — Two free tails, inner self-loop junction behind an outer junction
Graph: ep1 — J1 — J2 ↺ — J1 — ep2
(J2 holds the self-loop; J1 is an intermediate junction
connecting J2 to both endpoint stems)
Detect: same code path as Case 4 — self-loop node J2 is found.
After removing the self-loop, nx.shortest_path from J2 to
each degree-1 endpoint returns 2-hop paths [J2, J1, ep_x].
_path_coords concatenates the two arc-segments per branch
transparently, so the multi-hop structure requires no special
handling beyond Case 4.
Build: identical assembly to Case 4 (with ring direction selection):
branch_A reversed (ep1→J1→J2) + ring interior + branch_B (J2→J1→ep2).
Case 6 — Omega / Ω shape (two junctions connected by two parallel arcs)
Graph: ep1 — J1 ══(arc_A)══ J2 — ep2
══(arc_B)══
(J1 and J2 are connected by exactly TWO distinct arcs,
creating a non-self-loop cycle in the MultiGraph)
Detect: no self-loop nodes; graph is NOT a forest; nx.find_cycle
returns exactly 2 edges sharing the same unordered node pair
{J1, J2} — i.e. they are parallel edges.
Build: the worm body passes through the crossing region twice
(once inbound, once outbound), so one arc is used as the
"bridge" (traversed twice) and the other arc is traversed
once. Two candidate assemblies are evaluated:
T1: ep1 → J1 → arc_A → J2 → arc_B_rev → J1 → arc_A → J2 → ep2
T2: ep1 → J1 → arc_B → J2 → arc_A_rev → J1 → arc_B → J2 → ep2
If ``arc_a`` (the shorter arc) is shorter than
``min_bridge_arc_len_px``, direction scoring is unreliable and
the shorter arc is used as bridge unconditionally (heuristic:
minimises total repeated path length). Otherwise both T1 and
T2 are scored and the candidate that maximises the sum of
cos(turning angle) at the entry and exit seams is selected.
"""
paths_list = skel_obj.paths_list()
lengths = skel_obj.path_lengths()
# Use MultiGraph so that parallel arcs between the same pair of junction
# nodes (e.g. both sides of a ring) are preserved as distinct edges.
# nx.Graph would silently deduplicate them, keeping only the longer arc
# and making the ring appear acyclic — causing the shorter arc to be
# permanently abandoned from the centerline.
G = nx.MultiGraph()
for idx, (px_path, length) in enumerate(zip(paths_list, lengths)):
src, dst = int(px_path[0]), int(px_path[-1])
G.add_edge(src, dst, key=idx, length=length, path_idx=idx)
# ── Helper: get the single edge-data dict between u and v ──────────────
# After cycle-breaking the graph is a forest, so each adjacent pair has
# at most one edge. This helper retrieves that edge's data dict.
def _edge_data(G_ref: nx.MultiGraph, u: int, v: int) -> dict:
return next(iter(G_ref[u][v].values()))
# ── Cases 2 / 3 / 4 / 5 — self-loop detected ──────────────────────────────
# A worm that forms a complete ring produces a self-loop edge in the path
# graph (the entire ring arc has src == dst = the junction node on the
# ring). Additional branch edges connect that junction outward through
# any bridge segments toward the free endpoints (the "overhang" stems of
# an omega/Ω shape).
#
# Strategy
# --------
# 1. Identify the dominant self-loop node (most ring pixels).
# 2. In a graph copy with the self-loop removed, find the longest
# coordinate chain from the self-loop node to each degree-1 endpoint
# (walking through any intermediate junctions / bridge segments).
# 3. Assemble the full head-to-tail path:
# branch_to_epA_reversed + ring_arc[1:-1] + branch_to_epB
# If no endpoint branches exist (pure "O"), cut the ring at its
# narrowest point (minimum distance-transform value) and return that
# as an open path.
self_loop_nodes = [n for n in G.nodes() if G.has_edge(n, n)]
if self_loop_nodes:
# Pick the self-loop with the most pixels (dominant ring arc).
def _best_sl_data(n: int) -> dict | None:
candidates = [
d for d in G[n][n].values() if d.get("path_idx") is not None
]
if not candidates:
return None
return max(
candidates,
key=lambda d: len(skel_obj.path_coordinates(d["path_idx"])),
)
sl_node = max(
(n for n in self_loop_nodes if _best_sl_data(n) is not None),
key=lambda n: len(
skel_obj.path_coordinates(_best_sl_data(n)["path_idx"])
),
default=None,
)
if sl_node is None:
sl_node = self_loop_nodes[0]
sl_edata = _best_sl_data(sl_node)
sl_path_idx = sl_edata["path_idx"] if sl_edata else None
ring_coords = (
skel_obj.path_coordinates(sl_path_idx)
if sl_path_idx is not None else None
)
# ── Helper: collect pixel coords along a node path ─────────────────
def _path_coords(
node_path: list[int], G_ref: nx.MultiGraph
) -> np.ndarray:
"""Return (M, 2) row-col array for an ordered list of graph nodes."""
segs: list[np.ndarray] = []
for k in range(len(node_path) - 1):
u2, v2 = node_path[k], node_path[k + 1]
pi2 = _edge_data(G_ref, u2, v2).get("path_idx")
if pi2 is None:
continue
seg2 = skel_obj.path_coordinates(pi2)
pp2 = paths_list[pi2]
if int(pp2[0]) != u2:
seg2 = seg2[::-1]
if segs: # drop duplicate junction pixel
seg2 = seg2[1:]
segs.append(seg2)
return np.vstack(segs) if segs else np.empty((0, 2))
# ── Find endpoint branches via graph without the self-loop ──────────
G_no_loop = G.copy()
# Remove ALL self-loop edges at sl_node.
for k in list(G_no_loop[sl_node][sl_node].keys()):
G_no_loop.remove_edge(sl_node, sl_node, k)
# For each degree-1 endpoint reachable from sl_node, record the
# node path and its total arc length (sum of edge lengths).
endpoint_branches: list[tuple[float, list[int]]] = []
for ep in [n for n in G_no_loop.nodes() if G_no_loop.degree(n) == 1]:
try:
npath = nx.shortest_path(G_no_loop, sl_node, ep)
total = sum(
_edge_data(G_no_loop, npath[k], npath[k + 1])["length"]
for k in range(len(npath) - 1)
)
endpoint_branches.append((total, npath))
except nx.NetworkXNoPath:
pass
# Sort longest-branch first so we always keep the two biggest stems.
endpoint_branches.sort(key=lambda t: t[0], reverse=True)
if ring_coords is None or len(ring_coords) < 4:
# Self-loop was detected but the ring arc pixel array is invalid —
# either skan returned no coordinates for the dominant self-loop arc,
# or it has fewer than 4 pixels (too small to fit a spline).
# Cannot handle via Cases 2/3/4/5; falling through to Case 6 /
# Fallback A / Case 1.
n_ring = 0 if ring_coords is None else len(ring_coords)
_pfx = f"[{img_name}] " if img_name else ""
warnings.warn(
f"{_pfx}Self-loop node detected (Cases 2/3/4/5) but ring_coords is "
f"{'None' if ring_coords is None else f'only {n_ring} pixel(s) long'} "
f"(need \u2265 4). Cannot reconstruct centerline from the ring arc. "
f"Falling through to Case 6 / Fallback A / Case 1.",
RuntimeWarning,
stacklevel=2,
)
if ring_coords is not None and len(ring_coords) >= 4:
if len(endpoint_branches) >= 2:
# ── Case 4 / 5: two free tails flanking the self-loop junction ──────
# Case 4: ep1 — J ↺ — ep2 (direct branches from the loop node)
# Case 5: ep1 — J1 — J2 ↺ — J1 — ep2 (branches are multi-hop;
# _path_coords handles the extra J1 hop transparently)
# Two overhangs: epA → ... → sl_node → ring → sl_node → ... → epB
# branch paths are oriented sl_node→endpoint; reverse the first.
branch_a = _path_coords(endpoint_branches[0][1], G_no_loop)[::-1]
branch_b = _path_coords(endpoint_branches[1][1], G_no_loop)
# ring_coords[0] == ring_coords[-1] == sl_node pixel;
# drop the shared junction pixels at the seams.
#
# ── Choose ring traversal direction to minimise sharp turns ──
# The ring interior can be walked in two directions; pick the
# one whose tangent at the entry junction (end of branch_a →
# first ring pixel) AND exit junction (last ring pixel → start
# of branch_b) together produce the smallest cumulative turning
# angle. Score = Σ cos(turning angle) at both junctions;
# higher score = smoother path.
ring_fwd = ring_coords[1:-1] # forward around the loop
ring_rev = ring_coords[-2:0:-1] # reversed around the loop
def _junction_smoothness(ring_interior: np.ndarray) -> float:
"""Sum of cos(turning angle) at entry and exit junctions."""
score = 0.0
# Entry: branch_a end → first ring pixel
if len(branch_a) >= 2 and len(ring_interior) >= 1:
wi = _arc_len_index(branch_a, dir_vector_len_px, from_end=True)
wo = _arc_len_index(ring_interior, dir_vector_len_px, from_end=False)
v_in = branch_a[-1] - branch_a[-1 - wi]
v_out = ring_interior[wo] - branch_a[-1]
n_in, n_out = (np.linalg.norm(v_in),
np.linalg.norm(v_out))
if n_in > 0 and n_out > 0:
score += float(np.clip(
np.dot(v_in, v_out) / (n_in * n_out), -1.0, 1.0
))
# Exit: last ring pixel → branch_b[1]
# (branch_b[0] is the duplicate sl_node pixel, skipped in
# the assembly below, so use branch_b[1] as the first new
# pixel after the junction.)
if len(ring_interior) >= 2 and len(branch_b) >= 2:
wi = _arc_len_index(ring_interior, dir_vector_len_px, from_end=True)
wo = _arc_len_index(branch_b, dir_vector_len_px, from_end=False)
v_in = ring_interior[-1] - ring_interior[-1 - wi]
v_out = branch_b[wo] - ring_interior[-1]
n_in, n_out = (np.linalg.norm(v_in),
np.linalg.norm(v_out))
if n_in > 0 and n_out > 0:
score += float(np.clip(
np.dot(v_in, v_out) / (n_in * n_out), -1.0, 1.0
))
return score
ring_interior = (
ring_fwd
if _junction_smoothness(ring_fwd) >= _junction_smoothness(ring_rev)
else ring_rev
)
parts = []
if len(branch_a):
parts.append(branch_a)
parts.append(ring_interior if len(parts) else ring_coords)
if len(branch_b):
parts.append(branch_b[1:]) # skip dup sl_node pixel
result = np.vstack(parts)
if len(result) >= 4:
return result
elif len(endpoint_branches) == 1:
# ── Case 3: "6" shape — one free tail + self-loop ────────────────
# Graph: ep1 — J ↺
# One overhang ("6" shape): epA → sl_node → ring
# Cut the ring at sl_node (index 0 == index N-1) rather than
# at the narrowest cross-section. branch_a[-1] is already
# sl_node == ring_coords[0] == ring_coords[-1], so
# ring_coords[1:] continues seamlessly from the junction with
# no spatial jump and no U-turn kink at sl_node mid-traversal.
# Using argmin(dist) as the cut could place the seam anywhere
# mid-ring, causing a chord jump at sl_node and a backtrack
# segment that systematically biases the measured body length.
branch_a = _path_coords(endpoint_branches[0][1], G_no_loop)[::-1]
result = np.vstack([branch_a, ring_coords[1:]])
if len(result) >= 4:
return result
else:
# ── Case 2: pure "O" shape — full ring, no free tails ────────────
# Graph: J ↺ (no degree-1 endpoints after self-loop removal)
# Pure "O" — no overhangs: cut the ring at min-width.
r2 = np.clip(ring_coords[:, 0].astype(int), 0,
skel_obj.source_image.shape[0] - 1)
c2 = np.clip(ring_coords[:, 1].astype(int), 0,
skel_obj.source_image.shape[1] - 1)
cut_idx = int(np.argmin(skel_obj.source_image[r2, c2]))
ordered = np.concatenate(
[ring_coords[cut_idx:], ring_coords[1 : cut_idx + 1]], axis=0
)
if len(ordered) >= 4:
return ordered
# ── Case 6 — non-self-loop cycle: parallel-arc ring (omega / Ω shape) ────
if not nx.is_forest(G):
# ── Case 6: two junctions connected by two parallel arcs ───────────────
# Graph: ep1 — J1 ══(short arc)══ J2 — ep2
# ══(long arc) ══
# Detected when nx.find_cycle returns exactly 2 edges sharing the same
# unordered node pair {J1, J2} (i.e. parallel edges in the MultiGraph).
# The ring skeleton has exactly two junction nodes (u_j, v_j) that are
# connected by TWO parallel arcs: a short crossing-stub arc and a long
# ring-body arc. Endpoint branches extend outward from each junction
# to the two free tips.
#
# The worm body physically crosses over itself at the junction region,
# passing through that crossing TWICE — once on the way in and once on
# the way out after going around the big ring. The correct centerline
# therefore visits the crossing arc twice:
#
# ep_A → branch_A → u_j → short_arc → v_j
# → long_arc_reversed → u_j → short_arc → v_j
# → branch_B → ep_B
#
# This gives a continuous, gap-free path that faithfully represents the
# worm topology. The short arc appears twice in the coordinate array,
# which is biologically correct: both body layers at the crossing are
# traced.
#
# Detection: the cycle returned by nx.find_cycle has exactly 2 edges
# that share the same unordered junction-node pair — i.e. they are
# parallel edges in the MultiGraph.
try:
cycle_edges = nx.find_cycle(G)
except nx.NetworkXNoCycle:
cycle_edges = []
parallel_arc_result = None
if len(cycle_edges) == 2:
(ca_u, ca_v, ca_k), (cb_u, cb_v, cb_k) = cycle_edges
if {ca_u, ca_v} == {cb_u, cb_v}: # same junction pair
u_j, v_j = ca_u, ca_v
# Identify short arc (lower arc length) and long arc.
arcs = []
for k in list(G[u_j][v_j].keys()):
d = G[u_j][v_j][k]
arcs.append((d.get("length", np.inf), k, d.get("path_idx")))
arcs.sort() # shortest first
if len(arcs) >= 2:
_, short_k, short_pi = arcs[0]
_, long_k, long_pi = arcs[1]
def _arc_coords(pi: int, from_node: int) -> np.ndarray:
"""Path coords for arc pi, oriented so coords[0] is from_node."""
c = skel_obj.path_coordinates(pi)
if int(paths_list[pi][0]) != from_node:
c = c[::-1]
return c
# arc_a: u_j → v_j (first arc in sorted order)
# arc_b: u_j → v_j (second arc in sorted order)
# arc_a_r / arc_b_r: same arcs reversed (v_j → u_j)
arc_a = _arc_coords(short_pi, u_j) # u_j → v_j
arc_b = _arc_coords(long_pi, u_j) # u_j → v_j
arc_a_r = arc_a[::-1] # v_j → u_j
arc_b_r = arc_b[::-1] # v_j → u_j
# Find the endpoint branch attached to each junction.
# Remove both parallel arcs; remaining edges are branches.
G_branches = G.copy()
for k in list(G_branches[u_j][v_j].keys()):
G_branches.remove_edge(u_j, v_j, k)
def _branch_to_ep(start_node: int) -> np.ndarray | None:
"""Return coords from start_node to its nearest endpoint."""
eps = [
n for n in nx.node_connected_component(G_branches, start_node)
if G_branches.degree(n) <= 1 and n != start_node
]
if not eps:
return np.empty((0, 2))
ep = eps[0]
try:
npath = nx.shortest_path(G_branches, start_node, ep)
except nx.NetworkXNoPath:
return np.empty((0, 2))
segs: list[np.ndarray] = []
for ki in range(len(npath) - 1):
u2, v2 = npath[ki], npath[ki + 1]
pi2 = _edge_data(G_branches, u2, v2).get("path_idx")
if pi2 is None:
continue
seg = skel_obj.path_coordinates(pi2)
if int(paths_list[pi2][0]) != u2:
seg = seg[::-1]
if segs:
seg = seg[1:]
segs.append(seg)
return np.vstack(segs) if segs else np.empty((0, 2))
branch_a = _branch_to_ep(u_j) # u_j outward → ep_A
branch_b = _branch_to_ep(v_j) # v_j outward → ep_B
# ── Choose which arc is the bridge (traversed twice) ──────
# Two candidate topologies:
# T1: ep_A → u_j → arc_a → v_j → arc_b_r → u_j → arc_a → v_j → ep_B
# T2: ep_A → u_j → arc_b → v_j → arc_a_r → u_j → arc_b → v_j → ep_B
# Score each by summing cos(turning angle) at all 4 seams.
def _seam_cos_c6(arr_prev: np.ndarray,
arr_next: np.ndarray) -> float:
"""Cosine of turning angle: arr_prev[-1] is the junction pixel;
arr_next[0] is the first pixel AFTER the junction."""
if len(arr_prev) < 2 or len(arr_next) < 1:
return 0.0
wi = _arc_len_index(arr_prev, dir_vector_len_px, from_end=True)
wo = _arc_len_index(arr_next, dir_vector_len_px, from_end=False)
v_in = arr_prev[-1] - arr_prev[-1 - wi]
v_out = arr_next[wo] - arr_prev[-1]
n_in = np.linalg.norm(v_in)
n_out = np.linalg.norm(v_out)
if n_in == 0 or n_out == 0:
return 0.0
return float(np.dot(v_in, v_out) / (n_in * n_out))
def _build_c6(bridge: np.ndarray,
single_r: np.ndarray) -> tuple[np.ndarray, float]:
"""
Assemble one Case 6 candidate and compute its smoothness
score. bridge goes u_j → v_j; single_r goes v_j → u_j.
Layout: ep_A→u_j | bridge | single_r | bridge | v_j→ep_B
"""
# Build as a list of segments (junction pixel already at
# end of previous segment; duplicate dropped from start
# of next segment — consistent with Cases 4/5).
seg_entry = branch_a[::-1] # ep_A → u_j
seg_bridge = bridge[1:] # u_j → v_j (drop dup)
seg_single = single_r[1:] # v_j → u_j (drop dup)
seg_bridge2= bridge[1:] # u_j → v_j (second pass)
seg_exit = branch_b[1:] # v_j → ep_B (drop dup)
segs: list[np.ndarray] = []
if len(seg_entry):
segs.append(seg_entry)
segs.append(seg_bridge if segs else bridge)
segs.append(seg_single)
segs.append(seg_bridge2)
if len(seg_exit):
segs.append(seg_exit)
# Only score the entry (branch_A → bridge) and exit
# (bridge2 → branch_B) seams. The two turnaround seams
# at v_j / u_j are structural U-turns that are roughly
# equal for both topologies and do not help discriminate.
score = 0.0
if len(seg_entry): # seam 0: entry → bridge
score += _seam_cos_c6(segs[0], segs[1])
if len(seg_exit): # last seam: bridge2 → exit
score += _seam_cos_c6(segs[-2], segs[-1])
return np.vstack(segs), score
# ── Bridge selection ────────────────────────────────────
# Since arcs is sorted ascending, arc_a <= arc_b always.
# If arc_a < min_bridge_arc_len_px then at least one arc is
# too short for reliable direction scoring → skip scoring
# entirely and use the shorter arc (arc_a) as bridge.
# Only follow the score when both arcs exceed the threshold.
arc_a_len_px = float(arcs[0][0])
if arc_a_len_px < min_bridge_arc_len_px:
arr_t1, score_t1 = _build_c6(arc_a, arc_b_r)
result = arr_t1
else:
arr_t1, score_t1 = _build_c6(arc_a, arc_b_r) # arc_a is bridge
arr_t2, score_t2 = _build_c6(arc_b, arc_a_r) # arc_b is bridge
result = arr_t1 if score_t1 >= score_t2 else arr_t2
if len(result) >= 4:
parallel_arc_result = result
if parallel_arc_result is not None:
return parallel_arc_result
# Fallback A — general multi-edge cycle (e.g. triangular junction blob):
# The cycle has more than 2 edges, or its 2 edges don't share the same
# node pair, so it doesn't match Case 6. Break cycles at the shortest
# arc and fall through to Case 1 below.
n_cycles_broken = G.number_of_edges() - (G.number_of_nodes() - nx.number_connected_components(G))
_pfx = f"[{img_name}] " if img_name else ""
warnings.warn(
f"{_pfx}Fallback A: skeleton graph has an unrecognised cycle topology "
f"(cycle edges returned: {len(cycle_edges)}, "
f"estimated cycles to break: {max(n_cycles_broken, 1)}). "
f"Breaking cycles at minimum-width arcs and falling back to Case 1 "
f"(normal endpoint search). The centerline may be suboptimal.",
RuntimeWarning,
stacklevel=3,
)
_break_cycles_at_minimum_width(G, skel_obj, paths_list)
# ── Case 1 — normal worm (acyclic graph, or cycle-broken fallback) ────────
# Graph: ep1 — [optional intermediate junctions] — ep2
# Also handles any topology that was reduced to a forest by
# _break_cycles_at_minimum_width above.
endpoints = [n for n in G.nodes() if G.degree(n) == 1]
if len(endpoints) < 2:
# Fallback B — fewer than 2 degree-1 endpoints after all cycle handling.
# Typical cause: degenerate / very small mask whose skeleton collapsed to
# a single pixel or an isolated junction node with no leaf edges. Promote
# all nodes to candidates so the path search can still attempt a result.
_pfx = f"[{img_name}] " if img_name else ""
warnings.warn(
f"{_pfx}Fallback B: only {len(endpoints)} degree-1 endpoint(s) found after "
f"cycle handling (graph has {G.number_of_nodes()} node(s), "
f"{G.number_of_edges()} edge(s)). "
f"Promoting all nodes to endpoint candidates. "
f"The skeleton may be degenerate or the mask too small.",
RuntimeWarning,
stacklevel=3,
)
endpoints = list(G.nodes())
best_length = -1.0
best_node_path = None
for i in range(len(endpoints)):
for j in range(i + 1, len(endpoints)):
try:
# nx.shortest_path is used here because after all cycle-breaking
# the graph is a forest (tree), so there is exactly ONE path
# between any two nodes — "shortest" simply means "the only path"
# at the graph-topology level. The "longest" selection below
# operates at the arc-length metric level: among all endpoint
# pairs we keep whichever unique path has the greatest total
# pixel arc length (sum of edge "length" attributes), which
# corresponds to the true head-to-tail body length of the worm.
node_path = nx.shortest_path(G, endpoints[i], endpoints[j])
total = sum(
_edge_data(G, node_path[k], node_path[k + 1])["length"]
for k in range(len(node_path) - 1)
)
if total > best_length:
best_length = total
best_node_path = node_path
except nx.NetworkXNoPath:
continue
if best_node_path is None:
_pfx = f"[{img_name}] " if img_name else ""
warnings.warn(
f"{_pfx}Skeleton graph traversal failed: no path found between any pair of "
f"endpoints (graph has {G.number_of_nodes()} node(s), "
f"{G.number_of_edges()} edge(s)). "
f"The graph may be fully disconnected after cycle breaking. "
f"Returning None centerline.",
RuntimeWarning,
stacklevel=2,
)
return None
all_coords: list[np.ndarray] = []
for k in range(len(best_node_path) - 1):
u, v = best_node_path[k], best_node_path[k + 1]
edata = _edge_data(G, u, v)
path_idx = edata["path_idx"]
seg_coords = skel_obj.path_coordinates(path_idx) # (M, 2) row-col
px_path = paths_list[path_idx]
if int(px_path[0]) != u:
seg_coords = seg_coords[::-1]
if k > 0:
seg_coords = seg_coords[1:] # drop duplicate junction pixel
all_coords.append(seg_coords)
return np.vstack(all_coords) if all_coords else None
# ---------------------------------------------------------------------------
# Core skeletonization
# ---------------------------------------------------------------------------
def extract_centerline(
mask: np.ndarray,
n_points: int = 100,
px_per_point: float | None = None,
smooth_factor: float | None = None,
smooth_factor_ratio: float = 1.0,
contour_smooth_sigma: float = 2.0,
hole_fill_ratio_threshold: float = 0.05,
hole_keep_circularity_weight: float = 0.5,
ring_dilation_radius: int = 0,
dir_vector_len_px: float = 20.0,
min_bridge_arc_len_px: float = 20.0,
debug_ring_mask_path: str | None = None,
img_name: str = "",
) -> tuple[np.ndarray | None, np.ndarray | None, bool, np.ndarray]:
"""
Fit a smooth centerline to a single binary worm mask.
Parameters
----------
mask :
2-D uint8 / bool array (H × W). Non-zero pixels = foreground.
n_points :
Number of evenly-spaced points on the output spline.
smooth_factor :
Spline smoothing parameter passed to ``scipy.interpolate.splprep``.
``None`` → auto (``smooth_factor_ratio * len(skeleton_pixels)``).
smooth_factor_ratio :
Multiplier applied to ``len(skeleton_pixels)`` when ``smooth_factor``
is ``None``. ``1.0`` (default) matches SciPy's recommended midpoint.
``0`` → exact interpolation (wiggly); ``>1`` → heavier smoothing.
Typical values: 0, 0.5, 1.0, 5.0.
contour_smooth_sigma :
Standard deviation (pixels) of the Gaussian blur applied to the
binary mask **before** computing the medial axis. This rounds off
sharp polygon corners in the YOLO mask that would otherwise produce
spurious branches in the skeleton. Set to 0 to disable.
hole_fill_ratio_threshold :
Fraction of the original mask area that ``binary_fill_holes`` must add
before the mask is treated as a topologically significant ring ("O" /
"6" shape) and hole-filling is skipped. Lower values make the
detection more sensitive to smaller loops. Default 0.05 (5%).
hole_keep_circularity_weight :
Weight in ``[0, 1]`` controlling how much circularity vs normalised
area contributes to the score used to decide which enclosed hole is
the loop interior (and therefore kept unfilled). The combined score
per hole is::
score = w * circularity + (1 - w) * (area / max_area)
where ``w`` = this parameter. The hole with the **highest** score is
kept unfilled; all others (body-overlap gaps) are filled.
``1.0`` = pure circularity; ``0.0`` = pure area; default ``0.5``.
ring_dilation_radius :
Radius (pixels) of the elliptical dilation applied to ``mask_raw``
**before** both the ``is_ring`` detection step and the selective hole
analysis. Dilation strengthens weak or narrow mask connections so
that ``binary_fill_holes`` can see the enclosed loop area, and also
bridges body-overlap gaps before hole labelling. ``mask_raw`` itself
is never modified (it is shown as the "before" panel in the debug
image). Set to 0 (default) to disable.
is_ring_detection_dilation_radius :
Deprecated — now merged into ``ring_dilation_radius``. Ignored.
dir_vector_len_px :
Arc length in pixels measured from the junction seam in each direction
to estimate the incoming and outgoing directional vectors for
smoothness scoring. The helper walks the skeleton coordinate array
until the cumulative Euclidean distance reaches this value, then uses
the chord from that anchor point to the junction. Larger values
average out local pixel-level noise; if a segment is shorter than the
target the full segment is used. Default 20.0.
min_bridge_arc_len_px :
Minimum arc length (pixels) required for the Case 6 (omega / Ω ring)
direction-scoring step to be considered reliable. Since skan arcs are
sorted ascending, ``arc_a`` (shorter) is always <= ``arc_b`` (longer).
If ``arc_a < min_bridge_arc_len_px`` the direction vectors are too
short to be trustworthy, so scoring is skipped entirely and arc_a
(the shorter arc) is used as the bridge — the heuristic that minimises
total repeated path length. Default 20.0.
debug_ring_mask_path :
Optional file path (e.g. ``".../img_f00_ring.jpg"``) at which to save
a 2-panel debug image showing the mask before and after selective hole
fill. Only written when ``is_ring`` is ``True``. ``None`` = disabled.
Returns
-------
centerline :
``(n_points, 2)`` float array of **(row, col)** positions, or ``None``.
width_profile :
``(n_points,)`` float array of local body *diameter* in pixels
(= 2 × distance-transform value at each centerline point), or ``None``.
success : bool
effective_mask :
The hole-filled boolean mask actually used for skeletonization
(same shape as *mask*). Useful for callers that need to know
which pixels are considered foreground after selective hole filling.
"""
mask_raw = mask.astype(bool)
if mask_raw.sum() < 20:
return None, None, False, mask_raw
# ── Step 0: topology-aware hole filling ────────────────────────────────
# ``binary_fill_holes`` removes small internal voids — desirable for normal
# worms. BUT for self-overlapping worms ("O" / "6" shapes) the worm body
# forms a ring, so the large enclosed background region must stay unfilled
# or the skeleton degenerates into a solid disc.
#
# For ring masks YOLO can produce TWO kinds of holes:
# • One LARGE hole = background pixels enclosed by the loop body.
# Must be kept unfilled to preserve ring topology.
# • One/more SMALL holes = overlapping body segments that YOLO left
# transparent. Should be filled so the skeleton
# runs continuously through the crossing region.
#
# Strategy:
# 1. Detect whether the mask is a ring via the hole-area heuristic.
# 2. For normal worms: fill all holes with ``binary_fill_holes``.
# 3. For ring worms: label all enclosed background components, keep the
# LARGEST one unfilled (= background loop interior), and fill every
# smaller component (= occluded-body gaps).
# Optionally dilate mask_raw before hole-filling to bridge weak/narrow
# connections (e.g. a nearly-open loop). The dilated mask is used for
# both the is_ring detection step and the subsequent hole analysis.
# mask_raw itself is never modified (shown as "before" in the debug image).
if ring_dilation_radius > 0:
_ksize_rd = 2 * ring_dilation_radius + 1
_kernel_rd = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (_ksize_rd, _ksize_rd))
mask_work = cv2.dilate(mask_raw.astype(np.uint8), _kernel_rd).astype(bool)
else:
mask_work = mask_raw
mask_filled = binary_fill_holes(mask_work)
added_ratio = (mask_filled.sum() - mask_work.sum()) / max(mask_work.sum(), 1)
is_ring = added_ratio > hole_fill_ratio_threshold
if is_ring:
# Components that touch the image border are outer background; all
# others are enclosed holes.
inv = ~mask_work
labeled_bg, n_bg = sk_label(inv, connectivity=1, return_num=True)
border_labels: set[int] = set()
border_labels.update(int(v) for v in labeled_bg[0, :])
border_labels.update(int(v) for v in labeled_bg[-1, :])
border_labels.update(int(v) for v in labeled_bg[:, 0])
border_labels.update(int(v) for v in labeled_bg[:, -1])
border_labels.discard(0) # 0 = foreground pixels, ignore
hole_labels = [
lbl for lbl in range(1, n_bg + 1) if lbl not in border_labels
]
if len(hole_labels) > 1: # noqa: SIM102
# Multiple holes: keep the hole with the highest combined score
# of circularity (loop interior ≈ round) and normalised area
# (loop interior is usually larger than body-overlap gaps).
#
# score = w * circularity + (1-w) * (area / max_area)
# w = hole_keep_circularity_weight (0 = pure area, 1 = pure circularity)
def _circularity(lbl: int) -> float:
hole_mask_u8 = (labeled_bg == lbl).astype(np.uint8)
cnts, _ = cv2.findContours(
hole_mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
if not cnts:
return 0.0
area = float(cv2.contourArea(cnts[0]))
perim = float(cv2.arcLength(cnts[0], closed=True))
if perim < 1e-6:
return 0.0
return (4.0 * np.pi * area) / (perim ** 2)
hole_areas_raw = [
(int((labeled_bg == lbl).sum()), lbl) for lbl in hole_labels
]
max_area = max(a for a, _ in hole_areas_raw) or 1
w = float(np.clip(hole_keep_circularity_weight, 0.0, 1.0))
hole_scores = [
(
w * _circularity(lbl) + (1.0 - w) * (area / max_area),
lbl,
)
for area, lbl in hole_areas_raw
]
hole_scores.sort(reverse=True) # highest score first
keep_lbl = hole_scores[0][1] # loop interior
fill_mask = mask_work.copy()
for _, lbl in hole_scores[1:]: # fill all lower-scoring holes
fill_mask[labeled_bg == lbl] = True
mask_bool = fill_mask
else:
# Zero or one enclosed hole — nothing extra to fill.
mask_bool = mask_work
else:
# Normal (non-ring) worm: fill holes on the ORIGINAL (undilated) mask.
# ring_dilation_radius was applied to mask_work solely for the is_ring
# detection and hole-labelling step above. Using it here would
# artificially fatten the worm body, widening the medial axis and
# biasing the width profile. mask_raw is always the unmodified input.
mask_bool = binary_fill_holes(mask_raw)
# ── Debug: save ring mask processing stages ─────────────────────────────
if is_ring and debug_ring_mask_path is not None:
def _mask_panel(m: np.ndarray, title: str) -> np.ndarray:
"""Convert bool mask to an annotated BGR panel."""
bgr = np.zeros((*m.shape, 3), dtype=np.uint8)
bgr[m] = (200, 200, 200)
cv2.putText(bgr, title, (4, 16),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 220, 255), 1, cv2.LINE_AA)
return bgr
dil_tag = f", dil={ring_dilation_radius}px" if ring_dilation_radius > 0 else ""
panels = [
_mask_panel(mask_raw, "1: Original"),
_mask_panel(mask_bool, f"2: Selective fill (circ_w={hole_keep_circularity_weight:.2f}{dil_tag})"),
]
sep = np.zeros((mask_raw.shape[0], 3, 3), dtype=np.uint8)
debug_ring = np.concatenate([panels[0], sep, panels[1]], axis=1)
cv2.imwrite(debug_ring_mask_path, debug_ring)
# ── Step 1: smooth mask contour ─────────────────────────────────────────
# YOLO polygon masks have a limited vertex count, leaving sharp 90° corner
# artifacts. The medial axis branches toward each such corner, fragmenting
# the skeleton into many short segments at junction nodes. A Gaussian blur
# followed by re-thresholding at 0.5 rounds these corners before the medial
# axis is computed.
effective_sigma = contour_smooth_sigma
if effective_sigma > 0:
ksize = int(effective_sigma * 6) | 1 # next odd number ≥ 6σ
blurred = cv2.GaussianBlur(
mask_bool.astype(np.float32), (ksize, ksize), effective_sigma
)
smoothed = blurred > 0.5
# Re-apply hole-fill only when the mask did NOT have a significant hole
# (to avoid re-introducing the solid-disc problem on "O" shapes).
if not is_ring:
mask_bool = binary_fill_holes(smoothed)
else:
mask_bool = smoothed
# Medial axis + Euclidean distance transform in one call.