Spaces:
Sleeping
Sleeping
File size: 45,272 Bytes
a1b81e3 | 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 | import math
from collections import defaultdict, deque
import vtk
ABDOMEN_LABELS = ['Celiac Trunk', 'Superior Mesenteric', 'Renal', 'Renal'] # The 4 abdominal artery labels, assigned top to bottom
FORK_DEDUPLICATION_RADIUS_MM = 3.0 # If 2 bifurcations have distance smaller than this, then they're duplicates of each other
AORTA_CONTINUATION_RATIO = 0.75 # At bifurcation, 2nd segment (outlet) must be at least this fraction of the thickest (inlet) to be considered aorta continuation
ABDOMEN_CLUSTER_MAX_GAP_MM = 50.0 # Maximum gap (mm) within the abdominal cluster
THORACIC_GAP_MM = 80.0 # Minimum gap (mm) separating the thoracic artery cluster from the abdominal cluster
ILIAC_Z_DROP_RATIO = 1.5 # Z-drop must be at least this times the X-spread
# Helper that sums euclidean distances between consecutive points in a segment
def calculate_length(points):
length = 0.0
for i in range(len(points) - 1):
p1, p2 = points[i], points[i + 1]
length += math.sqrt((p2['x'] - p1['x']) ** 2 + (p2['y'] - p1['y']) ** 2 + (p2['z'] - p1['z']) ** 2)
return length
# Returns a unit length 3D vector
def normalize_vector(v):
norm = math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
if norm > 0:
return (v[0] / norm, v[1] / norm, v[2] / norm)
return (0.0, 0.0, 0.0)
# Computes average direction vector near a segment endpoint (bifurcation side)
def calculate_average_direction(points, is_start_at_bifurcation, limit_points):
if not points or len(points) < 2:
return (0, 0, 0)
n = min(len(points), limit_points)
# Walk away from the bifurcation: forward if bifurcation is at start, backward if at end
if is_start_at_bifurcation:
pairs = [(points[i], points[i + 1]) for i in range(n - 1)]
else:
end = len(points) - 1
pairs = [(points[i], points[i - 1]) for i in range(end, end - n + 1, -1)]
total = [0.0, 0.0, 0.0]
valid = 0
for a, b in pairs:
dx, dy, dz = b['x'] - a['x'], b['y'] - a['y'], b['z'] - a['z']
norm = math.sqrt(dx * dx + dy * dy + dz * dz)
if norm > 0:
# Each total will contain the sum of unit vectors
total[0] += dx / norm
total[1] += dy / norm
total[2] += dz / norm
valid += 1
if valid == 0:
return (0, 0, 0)
# Normalize the total after dividing by thier length to get the mean
return normalize_vector((total[0] / valid, total[1] / valid, total[2] / valid))
# Helper that sets label on a centerline segment and its points
def apply_label(segment, label):
segment['label'] = label
"""for pt in segment['points']:
pt['label'] = label"""
# Main Pipeline for extracting centerline data, labeling bifurcations and arteries, computing ostium and reference point positions
def extract_centerline_data(centerlines, branch_junctions=None):
# Parse VTK centerline polydata into Python list (segments containing points information) and dictionary (mapping each individual point to its parent segment)
segments, point_to_segments = parse_centerline_polydata(centerlines)
# Identify bifurcation points of all arteries, supplemented with known branch contact points
all_bifurcations = find_bifurcation_points(centerlines, branch_junctions)
# Adds segment fork points (segment diverges) to all_bifurcations, useful for When VMTK produces only few long cells that share the entire aorta which ruins polydata
fork_pts = find_segment_fork_points(segments, point_to_segments)
all_bifurcations, bifurcation_ids = merge_bifurcations_with_fork_points(all_bifurcations, fork_pts)
# Splits diverging segments at all bifurcations into smaller sub segments that can be modified independently
segments, point_to_segments = split_segments_at_bifurcations(segments, bifurcation_ids)
# Computes thickest segment branching from each bifurcation point
all_bifurcation_arms = {}
for bif in all_bifurcations:
result = compute_bifurcation_arms(bif, segments, point_to_segments)
if result is not None:
all_bifurcation_arms[bif['id']] = result
print(f"Bifurcations total={len(all_bifurcations)}, real branch points (3+ clusters)={len(all_bifurcation_arms)}")
# Label centerline segments Aorta or Artery at every bifurcation, returns bifurcation map which maps bifurcation id to its bifurcation point information and connected arms
bifurcations_map, bifurcations_info = label_aorta_and_arteries(segments, point_to_segments, all_bifurcations, all_bifurcation_arms)
# Detect if dataset contains thoracic arch or not
thoracic_info = detect_thoracic_region(centerlines)
has_thoracic = thoracic_info['detected']
print(f"has_thoracic={has_thoracic}, reversal_mm={thoracic_info['reversal_mm']}")
# Classify artery junctions into thoracic, abdomen, iliac zones, determine iliac cutoff, and relabel artery segments to match new classification
iliac_cutoff = classify_arteries_by_zone(bifurcations_info, segments, has_thoracic)
# Identify named abdomen arteries (Celiac Trunk / Superior Mesenteric / Renal), return ostium positions
abdomen_ostia = identify_abdominal_arteries(segments, point_to_segments, bifurcations_info)
# Place reference point at the most caudal abdomen bifurcation (junction), not at the derived ostium point
reference_point = compute_reference_point(segments, bifurcations_info, abdomen_ostia)
response = build_response(centerlines, segments, abdomen_ostia, reference_point, iliac_cutoff, bifurcations_map, bifurcations_info)
return response
# Converts centerline polydata into a python list that contains line segments information (unique segment id, points in that segment, radius of each point, label)
def parse_centerline_polydata(centerlines):
# Extracts centerline information
pts = centerlines.GetPoints()
lines = centerlines.GetLines()
radius_array = centerlines.GetPointData().GetArray('Radius')
segments = []
point_to_segments = defaultdict(list) # Maps each centerline point to the line segment it belongs to, auto creates a default value when a missing key is accessed
# Initalizes looping over the centerline segments
lines.InitTraversal()
id_list = vtk.vtkIdList()
seg_idx = 0
while lines.GetNextCell(id_list):
num_ids = id_list.GetNumberOfIds()
if num_ids < 2: # If number of points in this line segment is less than 2
continue
segment_points = []
seen_ids_in_seg = set() # Track unique point IDs within this segment
for i in range(num_ids):
point_id = id_list.GetId(i)
if point_id in seen_ids_in_seg:
continue # Skip duplicate point IDs within the same segment
seen_ids_in_seg.add(point_id)
pt = pts.GetPoint(point_id)
radius = radius_array.GetValue(point_id)
segment_points.append({'id': point_id, 'x': float(pt[0]), 'y': float(pt[1]), 'z': float(pt[2]), 'radius': float(radius), 'label': None})
# Filter zero-length segments (noise)
length = calculate_length(segment_points)
if length > 0.0:
# Map ALL points to their containing segment (not just start/end), this ensures bifurcation points (interior to cells) can find connected segments
for pt_entry in segment_points:
point_to_segments[pt_entry['id']].append(seg_idx)
segments.append({'points': segment_points, 'length': length, 'label': None})
seg_idx += 1
return segments, point_to_segments
# Computes bifurcation points which represent start point of any branching artery
def find_bifurcation_points(centerlines, known_junctions=None):
# Extracts centerline data
pts = centerlines.GetPoints()
lines = centerlines.GetLines()
radius_array = centerlines.GetPointData().GetArray('Radius')
# Traverse through each line, then for each point in each line we increment its degree which represents number of line cells touching that point
degree = defaultdict(int)
lines.InitTraversal()
id_list = vtk.vtkIdList()
while lines.GetNextCell(id_list):
for i in range(id_list.GetNumberOfIds()):
degree[id_list.GetId(i)] += 1
bifurcations = []
found_ids = set()
# Degree 1 = endpoint, 2 = mid-segment, 3+ = bifurcation
for point_id, deg in degree.items():
if deg >= 3:
pt = pts.GetPoint(point_id)
radius = radius_array.GetValue(point_id)
found_ids.add(point_id)
bifurcations.append({'id': point_id, 'x': float(pt[0]), 'y': float(pt[1]), 'z': float(pt[2]), 'radius': float(radius)})
# Supplement with known junction coordinates from the pipeline (branch contact points)
# This ensures bifurcations are detected even if vtkCleanPolyData didn't fully merge junction point IDs
if known_junctions:
locator = vtk.vtkPointLocator()
locator.SetDataSet(centerlines)
locator.BuildLocator()
for junction_xyz in known_junctions:
closest_id = locator.FindClosestPoint(junction_xyz)
if closest_id < 0 or closest_id in found_ids:
continue
if degree.get(closest_id, 0) < 3:
continue
pt = pts.GetPoint(closest_id)
radius = 0.0
if radius_array:
radius = radius_array.GetValue(closest_id)
found_ids.add(closest_id)
bifurcations.append({'id': closest_id, 'x': float(pt[0]), 'y': float(pt[1]), 'z': float(pt[2]), 'radius': float(radius)})
return bifurcations
# Detects fork points where overlapping segments diverge
def find_segment_fork_points(segments, point_to_segments):
# For each segment, walk from startβend and endβstart looking for the transition from 'shared with other segments' to 'alone'
# The last shared point before the unshared tail is a real bifurcation (fork). This catches junctions that degree-based detection misses
fork_points = []
for seg_i, seg in enumerate(segments):
pts = seg['points']
if len(pts) < 4:
continue
# Build per-point "other segment count" (how many OTHER segments also contain this point)
others = []
for p in pts:
segments_at = set(point_to_segments.get(p['id'], []))
segments_at.discard(seg_i) # Remove CURRENT segment we are at as we are only interested in the count of other segments
others.append(len(segments_at))
# Walk startβend: find last shared point before an unshared tail
for k in range(len(pts) - 1):
if others[k] > 0 and others[k + 1] == 0:
fork_points.append(pts[k])
break # only save the outermost fork from this side
# Walk endβstart: same check from the other direction
for k in range(len(pts) - 1, 0, -1):
if others[k] > 0 and others[k - 1] == 0:
fork_points.append(pts[k])
break
return fork_points
# Merges calculated bifurcations with detected fork points without any duplications
def merge_bifurcations_with_fork_points(all_bifurcations, fork_pts):
bifurcation_ids = {b['id'] for b in all_bifurcations}
# Build a spatial locator on the existing degree-based bifurcations so fork points that are physically the same junction are rejected
existing_bif_polydata = vtk.vtkPolyData()
existing_bif_pts = vtk.vtkPoints()
for b in all_bifurcations:
existing_bif_pts.InsertNextPoint(b['x'], b['y'], b['z'])
existing_bif_polydata.SetPoints(existing_bif_pts)
bif_locator = vtk.vtkPointLocator()
bif_locator.SetDataSet(existing_bif_polydata)
bif_locator.BuildLocator()
for fp in fork_pts:
if fp.get('radius', 0) < 0.5:
continue
if fp['id'] in bifurcation_ids:
continue
# Reject if any existing bifurcation is within threshold
if existing_bif_pts.GetNumberOfPoints() > 0:
nearest_id = bif_locator.FindClosestPoint(fp['x'], fp['y'], fp['z'])
nearest_pt = existing_bif_pts.GetPoint(nearest_id)
dist = math.sqrt((fp['x'] - nearest_pt[0]) ** 2 + (fp['y'] - nearest_pt[1]) ** 2 + (fp['z'] - nearest_pt[2]) ** 2)
if dist < FORK_DEDUPLICATION_RADIUS_MM:
continue
all_bifurcations.append({'id': fp['id'], 'x': fp['x'], 'y': fp['y'], 'z': fp['z'], 'radius': fp['radius']})
bifurcation_ids.add(fp['id'])
# Add the new point to the locator so subsequent fork points also deduplicate against it
existing_bif_pts.InsertNextPoint(fp['x'], fp['y'], fp['z'])
bif_locator.BuildLocator()
return all_bifurcations, bifurcation_ids
# Splits segments that pass through bifurcation points into sub-segments so that each sub-segment can be independently labeled as Aorta or Artery
def split_segments_at_bifurcations(segments, bifurcation_ids):
new_segments = []
new_points_to_segments = defaultdict(list)
seg_idx = 0
for seg in segments:
# Find positions of interior bifurcation points (skip endpoints)
pts = seg['points']
split_at = []
for k in range(1, len(pts) - 1):
if pts[k]['id'] in bifurcation_ids:
split_at.append(k)
# If no interior bifurcation is found at this segment, thenk keep segment unchanged
if not split_at:
for p in pts:
new_points_to_segments[p['id']].append(seg_idx)
new_segments.append(seg)
seg_idx += 1
continue
# Split at each interior bifurcation, the bifurcation point is included in both adjacent sub-segments
bounds = [0] + split_at + [len(pts) - 1]
for i in range(len(bounds) - 1):
sub_segment = pts[bounds[i] : bounds[i + 1] + 1]
if len(sub_segment) < 2:
continue
length = calculate_length(sub_segment)
if length < 0.5:
continue
for p in sub_segment:
new_points_to_segments[p['id']].append(seg_idx)
new_segments.append({'points': sub_segment, 'length': length, 'label': None})
seg_idx += 1
return new_segments, new_points_to_segments
# For a given bifurcation point, computes all directional arms leaving a bifurcation point
# Arms in a similar direction are clustered together and thickest arm per cluster is kept, returns list of arms sorted by radius (descending)
def compute_bifurcation_arms(bifurcation, segments, point_to_segments):
connected_seg_indices = point_to_segments.get(bifurcation['id'], [])
direction_arms = []
for seg_idx in connected_seg_indices:
# Get segment points
seg = segments[seg_idx]
seg_pts = seg['points']
if not seg_pts:
continue
# Get bifurcation point position on this segment
bif_pos = None
for k, p in enumerate(seg_pts):
if p['id'] == bifurcation['id']:
bif_pos = k
break
if bif_pos is None:
continue
# Forward arm (bifurcation β end of segment)
if bif_pos < len(seg_pts) - 1:
arm = seg_pts[bif_pos:]
skip = min(3, max(0, len(arm) - 2)) # This avoids sampling the first few points right at the junction where radius is inflated by trunk geometry
sample = arm[skip:skip + 10] or arm[-min(5, len(arm)):] # Samples up to 10 points further out for a more accurate branch local radius and direction
local_radius = sum(p['radius'] for p in sample) / len(sample)
direction = calculate_average_direction(arm, True, 10)
direction_arms.append({'seg_idx': seg_idx, 'local_radius': local_radius, 'direction': direction, 'arm_points': arm})
# Backward arm (bifurcation β start of segment)
if bif_pos > 0:
arm = seg_pts[:bif_pos + 1][::-1]
skip = min(3, max(0, len(arm) - 2))
sample = arm[skip:skip + 10] or arm[-min(5, len(arm)):]
local_radius = sum(p['radius'] for p in sample) / len(sample)
direction = calculate_average_direction(arm, True, 10)
direction_arms.append({'seg_idx': seg_idx, 'local_radius': local_radius, 'direction': direction, 'arm_points': arm})
# Cluster by direction similarity (dot product > 0.8 = same direction)
clusters = []
for arm in direction_arms:
arm_direction = arm['direction']
d_norm = math.sqrt(arm_direction[0] ** 2 + arm_direction[1] ** 2 + arm_direction[2] ** 2)
if d_norm == 0:
continue
placed = False
for cluster in clusters:
cluster_direction = cluster[0]['direction']
cd_norm = math.sqrt(cluster_direction[0] ** 2 + cluster_direction[1] ** 2 + cluster_direction[2] ** 2)
if cd_norm == 0:
continue
dot = (arm_direction[0] * cluster_direction[0] + arm_direction[1] * cluster_direction[1] + arm_direction[2] * cluster_direction[2]) / (d_norm * cd_norm)
if dot > 0.8:
cluster.append(arm)
placed = True
break
if not placed:
clusters.append([arm])
# Keep the thickest arm per cluster
bifurcation_arms = []
for cluster in clusters:
best = max(cluster, key=lambda a: a['local_radius'])
bifurcation_arms.append(best)
bifurcation_arms.sort(key=lambda c: c['local_radius'], reverse=True)
if len(bifurcation_arms) <= 2:
return None # Not a true branch point (trunk-overlap)
return bifurcation_arms
# Label every segment as 'Aorta' or 'Artery', further artery classification will build upon these labels
def label_aorta_and_arteries(segments, point_to_segments, bifurcations, bifurcation_arms):
if not bifurcations:
return {}, []
# Reset labels
for seg in segments:
seg['label'] = None
seg['confirmed_aorta'] = False # True means the bifurcation loop explicitly kept it as trunk, False means BFS propagated the label into a branch stub
# Sort bifurcations radius wise descendingly
sorted_bifs = sorted(bifurcations, key=lambda b: b['radius'], reverse=True)
bifurcations_map = {} # Maps bifurcation_id β (bifurcation_dict, connected_segments_list)
bifurcations_info = []
# Classify every bifurcation
for bif in sorted_bifs:
segment_connections = bifurcation_arms.get(bif['id'])
if segment_connections is None:
continue
bifurcations_map[bif['id']] = (bif, segment_connections)
# Retrieve information about connected segments to bifurcation (average radius and aorta vs artery segments count)
arm_info = [(c, calculate_distal_radius(c['arm_points'])) for c in segment_connections]
confirmed_count = sum(1 for c, _ in arm_info if segments[c['seg_idx']].get('confirmed_aorta'))
artery_count = sum(1 for c, _ in arm_info if segments[c['seg_idx']]['label'] == 'Artery')
# If bifurcation is inside an artery sub-tree (no aorta arms), we label all segments connected to this bifurcation as artery
if confirmed_count == 0 and artery_count > 0:
for c, _ in arm_info:
if segments[c['seg_idx']]['label'] is None:
apply_label(segments[c['seg_idx']], 'Artery')
bifurcations_info.append({
'id': int(bif['id']), 'x': float(bif['x']), 'y': float(bif['y']), 'z': float(bif['z']), 'radius': float(bif['radius']),
'artery_type': None, 'gap_to_next_bifurcation': None, 'is_iliac_cutoff': False, # Needed when classifying arteries into different regions
'connected_arms_labels': [segments[c['seg_idx']]['label'] for c, _ in arm_info],
'arm_endpoint_z': [float(c['arm_points'][-1]['z']) if c['arm_points'] else 0.0 for c, _ in arm_info], # Needed when checking if this is iliac cutoff
'arm_endpoint_x': [float(c['arm_points'][-1]['x']) if c['arm_points'] else 0.0 for c, _ in arm_info], # Needed when checking if this is iliac cutoff
})
continue
# Break if there are no arms that are unlabeled or already aorta labeled
aorta_arms = [(c, r) for c, r in arm_info if segments[c['seg_idx']]['label'] in (None, 'Aorta')]
if not aorta_arms:
continue
# Sort connected aorta/None arms descending by radius
aorta_arms_candidates = sorted(aorta_arms, key=lambda x: x[1], reverse=True)
# Decide number of arms that belong to aorta trunk
aorta_arms_count = compute_aorta_arms_count(aorta_arms_candidates, segments)
largest_arm_radius = aorta_arms_candidates[0][1] # Largest radius of aorta arms
# Apply labels to the connected arms to this bifurcation
for i, (c, _) in enumerate(aorta_arms_candidates):
seg = segments[c['seg_idx']]
if i < aorta_arms_count:
apply_label(seg, 'Aorta')
seg['confirmed_aorta'] = True
else:
if not seg.get('confirmed_aorta'):
apply_label(seg, 'Artery')
# Handle direction-clustered hidden segments (At noisy bifurcations, multiple connected segments may exist at the same bifurcation point)
connection_segments = set(c['seg_idx'] for c, _ in arm_info)
all_connected = set(point_to_segments.get(bif['id'], []))
for seg_idx in (all_connected - connection_segments):
seg = segments[seg_idx]
if seg.get('confirmed_aorta'):
continue
seg_r = 0.0
pts = segments[seg_idx]['points']
if pts:
seg_r = sum(p['radius'] for p in pts) / len(pts)
if largest_arm_radius > 0 and seg_r / largest_arm_radius >= AORTA_CONTINUATION_RATIO:
apply_label(seg, 'Aorta')
seg['confirmed_aorta'] = True
elif seg['label'] is None:
apply_label(seg, 'Artery')
bifurcations_info.append({
'id': int(bif['id']), 'x': float(bif['x']), 'y': float(bif['y']), 'z': float(bif['z']), 'radius': float(bif['radius']),
'artery_type': None, 'gap_to_next_bifurcation': None, 'is_iliac_cutoff': False,
'connected_arms_labels': [segments[c['seg_idx']]['label'] for c, _ in arm_info],
'arm_endpoint_z': [float(c['arm_points'][-1]['z']) if c['arm_points'] else 0.0 for c, _ in arm_info],
'arm_endpoint_x': [float(c['arm_points'][-1]['x']) if c['arm_points'] else 0.0 for c, _ in arm_info],
})
# Propagate the labeled segments to their unlabeled neighbors
propagate_labeled_segments(segments, point_to_segments)
# Safety net: if no segment ended up labeled 'Aorta' (happens when every junction failed the 3-cluster gate, like a dataset with only simple T-junctions)
# So we re-seed the longest segment as 'Aorta' and re-flood the labels propagation
if not any(seg.get('label') == 'Aorta' for seg in segments):
seeded = max((s for s in segments if s.get('points')), key=lambda s: len(s['points']), default=None)
if seeded is not None:
for s in segments:
s['label'] = None
apply_label(seeded, 'Aorta')
propagate_labeled_segments(segments, point_to_segments)
# Safety-net relabel may change final connected-arm labels
for bif_info in bifurcations_info:
seg_connections = bifurcation_arms.get(bif_info['id'])
if not seg_connections:
continue
bif_info['connected_arms_labels'] = [segments[c['seg_idx']]['label'] for c in seg_connections]
return bifurcations_map, bifurcations_info
# Helper that calculates average inscribed-sphere radius over the distal half of an arm
# Skipping the proximal half avoids radius inflation that occurs when the iliac artery origin lies inside the aortic sac
def calculate_distal_radius(arm_points):
# arm_points[0] is the junction (proximal) end; arm_points[-1] is the distal tip.
n = len(arm_points)
if n == 0:
return 0
start = 0
if n > 4:
start = n // 2
pts = arm_points[start:]
return sum(p['radius'] for p in pts) / len(pts)
# Decide how many of the labeled Aorta/None arms connected to a bifurcation to label/keep as 'Aorta'
def compute_aorta_arms_count(aorta_arms_candidates, segments):
largest_arm_radius = 0
aorta_arms_count = 0
if aorta_arms_candidates:
largest_arm_radius = aorta_arms_candidates[0][1]
# If candidates are 3 or more for a bifurcations (maximum arms count is usually 4) then compute radius differences, where largest gap indicates branch separation
if len(aorta_arms_candidates) >= 3:
radii = [r for _, r in aorta_arms_candidates]
gaps = [radii[i] - radii[i + 1] for i in range(len(radii) - 1)]
aorta_arms_count = gaps.index(max(gaps)) + 1
# If candidates are 2 and they have similar radii, then they both are probably the aorta inlet and outlet of the bifurcation so keep both, otherwise keep only one
elif len(aorta_arms_candidates) == 2:
second_largest_arm_radius = aorta_arms_candidates[1][1]
if (largest_arm_radius > 0 and second_largest_arm_radius / largest_arm_radius >= AORTA_CONTINUATION_RATIO):
aorta_arms_count = 2
else:
aorta_arms_count = 1
else:
aorta_arms_count = 1
if aorta_arms_count <= 1:
return aorta_arms_count
# Add guards only if a confirmed-aorta arm is already in the keep group
confirmed_aorta_max_radius = max((r for c, r in aorta_arms_candidates[:aorta_arms_count] if segments[c['seg_idx']].get('confirmed_aorta')), default=0)
if confirmed_aorta_max_radius == 0:
return aorta_arms_count
# Ratio threshold: any non-confirmed kept arm must be β₯ 75% of aorta trunk
for i in range(aorta_arms_count - 1, 0, -1):
curr_connection, curr_radius = aorta_arms_candidates[i]
if (not segments[curr_connection['seg_idx']].get('confirmed_aorta') and curr_radius < AORTA_CONTINUATION_RATIO * confirmed_aorta_max_radius):
aorta_arms_count = i
break
aorta_arms_count = max(1, aorta_arms_count)
# Check symmetric branches where two thinner arms are closer to each other than to aorta trunk
if aorta_arms_count == 2 and len(aorta_arms_candidates) == 3:
ra = aorta_arms_candidates[0][1]
conn2, rb = aorta_arms_candidates[1]
rc = aorta_arms_candidates[2][1]
if (rb > 0 and ra > 0 and (rc / rb) > (rb / ra) and not segments[conn2['seg_idx']].get('confirmed_aorta')):
aorta_arms_count = 1
return aorta_arms_count
# Fill unlabeled segments with labels from already-labeled neighbors
def propagate_labeled_segments(segments, point_to_segments):
# Add all labeled segments to our queue and set them as visited
queue = deque()
visited = set()
for seg_idx, seg in enumerate(segments):
if seg['label'] is not None:
queue.append(seg_idx)
visited.add(seg_idx)
# Loop over each labeled segment, filling its unlabeled neighbors with the same label
while queue:
# Retrieve current labeled segment info
seg_idx = queue.popleft()
label = segments[seg_idx]['label']
pts = segments[seg_idx]['points']
if not pts:
continue
# Loop over each point in the labeled segment
for point_id in {pts[0]['id'], pts[-1]['id']}:
for neighbor_seg_idx in point_to_segments.get(point_id, []):
# Exit if neighbor was already visited or labeled
if neighbor_seg_idx in visited:
continue
if segments[neighbor_seg_idx]['label'] is not None:
visited.add(neighbor_seg_idx)
continue
# Apply label only to unlabeled segments
visited.add(neighbor_seg_idx)
apply_label(segments[neighbor_seg_idx], label)
queue.append(neighbor_seg_idx) # Add this neighbor to the queue as after labeling, it may still have unlabeled neighbors
# Fallback that labels unseen segments as artery
for seg in segments:
if seg['label'] is None:
apply_label(seg, 'Artery')
# Detects if the dataset contains the thoracic aorta (aortic arch)
def detect_thoracic_region(centerlines_vtk):
thoracic_info = {'detected': False, 'reversal_mm': 0.0, 'arch_zone_z': 0.0}
pts = centerlines_vtk.GetPoints()
if pts is None or pts.GetNumberOfPoints() == 0:
return thoracic_info
n_pts = pts.GetNumberOfPoints()
z_all = [pts.GetPoint(i)[2] for i in range(n_pts)]
z_min = min(z_all)
z_max = max(z_all)
z_range = z_max - z_min
# The indicator of the aortic arch is a near-180Β° U-turn in the superior portion of the scan.
# This appears as a Z-reversal: along a single centerline cell the Z-coordinate first increases (ascending aorta) then decreases(descending aorta) or vice-versa
TOP_FRACTION = 0.30 # Inspect top 30% of Z extent
MIN_ARCH_SPAN_MM = 40.0 # Reversal region must span >= 40 mm
z_arch_start = z_max - TOP_FRACTION * z_range
biggest_reversal = 0.0
n_cells = centerlines_vtk.GetNumberOfCells()
for cell_idx in range(n_cells):
cell = centerlines_vtk.GetCell(cell_idx)
cell_pts = cell.GetPoints()
if cell_pts is None:
continue
n_cell = cell_pts.GetNumberOfPoints()
# Extract Z of points that lie inside the arch zone
arch_z = [cell_pts.GetPoint(j)[2] for j in range(n_cell) if cell_pts.GetPoint(j)[2] >= z_arch_start]
if len(arch_z) < 4:
continue
peak_z = max(arch_z)
trough_z = min(arch_z)
# Case 1: dome (ascending β arch peak β descending)
dome_span = min(peak_z - arch_z[0], peak_z - arch_z[-1])
# Case 2: inverse dome (descending β bottom β ascending)
inv_span = min(arch_z[0] - trough_z, arch_z[-1] - trough_z)
reversal = max(dome_span, inv_span)
if reversal > biggest_reversal:
biggest_reversal = reversal
if reversal >= MIN_ARCH_SPAN_MM:
return {'detected': True, 'reversal_mm': round(reversal, 1), 'arch_zone_z': round(z_arch_start, 1)}
return {'detected': False, 'reversal_mm': round(biggest_reversal, 1), 'arch_zone_z': round(z_arch_start, 1)}
# Classify artery bifurcations into thoracic / abdomen / iliac zones, returns iliac cutoff bifurcation
def classify_arteries_by_zone(bifurcations_info, segments, has_thoracic):
# Keep only bifurcations where at least one arm ended up labeled 'Artery'
bifurcations_indices = [i for i, bif in enumerate(bifurcations_info) if 'Artery' in (bif.get('connected_arms_labels') or [])]
if not bifurcations_indices:
return None
# Sort descending by Z (highest = most superior)
bifurcations_indices.sort(key=lambda i: bifurcations_info[i]['z'], reverse=True)
# Compute Z-gap to the next artery bifurcation below each one
for idx, bif_idx in enumerate(bifurcations_indices):
if idx + 1 < len(bifurcations_indices):
nxt_bif_idx = bifurcations_indices[idx + 1]
gap = bifurcations_info[bif_idx]['z'] - bifurcations_info[nxt_bif_idx]['z']
bifurcations_info[bif_idx]['gap_to_next_bifurcation'] = round(gap, 1)
# Zone classification
thoracic_zone = []
abdomen_zone = []
ptr = 0
n = len(bifurcations_indices)
# Collect thoracic bifurcations until thoracic gap is found (or end of bifurcations list), has_thoracic indicates if dataset contains thoracic part or not
if has_thoracic:
while ptr < n:
idx = bifurcations_indices[ptr]
thoracic_zone.append(idx)
gap = bifurcations_info[idx]['gap_to_next_bifurcation']
ptr += 1
if gap is None or gap >= THORACIC_GAP_MM:
break
# Collect abdomen bifurcations after the thoracic cluster (or its the first bifurcations in datasets that start at abdomen region having no thoracic arch)
while ptr < n and len(abdomen_zone) < len(ABDOMEN_LABELS):
idx = bifurcations_indices[ptr]
abdomen_zone.append(idx)
gap = bifurcations_info[idx]['gap_to_next_bifurcation']
ptr += 1
if gap is None or gap >= ABDOMEN_CLUSTER_MAX_GAP_MM:
break
for idx in thoracic_zone:
bifurcations_info[idx]['artery_type'] = 'thoracic'
for idx in abdomen_zone:
bifurcations_info[idx]['artery_type'] = 'abdomen'
# Relabel artery segments which appear in thoracic region as thoraic arteries
if has_thoracic and thoracic_zone:
# Compute boundary between thoracic bifurcations and abdomen bifurcations
if abdomen_zone:
z_min_thoracic = min(bifurcations_info[i]['z'] for i in thoracic_zone)
z_max_abdomen = max(bifurcations_info[i]['z'] for i in abdomen_zone)
z_boundary = (z_min_thoracic + z_max_abdomen) / 2.0
else:
z_min_thoracic = min(bifurcations_info[i]['z'] for i in thoracic_zone)
z_boundary = z_min_thoracic - 20.0 # fallback: 20 mm below lowest thoracic junction
for seg in segments:
if seg.get('label') == 'Artery' and seg.get('points'):
avg_z = sum(pt['z'] for pt in seg['points']) / len(seg['points'])
if avg_z > z_boundary:
apply_label(seg, 'Thoracic Artery')
# Walk the remaining bifurcations (below the abdomen cluster, Z descending), detect iliac cutoff by scoring how strongly an arm trends inferiorly versus laterally
reference_zone = abdomen_zone if abdomen_zone else thoracic_zone
iliac_cutoff = None
cutoff_pos = None
cutoff_idx = None
if reference_zone:
for k in range(ptr, n):
idx = bifurcations_indices[k]
bif = bifurcations_info[idx]
bif_z = bif['z']
bif_x = bif['x']
arm_labels = bif.get('connected_arms_labels', [])
arm_endpoint_z = bif.get('arm_endpoint_z', [])
arm_endpoint_x = bif.get('arm_endpoint_x', [])
# Iliac like means any Artery arm drops more in Z than it spreads in X (lateral noise branches like lumbars move far in X but barely drop in Z)
iliac_like = any(((bif_z - ez) > ILIAC_Z_DROP_RATIO * abs(ex - bif_x)) for ez, ex, lbl in zip(arm_endpoint_z, arm_endpoint_x, arm_labels) if lbl == 'Artery')
if iliac_like:
cutoff_pos = k
cutoff_idx = idx
break
# Fallback: no iliac-like junction found β use first bifurcation below the reference zone if one exists
# otherwise fall back to the last bifurcation in the reference zone
if cutoff_idx is None:
if ptr < n:
cutoff_pos = ptr
cutoff_idx = bifurcations_indices[ptr]
else:
cutoff_pos = n - 1
cutoff_idx = reference_zone[-1]
iliac_bif = bifurcations_info[cutoff_idx]
bifurcations_info[cutoff_idx]['is_iliac_cutoff'] = True
iliac_cutoff = {'x': float(iliac_bif['x']), 'y': float(iliac_bif['y']), 'z': float(iliac_bif['z']), 'radius': float(iliac_bif['radius'])}
# Mark the cutoff bifurcation and everything below it as 'iliac'
if cutoff_pos is not None:
for k in range(cutoff_pos, n):
bifurcations_info[bifurcations_indices[k]]['artery_type'] = 'iliac'
# Relabel segments below iliac cutoff as iliac arteries
if iliac_cutoff:
cutoff_z = iliac_cutoff['z']
cutoff_x = iliac_cutoff['x']
for seg in segments:
if seg.get('label') in ('Aorta', 'Artery') and seg.get('points'):
pts = seg['points']
# Use centroid Z: avoids mislabeling the aortic trunk whose last point sits exactly at the bifurcation
# Iliac arms extend well below cutoff_z, so their centroid is clearly < cutoff_z
centroid_z = sum(pt['z'] for pt in pts) / len(pts)
if centroid_z < cutoff_z:
avg_x = sum(pt['x'] for pt in pts) / len(pts)
if avg_x < cutoff_x:
apply_label(seg, 'Left Iliac Artery')
else:
apply_label(seg, 'Right Iliac Artery')
return iliac_cutoff
# Finds and labels 4 main abdominal arteries (celiac, SMA, renals) using already-classified abdomen-zone bifurcations
def identify_abdominal_arteries(segments, point_to_segments, bifurcations_info):
if not bifurcations_info:
return []
# Take only abdomen-zone bifurcations and order from superior -> inferior
abdomen_candidates = [b for b in bifurcations_info if b.get('artery_type') == 'abdomen']
if not abdomen_candidates:
return []
abdomen_candidates.sort(key=lambda b: b['z'], reverse=True)
abdomen_ostia = []
label_idx = 0
labeled_seg_indices = set()
for bif in abdomen_candidates:
if label_idx >= len(ABDOMEN_LABELS):
break
bif_id = bif.get('id')
if bif_id is None:
continue
# Recompute directional arms for this bifurcation on current segments
connections = compute_bifurcation_arms(bif, segments, point_to_segments)
if not connections:
continue
# Skip the first two arms (aorta inlet/outlet), label remaining branch arms
# Sort by arm endpoint Z descending: superior branch always gets the earlier label
branch_arms = sorted(connections[2:], key=lambda b: b['arm_points'][-1]['z'] if b['arm_points'] else 0.0, reverse=True)
for branch in branch_arms:
if label_idx >= len(ABDOMEN_LABELS):
break
if branch['seg_idx'] in labeled_seg_indices:
continue
labeled_seg_indices.add(branch['seg_idx'])
label = ABDOMEN_LABELS[label_idx]
branch_dir = branch['direction']
arm_pts = branch['arm_points']
branch_label = label
if label == 'Renal':
if branch_dir[0] > 0:
branch_label = 'Right Renal'
else:
branch_label = 'Left Renal'
apply_label(segments[branch['seg_idx']], branch_label)
target = (bif['x'] + bif['radius'] * branch_dir[0], bif['y'] + bif['radius'] * branch_dir[1], bif['z'] + bif['radius'] * branch_dir[2])
best_pt = min(arm_pts, key=lambda p: (p['x'] - target[0]) ** 2 + (p['y'] - target[1]) ** 2 + (p['z'] - target[2]) ** 2)
abdomen_ostia.append({
'x': best_pt['x'], 'y': best_pt['y'], 'z': best_pt['z'],
'nx': branch_dir[0], 'ny': branch_dir[1], 'nz': branch_dir[2],
'label': branch_label, 'radius': best_pt['radius'],
})
label_idx += 1
return abdomen_ostia
# Places the default reference point at the most caudal abdomen bifurcation(actual junction), otherwise falls back to abdomen ostia
def compute_reference_point(segments, bifurcations_info, abdomen_ostia=None):
abdomen_bifs = [b for b in (bifurcations_info or []) if b.get('artery_type') == 'abdomen']
if abdomen_bifs:
last_abdomen_bif = min(abdomen_bifs, key=lambda b: b['z'])
return {'x': last_abdomen_bif['x'], 'y': last_abdomen_bif['y'], 'z': last_abdomen_bif['z']}
if abdomen_ostia:
last_abdomen_ostium = min(abdomen_ostia, key=lambda b: b['z'])
return {'x': last_abdomen_ostium['x'], 'y': last_abdomen_ostium['y'], 'z': last_abdomen_ostium['z']}
aorta_points = [pt for seg in segments if seg['label'] == 'Aorta' for pt in seg['points']]
if not aorta_points:
return {'x': 0, 'y': 0, 'z': 0}
return {
'x': sum(p['x'] for p in aorta_points) / len(aorta_points),
'y': sum(p['y'] for p in aorta_points) / len(aorta_points),
'z': sum(p['z'] for p in aorta_points) / len(aorta_points),
}
# Build Frontend response JSON
def build_response(centerlines_vtk, segments, abdomen_ostia, reference_point, iliac_cutoff, bifurcations_map=None, bifurcations_info=None):
bounds = centerlines_vtk.GetBounds()
# Orient artery segments so points run bifurcation β endpoint
orient_artery_segments(segments, bifurcations_map)
# Flatten segments into a single points list + segment index ranges
flat_points = []
segment_ranges = []
for seg in segments:
if not seg['points']:
continue # Skip empty segments
start_idx = len(flat_points) # Currently it's empty so start_idx is 0, second iteration it will be the length of first segment and so on
for pt in seg['points']:
flat_points.append({'x': pt['x'], 'y': pt['y'], 'z': pt['z'], 'radius': pt['radius'], 'label': seg['label']})
segment_ranges.append({'start': start_idx, 'end': len(flat_points) - 1, 'label': seg['label']})
return {
'points': flat_points,
'segments': segment_ranges,
'abdomen_ostia': abdomen_ostia,
'bifurcations': [
{
'id': int(b['id']),
'x': float(b['x']),
'y': float(b['y']),
'z': float(b['z']),
'radius': float(b['radius']),
'artery_type': b.get('artery_type'),
'is_iliac_cutoff': bool(b.get('is_iliac_cutoff', False)),
}
for b in (bifurcations_info or [])
],
'reference_point': reference_point,
'iliac_cutoff': iliac_cutoff,
'bounds': {
'x_min': float(bounds[0]), 'x_max': float(bounds[1]),
'y_min': float(bounds[2]), 'y_max': float(bounds[3]),
'z_min': float(bounds[4]), 'z_max': float(bounds[5]),
},
}
# Ensures artery (non-Aorta) segments are ordered bifurcation β endpoint
def orient_artery_segments(segments, bifurcations_map=None):
# Fast path: use bifurcation-map arm directions to orient covered segments
oriented_from_map = set()
if bifurcations_map:
for _, (_, connections) in bifurcations_map.items():
for c in connections:
seg_idx = c['seg_idx']
if seg_idx < 0 or seg_idx >= len(segments):
continue
seg = segments[seg_idx]
if seg.get('label') == 'Aorta':
continue
pts = seg.get('points', [])
arm_pts = c.get('arm_points', [])
if len(pts) < 2 or len(arm_pts) < 2:
continue
# arm_points are built bifurcation -> distal. Align segment to that
if pts[0]['id'] != arm_pts[0]['id'] and pts[-1]['id'] == arm_pts[0]['id']:
pts.reverse()
oriented_from_map.add(seg_idx)
# Collect all Aorta segment endpoints (first and last point of each Aorta seg)
aorta_endpoints = []
aorta_endpoint_ids = set()
for seg in segments:
if seg.get('label') != 'Aorta':
continue
pts = seg.get('points', [])
if not pts:
continue
aorta_endpoints.append(pts[0])
aorta_endpoint_ids.add(pts[0]['id'])
if len(pts) > 1:
aorta_endpoints.append(pts[-1])
aorta_endpoint_ids.add(pts[-1]['id'])
for seg_idx, seg in enumerate(segments):
if seg.get('label') == 'Aorta':
continue
if seg_idx in oriented_from_map:
continue
pts = seg.get('points', [])
if len(pts) < 2:
continue
if aorta_endpoints:
# O(1) endpoint-id check first
if pts[0]['id'] in aorta_endpoint_ids:
continue
if pts[-1]['id'] in aorta_endpoint_ids:
pts.reverse()
continue
# Proximity check: which end of artery segment is closest to any Aorta endpoint?
p0, p_last = pts[0], pts[-1]
min_dist_start = min((p0['x'] - end_pt['x']) ** 2 + (p0['y'] - end_pt['y']) ** 2 + (p0['z'] - end_pt['z']) ** 2 for end_pt in aorta_endpoints)
min_dist_end = min((p_last['x'] - end_pt['x']) ** 2 + (p_last['y'] - end_pt['y']) ** 2 + (p_last['z'] - end_pt['z']) ** 2 for end_pt in aorta_endpoints)
# If the END is closer to the aorta, the segment is backwards β reverse it
if min_dist_end < min_dist_start:
pts.reverse()
else:
# Fallback: radius heuristic (bifurcation end is thicker)
r_first = pts[0]['radius']
r_last = pts[-1]['radius']
if r_first < r_last:
pts.reverse()
|