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()