cardio 2025.12.0__py3-none-any.whl → 2026.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cardio/__init__.py +1 -1
- cardio/logic.py +200 -105
- cardio/orientation.py +8 -3
- cardio/rotation.py +146 -0
- cardio/scene.py +31 -10
- cardio/ui.py +137 -106
- cardio/utils.py +0 -7
- cardio/volume.py +21 -8
- {cardio-2025.12.0.dist-info → cardio-2026.1.2.dist-info}/METADATA +4 -4
- {cardio-2025.12.0.dist-info → cardio-2026.1.2.dist-info}/RECORD +12 -11
- {cardio-2025.12.0.dist-info → cardio-2026.1.2.dist-info}/WHEEL +0 -0
- {cardio-2025.12.0.dist-info → cardio-2026.1.2.dist-info}/entry_points.txt +0 -0
cardio/__init__.py
CHANGED
cardio/logic.py
CHANGED
|
@@ -9,15 +9,40 @@ from .screenshot import Screenshot
|
|
|
9
9
|
|
|
10
10
|
class Logic:
|
|
11
11
|
def _get_visible_rotation_data(self):
|
|
12
|
-
"""Get rotation sequence and angles for visible rotations only.
|
|
13
|
-
|
|
12
|
+
"""Get rotation sequence and angles for visible rotations only.
|
|
13
|
+
|
|
14
|
+
Returns data in ITK convention, as required by VTK.
|
|
15
|
+
Converts from current convention if necessary.
|
|
16
|
+
"""
|
|
17
|
+
from .orientation import IndexOrder
|
|
18
|
+
|
|
19
|
+
rotation_data = getattr(
|
|
20
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
21
|
+
)
|
|
22
|
+
angles_list = rotation_data.get("angles_list", [])
|
|
23
|
+
|
|
24
|
+
# Build rotation_sequence (list of {"axis": ...}) for visible rotations
|
|
25
|
+
rotation_sequence = []
|
|
14
26
|
rotation_angles = {}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
|
|
28
|
+
visible_index = 0
|
|
29
|
+
for rotation in angles_list:
|
|
30
|
+
if rotation.get("visible", True):
|
|
31
|
+
rotation_sequence.append({"axis": rotation["axes"]})
|
|
32
|
+
rotation_angles[visible_index] = rotation["angles"][0]
|
|
33
|
+
visible_index += 1
|
|
34
|
+
|
|
35
|
+
# CRITICAL: VTK always needs rotations in ITK convention
|
|
36
|
+
# Convert from current convention to ITK if necessary
|
|
37
|
+
current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
38
|
+
if current_convention == IndexOrder.ROMA:
|
|
39
|
+
# Convert ROMA to ITK: X→Z, Y→Y, Z→X, angle→-angle
|
|
40
|
+
rotation_sequence = [
|
|
41
|
+
{"axis": {"X": "Z", "Y": "Y", "Z": "X"}[rot["axis"]]}
|
|
42
|
+
for rot in rotation_sequence
|
|
43
|
+
]
|
|
44
|
+
rotation_angles = {idx: -angle for idx, angle in rotation_angles.items()}
|
|
45
|
+
|
|
21
46
|
return rotation_sequence, rotation_angles
|
|
22
47
|
|
|
23
48
|
def __init__(self, server, scene: Scene):
|
|
@@ -39,6 +64,12 @@ class Logic:
|
|
|
39
64
|
{"text": "Radians", "value": "radians"},
|
|
40
65
|
]
|
|
41
66
|
|
|
67
|
+
# Initialize axis convention items for dropdown
|
|
68
|
+
self.server.state.index_order_items = [
|
|
69
|
+
{"text": "ITK (X=L, Y=P, Z=S)", "value": "itk"},
|
|
70
|
+
{"text": "Roma (X=S, Y=P, Z=L)", "value": "roma"},
|
|
71
|
+
]
|
|
72
|
+
|
|
42
73
|
# Initialize MPR origin (will be updated when active volume changes)
|
|
43
74
|
self.server.state.mpr_origin = [0.0, 0.0, 0.0]
|
|
44
75
|
self.server.state.mpr_crosshairs_enabled = self.scene.mpr_crosshairs_enabled
|
|
@@ -55,17 +86,9 @@ class Logic:
|
|
|
55
86
|
self.update_mpr_window_level
|
|
56
87
|
)
|
|
57
88
|
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
|
58
|
-
self.server.state.change("
|
|
89
|
+
self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
|
|
59
90
|
self.server.state.change("angle_units")(self.sync_angle_units)
|
|
60
|
-
|
|
61
|
-
# Add handlers for individual rotation angles and visibility
|
|
62
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
63
|
-
self.server.state.change(f"mpr_rotation_angle_{i}")(
|
|
64
|
-
self.update_mpr_rotation
|
|
65
|
-
)
|
|
66
|
-
self.server.state.change(f"mpr_rotation_visible_{i}")(
|
|
67
|
-
self.update_mpr_rotation
|
|
68
|
-
)
|
|
91
|
+
self.server.state.change("index_order")(self.sync_index_order)
|
|
69
92
|
|
|
70
93
|
# Initialize visibility state variables
|
|
71
94
|
for m in self.scene.meshes:
|
|
@@ -160,6 +183,7 @@ class Logic:
|
|
|
160
183
|
self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
|
|
161
184
|
self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
|
|
162
185
|
self.server.controller.remove_rotation_event = self.remove_mpr_rotation
|
|
186
|
+
self.server.controller.reset_rotation_angle = self.reset_rotation_angle
|
|
163
187
|
self.server.controller.reset_rotations = self.reset_mpr_rotations
|
|
164
188
|
|
|
165
189
|
# Initialize MPR state
|
|
@@ -176,8 +200,18 @@ class Logic:
|
|
|
176
200
|
self.server.state.mpr_window = self.scene.mpr_window
|
|
177
201
|
self.server.state.mpr_level = self.scene.mpr_level
|
|
178
202
|
self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
|
|
179
|
-
|
|
180
|
-
|
|
203
|
+
|
|
204
|
+
# Initialize rotation data from RotationSequence
|
|
205
|
+
self.server.state.mpr_rotation_data = (
|
|
206
|
+
self.scene.mpr_rotation_sequence.to_dict_for_ui()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self.server.state.angle_units = (
|
|
210
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units.value
|
|
211
|
+
)
|
|
212
|
+
self.server.state.index_order = (
|
|
213
|
+
self.scene.mpr_rotation_sequence.metadata.index_order.value
|
|
214
|
+
)
|
|
181
215
|
|
|
182
216
|
# Initialize MPR presets data
|
|
183
217
|
try:
|
|
@@ -190,12 +224,6 @@ class Logic:
|
|
|
190
224
|
print(f"Error initializing MPR presets: {e}")
|
|
191
225
|
self.server.state.mpr_presets = []
|
|
192
226
|
|
|
193
|
-
# Initialize rotation angle states
|
|
194
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
195
|
-
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
196
|
-
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
197
|
-
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
198
|
-
|
|
199
227
|
# Apply initial preset to ensure window/level values are set correctly
|
|
200
228
|
# Only update state values, don't call update methods yet since MPR may not be enabled
|
|
201
229
|
from .window_level import presets
|
|
@@ -319,6 +347,7 @@ class Logic:
|
|
|
319
347
|
origin,
|
|
320
348
|
rotation_sequence,
|
|
321
349
|
rotation_angles,
|
|
350
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
322
351
|
)
|
|
323
352
|
|
|
324
353
|
# Apply window/level
|
|
@@ -490,63 +519,40 @@ class Logic:
|
|
|
490
519
|
|
|
491
520
|
@asynchronous.task
|
|
492
521
|
async def save_rotation_angles(self):
|
|
493
|
-
"""Save current rotation angles to
|
|
494
|
-
|
|
522
|
+
"""Save current rotation angles to TOML file."""
|
|
523
|
+
from .rotation import RotationSequence
|
|
495
524
|
|
|
496
|
-
# Get current timestamp
|
|
497
525
|
timestamp = dt.datetime.now()
|
|
498
526
|
timestamp_str = timestamp.strftime(self.scene.timestamp_format)
|
|
499
|
-
iso_timestamp = timestamp.isoformat()
|
|
500
|
-
|
|
501
|
-
# Get active volume label
|
|
502
527
|
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
|
528
|
+
|
|
503
529
|
if not active_volume_label:
|
|
504
|
-
print("Warning: No active volume selected
|
|
530
|
+
print("Warning: No active volume selected")
|
|
505
531
|
return
|
|
506
532
|
|
|
507
|
-
# Create directory structure
|
|
508
533
|
save_dir = self.scene.rotations_directory / active_volume_label
|
|
509
534
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
510
535
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
metadata = tk.table()
|
|
516
|
-
metadata["coordinate_system"] = self.scene.coordinate_system
|
|
517
|
-
metadata["units"] = self.scene.angle_units.value
|
|
518
|
-
metadata["timestamp"] = iso_timestamp
|
|
519
|
-
metadata["volume_label"] = active_volume_label
|
|
520
|
-
doc["metadata"] = metadata
|
|
536
|
+
rotation_data = getattr(
|
|
537
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
538
|
+
)
|
|
539
|
+
rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
|
|
521
540
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
origin_table = tk.table()
|
|
525
|
-
origin_table["x"] = origin[0]
|
|
526
|
-
origin_table["y"] = origin[1]
|
|
527
|
-
origin_table["z"] = origin[2]
|
|
528
|
-
doc["origin"] = origin_table
|
|
529
|
-
|
|
530
|
-
# Rotations section (array of tables)
|
|
531
|
-
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
532
|
-
rotations_array = tk.aot()
|
|
533
|
-
|
|
534
|
-
for i, rotation_def in enumerate(rotation_sequence):
|
|
535
|
-
rotation = tk.table()
|
|
536
|
-
rotation["axis"] = rotation_def["axis"]
|
|
537
|
-
angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
538
|
-
rotation["angle"] = float(angle)
|
|
539
|
-
rotation["visible"] = getattr(
|
|
540
|
-
self.server.state, f"mpr_rotation_visible_{i}", True
|
|
541
|
-
)
|
|
542
|
-
rotations_array.append(rotation)
|
|
541
|
+
mpr_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
542
|
+
rotation_seq.mpr_origin = list(mpr_origin)
|
|
543
543
|
|
|
544
|
-
|
|
544
|
+
rotation_seq.metadata.timestamp = timestamp.isoformat()
|
|
545
|
+
rotation_seq.metadata.volume_label = active_volume_label
|
|
546
|
+
rotation_seq.metadata.coordinate_system = self.scene.coordinate_system
|
|
547
|
+
rotation_seq.metadata.index_order = (
|
|
548
|
+
self.scene.mpr_rotation_sequence.metadata.index_order
|
|
549
|
+
)
|
|
550
|
+
rotation_seq.metadata.angle_units = (
|
|
551
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
552
|
+
)
|
|
545
553
|
|
|
546
|
-
# Save to file
|
|
547
554
|
output_path = save_dir / f"{timestamp_str}.toml"
|
|
548
|
-
|
|
549
|
-
f.write(tk.dumps(doc))
|
|
555
|
+
rotation_seq.to_file(output_path)
|
|
550
556
|
|
|
551
557
|
def reset_all(self):
|
|
552
558
|
self.server.state.frame = 0
|
|
@@ -573,13 +579,87 @@ class Logic:
|
|
|
573
579
|
|
|
574
580
|
def sync_angle_units(self, angle_units, **kwargs):
|
|
575
581
|
"""Sync angle units selection - updates the scene configuration."""
|
|
576
|
-
|
|
582
|
+
import copy
|
|
583
|
+
|
|
584
|
+
import numpy as np
|
|
577
585
|
|
|
578
|
-
|
|
586
|
+
from .orientation import AngleUnits
|
|
587
|
+
|
|
588
|
+
# Get current units before changing
|
|
589
|
+
old_units = self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
590
|
+
|
|
591
|
+
# Update based on UI selection
|
|
592
|
+
new_units = None
|
|
579
593
|
if angle_units == "degrees":
|
|
580
|
-
|
|
594
|
+
new_units = AngleUnits.DEGREES
|
|
581
595
|
elif angle_units == "radians":
|
|
582
|
-
|
|
596
|
+
new_units = AngleUnits.RADIANS
|
|
597
|
+
|
|
598
|
+
if new_units is None or old_units == new_units:
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
# Convert all existing rotation angles
|
|
602
|
+
rotation_data = getattr(
|
|
603
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
604
|
+
)
|
|
605
|
+
if rotation_data.get("angles_list"):
|
|
606
|
+
updated_data = copy.deepcopy(rotation_data)
|
|
607
|
+
|
|
608
|
+
for rotation in updated_data["angles_list"]:
|
|
609
|
+
current_angle = rotation.get("angles", [0])[0]
|
|
610
|
+
|
|
611
|
+
# Convert based on old -> new units
|
|
612
|
+
if old_units == AngleUnits.DEGREES and new_units == AngleUnits.RADIANS:
|
|
613
|
+
rotation["angles"][0] = np.radians(current_angle)
|
|
614
|
+
elif (
|
|
615
|
+
old_units == AngleUnits.RADIANS and new_units == AngleUnits.DEGREES
|
|
616
|
+
):
|
|
617
|
+
rotation["angles"][0] = np.degrees(current_angle)
|
|
618
|
+
|
|
619
|
+
self.server.state.mpr_rotation_data = updated_data
|
|
620
|
+
|
|
621
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units = new_units
|
|
622
|
+
|
|
623
|
+
def sync_index_order(self, index_order, **kwargs):
|
|
624
|
+
"""Sync index order selection - converts existing rotations and updates scene."""
|
|
625
|
+
import copy
|
|
626
|
+
|
|
627
|
+
from .orientation import IndexOrder
|
|
628
|
+
|
|
629
|
+
old_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
630
|
+
|
|
631
|
+
# Convert string input to enum
|
|
632
|
+
if isinstance(index_order, str):
|
|
633
|
+
match index_order.lower():
|
|
634
|
+
case "itk":
|
|
635
|
+
new_convention = IndexOrder.ITK
|
|
636
|
+
case "roma":
|
|
637
|
+
new_convention = IndexOrder.ROMA
|
|
638
|
+
case _:
|
|
639
|
+
raise ValueError(f"Unrecognized index order: {index_order}")
|
|
640
|
+
else:
|
|
641
|
+
new_convention = index_order
|
|
642
|
+
|
|
643
|
+
if old_convention == new_convention:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
rotation_data = getattr(
|
|
647
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
648
|
+
)
|
|
649
|
+
if rotation_data.get("angles_list"):
|
|
650
|
+
updated_data = copy.deepcopy(rotation_data)
|
|
651
|
+
|
|
652
|
+
for rotation in updated_data["angles_list"]:
|
|
653
|
+
current_axis = rotation.get("axes")
|
|
654
|
+
current_angle = rotation.get("angles", [0])[0]
|
|
655
|
+
|
|
656
|
+
# Conversion is the same for both ITK<->ROMA directions
|
|
657
|
+
rotation["axes"] = {"X": "Z", "Y": "Y", "Z": "X"}[current_axis]
|
|
658
|
+
rotation["angles"][0] = -current_angle
|
|
659
|
+
|
|
660
|
+
self.server.state.mpr_rotation_data = updated_data
|
|
661
|
+
|
|
662
|
+
self.scene.mpr_rotation_sequence.metadata.index_order = new_convention
|
|
583
663
|
|
|
584
664
|
def _initialize_clipping_state(self):
|
|
585
665
|
"""Initialize clipping state variables for all objects."""
|
|
@@ -771,6 +851,7 @@ class Logic:
|
|
|
771
851
|
origin,
|
|
772
852
|
rotation_sequence,
|
|
773
853
|
rotation_angles,
|
|
854
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
774
855
|
)
|
|
775
856
|
|
|
776
857
|
# Update all views
|
|
@@ -869,6 +950,7 @@ class Logic:
|
|
|
869
950
|
origin,
|
|
870
951
|
rotation_sequence,
|
|
871
952
|
rotation_angles,
|
|
953
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
872
954
|
)
|
|
873
955
|
|
|
874
956
|
# Update all views
|
|
@@ -901,46 +983,56 @@ class Logic:
|
|
|
901
983
|
"""Add a new rotation to the MPR rotation sequence."""
|
|
902
984
|
import copy
|
|
903
985
|
|
|
904
|
-
|
|
905
|
-
getattr(self.server.state, "
|
|
986
|
+
current_data = copy.deepcopy(
|
|
987
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
906
988
|
)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
989
|
+
angles_list = current_data["angles_list"]
|
|
990
|
+
new_index = len(angles_list)
|
|
991
|
+
|
|
992
|
+
angles_list.append(
|
|
993
|
+
{
|
|
994
|
+
"axes": axis,
|
|
995
|
+
"angles": [0],
|
|
996
|
+
"visible": True,
|
|
997
|
+
"name": "",
|
|
998
|
+
"name_editable": True,
|
|
999
|
+
"deletable": True,
|
|
1000
|
+
}
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
self.server.state.mpr_rotation_data = current_data
|
|
910
1004
|
|
|
911
1005
|
def remove_mpr_rotation(self, index):
|
|
912
|
-
"""Remove a rotation at given index
|
|
913
|
-
|
|
914
|
-
if 0 <= index < len(sequence):
|
|
915
|
-
sequence = sequence[:index]
|
|
916
|
-
self.server.state.mpr_rotation_sequence = sequence
|
|
1006
|
+
"""Remove a rotation at given index."""
|
|
1007
|
+
import copy
|
|
917
1008
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1009
|
+
current_data = copy.deepcopy(
|
|
1010
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
1011
|
+
)
|
|
1012
|
+
angles_list = current_data["angles_list"]
|
|
921
1013
|
|
|
922
|
-
|
|
1014
|
+
if 0 <= index < len(angles_list):
|
|
1015
|
+
angles_list.pop(index)
|
|
1016
|
+
current_data["angles_list"] = angles_list
|
|
1017
|
+
self.server.state.mpr_rotation_data = current_data
|
|
1018
|
+
|
|
1019
|
+
def reset_rotation_angle(self, index):
|
|
1020
|
+
"""Reset the angle of a rotation at given index to zero."""
|
|
1021
|
+
import copy
|
|
1022
|
+
|
|
1023
|
+
current_data = copy.deepcopy(
|
|
1024
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
1025
|
+
)
|
|
1026
|
+
angles_list = current_data["angles_list"]
|
|
1027
|
+
|
|
1028
|
+
if 0 <= index < len(angles_list):
|
|
1029
|
+
angles_list[index]["angles"][0] = 0
|
|
1030
|
+
current_data["angles_list"] = angles_list
|
|
1031
|
+
self.server.state.mpr_rotation_data = current_data
|
|
923
1032
|
|
|
924
1033
|
def reset_mpr_rotations(self):
|
|
925
1034
|
"""Reset all MPR rotations."""
|
|
926
|
-
self.server.state.
|
|
927
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
928
|
-
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
929
|
-
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
930
|
-
self.update_mpr_rotation_labels()
|
|
931
|
-
|
|
932
|
-
def update_mpr_rotation_labels(self):
|
|
933
|
-
"""Update the rotation axis labels for display."""
|
|
934
|
-
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
935
|
-
for i, rotation in enumerate(rotation_sequence):
|
|
936
|
-
setattr(
|
|
937
|
-
self.server.state,
|
|
938
|
-
f"mpr_rotation_axis_{i}",
|
|
939
|
-
f"{rotation['axis']} ({i + 1})",
|
|
940
|
-
)
|
|
941
|
-
# Clear unused labels
|
|
942
|
-
for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
|
|
943
|
-
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
1035
|
+
self.server.state.mpr_rotation_data = {"angles_list": []}
|
|
944
1036
|
|
|
945
1037
|
def finalize_mpr_initialization(self, **kwargs):
|
|
946
1038
|
"""Set the active volume label after UI is ready to avoid race condition."""
|
|
@@ -950,6 +1042,9 @@ class Logic:
|
|
|
950
1042
|
self.sync_active_volume(self._pending_active_volume)
|
|
951
1043
|
delattr(self, "_pending_active_volume")
|
|
952
1044
|
|
|
1045
|
+
# Apply loaded rotation data to MPR views
|
|
1046
|
+
self.update_mpr_rotation()
|
|
1047
|
+
|
|
953
1048
|
@asynchronous.task
|
|
954
1049
|
async def close_application(self):
|
|
955
1050
|
"""Close the application by stopping the server."""
|
cardio/orientation.py
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
import enum
|
|
2
2
|
|
|
3
3
|
import itk
|
|
4
4
|
import numpy as np
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
# DICOM LPS canonical orientation vector mappings
|
|
8
|
-
class EulerAxis(
|
|
8
|
+
class EulerAxis(enum.StrEnum):
|
|
9
9
|
X = "X"
|
|
10
10
|
Y = "Y"
|
|
11
11
|
Z = "Z"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class
|
|
14
|
+
class IndexOrder(enum.StrEnum):
|
|
15
|
+
ITK = "itk" # X=Left, Y=Posterior, Z=Superior
|
|
16
|
+
ROMA = "roma" # X=Superior, Y=Posterior, Z=Left
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AngleUnits(enum.StrEnum):
|
|
15
20
|
DEGREES = "degrees"
|
|
16
21
|
RADIANS = "radians"
|
|
17
22
|
|
cardio/rotation.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# System
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import pathlib as pl
|
|
4
|
+
import typing as ty
|
|
5
|
+
|
|
6
|
+
# Third Party
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pydantic as pc
|
|
9
|
+
import tomlkit as tk
|
|
10
|
+
|
|
11
|
+
# Internal
|
|
12
|
+
from .orientation import AngleUnits, IndexOrder
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RotationStep(pc.BaseModel):
|
|
16
|
+
"""Single rotation step.
|
|
17
|
+
|
|
18
|
+
Both angles and axes are stored in the current convention/units.
|
|
19
|
+
- Angles: stored in units specified by parent metadata.angle_units
|
|
20
|
+
- Axes: stored in convention specified by parent metadata.index_order
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
axes: ty.Literal["X", "Y", "Z"]
|
|
24
|
+
angle: float = 0.0
|
|
25
|
+
visible: bool = True
|
|
26
|
+
name: str = ""
|
|
27
|
+
name_editable: bool = True
|
|
28
|
+
deletable: bool = True
|
|
29
|
+
|
|
30
|
+
@pc.model_validator(mode="before")
|
|
31
|
+
@classmethod
|
|
32
|
+
def handle_legacy_format(cls, data):
|
|
33
|
+
"""Handle legacy 'angles' list format and 'axis' field."""
|
|
34
|
+
if isinstance(data, dict):
|
|
35
|
+
if "angles" in data and "angle" not in data:
|
|
36
|
+
angles = data["angles"]
|
|
37
|
+
if isinstance(angles, list) and len(angles) > 0:
|
|
38
|
+
data["angle"] = angles[0]
|
|
39
|
+
else:
|
|
40
|
+
data["angle"] = angles
|
|
41
|
+
if "axis" in data and "axes" not in data:
|
|
42
|
+
data["axes"] = data["axis"]
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RotationMetadata(pc.BaseModel):
|
|
47
|
+
"""Metadata for TOML files."""
|
|
48
|
+
|
|
49
|
+
coordinate_system: ty.Literal["LPS"] = "LPS"
|
|
50
|
+
index_order: IndexOrder = IndexOrder.ITK
|
|
51
|
+
angle_units: AngleUnits = AngleUnits.RADIANS
|
|
52
|
+
timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
|
|
53
|
+
volume_label: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RotationSequence(pc.BaseModel):
|
|
57
|
+
"""Complete rotation sequence.
|
|
58
|
+
|
|
59
|
+
Both angles and axes are always stored in the current convention/units:
|
|
60
|
+
- Angles: stored in units specified by metadata.angle_units
|
|
61
|
+
- Axes: stored in convention specified by metadata.index_order
|
|
62
|
+
|
|
63
|
+
When convention/units change in the UI, all existing rotations are converted.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
model_config = pc.ConfigDict(frozen=False)
|
|
67
|
+
|
|
68
|
+
metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
|
|
69
|
+
angles_list: list[RotationStep] = pc.Field(default_factory=list)
|
|
70
|
+
mpr_origin: list[float] = pc.Field(
|
|
71
|
+
default_factory=lambda: [0.0, 0.0, 0.0],
|
|
72
|
+
description="MPR origin position [x, y, z] in LPS coordinates",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@pc.field_validator("mpr_origin")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_mpr_origin(cls, v):
|
|
78
|
+
"""Ensure mpr_origin is a 3-element list of floats."""
|
|
79
|
+
if not isinstance(v, list) or len(v) != 3:
|
|
80
|
+
raise ValueError("mpr_origin must be a 3-element list [x, y, z]")
|
|
81
|
+
return [float(x) for x in v]
|
|
82
|
+
|
|
83
|
+
@pc.field_validator("angles_list", mode="before")
|
|
84
|
+
@classmethod
|
|
85
|
+
def convert_legacy_list(cls, v):
|
|
86
|
+
"""Convert legacy list of dicts to list of RotationStep."""
|
|
87
|
+
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
|
88
|
+
return [RotationStep(**item) for item in v]
|
|
89
|
+
return v
|
|
90
|
+
|
|
91
|
+
def to_dict_for_ui(self) -> dict:
|
|
92
|
+
"""Convert to UI format.
|
|
93
|
+
|
|
94
|
+
Angles are passed through in their current units (metadata.angle_units).
|
|
95
|
+
UI expects 'angles' as a list for backward compatibility.
|
|
96
|
+
"""
|
|
97
|
+
return {
|
|
98
|
+
"angles_list": [
|
|
99
|
+
{
|
|
100
|
+
"axes": step.axes,
|
|
101
|
+
"angles": [step.angle],
|
|
102
|
+
"visible": step.visible,
|
|
103
|
+
"name": step.name,
|
|
104
|
+
"name_editable": step.name_editable,
|
|
105
|
+
"deletable": step.deletable,
|
|
106
|
+
}
|
|
107
|
+
for step in self.angles_list
|
|
108
|
+
],
|
|
109
|
+
"mpr_origin": self.mpr_origin,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
|
|
114
|
+
"""Create from UI format.
|
|
115
|
+
|
|
116
|
+
Angles from UI are stored as-is (in current UI units).
|
|
117
|
+
Caller should set metadata.angle_units to match the UI's current units.
|
|
118
|
+
"""
|
|
119
|
+
angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
|
|
120
|
+
metadata = RotationMetadata(volume_label=volume_label)
|
|
121
|
+
mpr_origin = data.get("mpr_origin", [0.0, 0.0, 0.0])
|
|
122
|
+
return cls(metadata=metadata, angles_list=angles_list, mpr_origin=mpr_origin)
|
|
123
|
+
|
|
124
|
+
def to_toml(self) -> str:
|
|
125
|
+
"""Serialize to TOML using stored serialization preferences."""
|
|
126
|
+
data = self.model_dump(mode="json")
|
|
127
|
+
return tk.dumps(data)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_toml(cls, toml_content: str) -> "RotationSequence":
|
|
131
|
+
"""Deserialize from TOML (no conversions - loads as-is)."""
|
|
132
|
+
doc = tk.loads(toml_content)
|
|
133
|
+
data = dict(doc)
|
|
134
|
+
return cls(**data)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_file(cls, path: pl.Path) -> "RotationSequence":
|
|
138
|
+
"""Load from TOML file."""
|
|
139
|
+
with open(path, "r") as f:
|
|
140
|
+
return cls.from_toml(f.read())
|
|
141
|
+
|
|
142
|
+
def to_file(self, path: pl.Path):
|
|
143
|
+
"""Save to TOML file using stored serialization preferences."""
|
|
144
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with open(path, "w") as f:
|
|
146
|
+
f.write(self.to_toml())
|
cardio/scene.py
CHANGED
|
@@ -8,9 +8,10 @@ import pydantic_settings as ps
|
|
|
8
8
|
import vtk
|
|
9
9
|
|
|
10
10
|
from .mesh import Mesh
|
|
11
|
+
from .orientation import AngleUnits, IndexOrder
|
|
12
|
+
from .rotation import RotationSequence
|
|
11
13
|
from .segmentation import Segmentation
|
|
12
14
|
from .types import RGBColor
|
|
13
|
-
from .utils import AngleUnit
|
|
14
15
|
from .volume import Volume
|
|
15
16
|
|
|
16
17
|
MeshListAdapter = pc.TypeAdapter(list[Mesh])
|
|
@@ -124,23 +125,23 @@ class Scene(ps.BaseSettings):
|
|
|
124
125
|
mpr_window_level_preset: int = pc.Field(
|
|
125
126
|
default=7, description="Window/level preset key for MPR views"
|
|
126
127
|
)
|
|
127
|
-
mpr_rotation_sequence:
|
|
128
|
-
default_factory=
|
|
129
|
-
description="Dynamic rotation sequence for MPR views
|
|
128
|
+
mpr_rotation_sequence: RotationSequence = pc.Field(
|
|
129
|
+
default_factory=RotationSequence,
|
|
130
|
+
description="Dynamic rotation sequence for MPR views",
|
|
131
|
+
)
|
|
132
|
+
mpr_rotation_file: pl.Path | None = pc.Field(
|
|
133
|
+
default=None,
|
|
134
|
+
description="Path to TOML file containing rotation configuration to load",
|
|
130
135
|
)
|
|
131
136
|
max_mpr_rotations: int = pc.Field(
|
|
132
137
|
default=20,
|
|
133
138
|
description="Maximum number of MPR rotations supported",
|
|
134
139
|
)
|
|
135
|
-
angle_units: AngleUnit = pc.Field(
|
|
136
|
-
default=AngleUnit.DEGREES,
|
|
137
|
-
description="Units for angle measurements in rotation serialization",
|
|
138
|
-
)
|
|
139
140
|
coordinate_system: str = pc.Field(
|
|
140
|
-
default="
|
|
141
|
+
default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
|
|
141
142
|
)
|
|
142
143
|
mpr_crosshairs_enabled: bool = pc.Field(
|
|
143
|
-
default=
|
|
144
|
+
default=True, description="Show crosshair lines indicating slice intersections"
|
|
144
145
|
)
|
|
145
146
|
mpr_crosshair_colors: dict = pc.Field(
|
|
146
147
|
default_factory=lambda: {
|
|
@@ -176,6 +177,26 @@ class Scene(ps.BaseSettings):
|
|
|
176
177
|
return SegmentationListAdapter.validate_json(v)
|
|
177
178
|
return v
|
|
178
179
|
|
|
180
|
+
@pc.model_validator(mode="after")
|
|
181
|
+
def load_rotation_file(self):
|
|
182
|
+
"""Load rotation sequence from TOML file if specified."""
|
|
183
|
+
if self.mpr_rotation_file is not None and self.mpr_rotation_file.exists():
|
|
184
|
+
self.mpr_rotation_sequence = RotationSequence.from_file(
|
|
185
|
+
self.mpr_rotation_file
|
|
186
|
+
)
|
|
187
|
+
if (
|
|
188
|
+
not self.active_volume_label
|
|
189
|
+
and self.mpr_rotation_sequence.metadata.volume_label
|
|
190
|
+
):
|
|
191
|
+
self.active_volume_label = (
|
|
192
|
+
self.mpr_rotation_sequence.metadata.volume_label
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if self.mpr_rotation_sequence.mpr_origin:
|
|
196
|
+
self.mpr_origin = list(self.mpr_rotation_sequence.mpr_origin)
|
|
197
|
+
|
|
198
|
+
return self
|
|
199
|
+
|
|
179
200
|
# VTK objects as private attributes
|
|
180
201
|
_renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
|
|
181
202
|
_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
|
cardio/ui.py
CHANGED
|
@@ -3,11 +3,12 @@ import time
|
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
from trame.ui.vuetify3 import SinglePageWithDrawerLayout
|
|
6
|
+
from trame.widgets import client
|
|
6
7
|
from trame.widgets import html
|
|
7
8
|
from trame.widgets import vtk as vtk_widgets
|
|
8
9
|
from trame.widgets import vuetify3 as vuetify
|
|
9
10
|
|
|
10
|
-
from .orientation import EulerAxis, euler_angle_to_rotation_matrix
|
|
11
|
+
from .orientation import AngleUnits, EulerAxis, euler_angle_to_rotation_matrix
|
|
11
12
|
from .scene import Scene
|
|
12
13
|
from .volume_property_presets import list_volume_property_presets
|
|
13
14
|
from .window_level import presets
|
|
@@ -151,16 +152,21 @@ class UI:
|
|
|
151
152
|
return np.array([0.0, 0.0, 1.0])
|
|
152
153
|
|
|
153
154
|
# Build cumulative rotation from visible rotations
|
|
154
|
-
|
|
155
|
+
rotation_data = getattr(
|
|
156
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
157
|
+
)
|
|
158
|
+
angles_list = rotation_data.get("angles_list", [])
|
|
155
159
|
cumulative_rotation = np.eye(3)
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
# Get current angle units
|
|
162
|
+
angle_units_str = getattr(self.server.state, "angle_units", "degrees")
|
|
163
|
+
angle_units = AngleUnits(angle_units_str)
|
|
164
|
+
|
|
165
|
+
for rotation in angles_list:
|
|
166
|
+
if rotation.get("visible", True):
|
|
167
|
+
angle = rotation.get("angles", [0])[0]
|
|
162
168
|
rotation_matrix = euler_angle_to_rotation_matrix(
|
|
163
|
-
EulerAxis(rotation["
|
|
169
|
+
EulerAxis(rotation["axes"]), angle, angle_units
|
|
164
170
|
)
|
|
165
171
|
cumulative_rotation = cumulative_rotation @ rotation_matrix
|
|
166
172
|
|
|
@@ -462,7 +468,7 @@ class UI:
|
|
|
462
468
|
# Volume selection dropdown
|
|
463
469
|
if self.scene.volumes:
|
|
464
470
|
vuetify.VSelect(
|
|
465
|
-
v_if="!maximized_view || maximized_view === 'volume'",
|
|
471
|
+
v_if="(!maximized_view || maximized_view === 'volume') && volume_items.length >= 2",
|
|
466
472
|
v_model=("active_volume_label", ""),
|
|
467
473
|
items=("volume_items", []),
|
|
468
474
|
item_title="text",
|
|
@@ -472,57 +478,6 @@ class UI:
|
|
|
472
478
|
hide_details=True,
|
|
473
479
|
)
|
|
474
480
|
|
|
475
|
-
# Window/Level controls for MPR
|
|
476
|
-
vuetify.VListSubheader(
|
|
477
|
-
"Window/Level", v_if="!maximized_view && active_volume_label"
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
vuetify.VSelect(
|
|
481
|
-
v_if="!maximized_view && active_volume_label",
|
|
482
|
-
v_model=("mpr_window_level_preset", 7),
|
|
483
|
-
items=("mpr_presets", []),
|
|
484
|
-
item_title="text",
|
|
485
|
-
item_value="value",
|
|
486
|
-
label="Preset",
|
|
487
|
-
dense=True,
|
|
488
|
-
hide_details=True,
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
vuetify.VSlider(
|
|
492
|
-
v_if="!maximized_view && active_volume_label",
|
|
493
|
-
v_model="mpr_window",
|
|
494
|
-
min=1.0,
|
|
495
|
-
max=2000.0,
|
|
496
|
-
step=1.0,
|
|
497
|
-
hint="Window",
|
|
498
|
-
persistent_hint=True,
|
|
499
|
-
dense=True,
|
|
500
|
-
hide_details=False,
|
|
501
|
-
thumb_label=True,
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
vuetify.VSlider(
|
|
505
|
-
v_if="!maximized_view && active_volume_label",
|
|
506
|
-
v_model="mpr_level",
|
|
507
|
-
min=-1000.0,
|
|
508
|
-
max=1000.0,
|
|
509
|
-
step=1.0,
|
|
510
|
-
hint="Level",
|
|
511
|
-
persistent_hint=True,
|
|
512
|
-
dense=True,
|
|
513
|
-
hide_details=False,
|
|
514
|
-
thumb_label=True,
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
vuetify.VCheckbox(
|
|
518
|
-
v_if="!maximized_view && active_volume_label",
|
|
519
|
-
v_model=("mpr_crosshairs_enabled", True),
|
|
520
|
-
label="Show Crosshairs",
|
|
521
|
-
dense=True,
|
|
522
|
-
hide_details=True,
|
|
523
|
-
classes="mt-2",
|
|
524
|
-
)
|
|
525
|
-
|
|
526
481
|
# MPR Rotation controls
|
|
527
482
|
vuetify.VListSubheader(
|
|
528
483
|
"Rotations", v_if="!maximized_view && active_volume_label"
|
|
@@ -564,8 +519,8 @@ class UI:
|
|
|
564
519
|
|
|
565
520
|
# Reset rotations button
|
|
566
521
|
vuetify.VBtn(
|
|
567
|
-
"
|
|
568
|
-
v_if="!maximized_view && active_volume_label &&
|
|
522
|
+
"Remove All Rotations",
|
|
523
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
|
|
569
524
|
click=self.server.controller.reset_rotations,
|
|
570
525
|
small=True,
|
|
571
526
|
dense=True,
|
|
@@ -576,48 +531,105 @@ class UI:
|
|
|
576
531
|
prepend_icon="mdi-refresh",
|
|
577
532
|
)
|
|
578
533
|
|
|
579
|
-
# Individual rotation sliders
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
vuetify.
|
|
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
|
-
|
|
534
|
+
# Individual rotation sliders with DeepReactive
|
|
535
|
+
with client.DeepReactive("mpr_rotation_data"):
|
|
536
|
+
for i in range(self.scene.max_mpr_rotations):
|
|
537
|
+
with vuetify.VContainer(
|
|
538
|
+
v_if=f"!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > {i}",
|
|
539
|
+
fluid=True,
|
|
540
|
+
classes="pa-0 mb-2",
|
|
541
|
+
):
|
|
542
|
+
with vuetify.VRow(no_gutters=True):
|
|
543
|
+
with vuetify.VCol(cols="12"):
|
|
544
|
+
vuetify.VTextField(
|
|
545
|
+
v_model=(
|
|
546
|
+
f"mpr_rotation_data.angles_list[{i}].name",
|
|
547
|
+
),
|
|
548
|
+
placeholder="Name",
|
|
549
|
+
dense=True,
|
|
550
|
+
hide_details=True,
|
|
551
|
+
readonly=(
|
|
552
|
+
f"!mpr_rotation_data.angles_list[{i}].name_editable",
|
|
553
|
+
),
|
|
554
|
+
__events=["keydown", "keyup", "keypress"],
|
|
555
|
+
keydown="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
556
|
+
keyup="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
557
|
+
keypress="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
558
|
+
)
|
|
559
|
+
with vuetify.VRow(no_gutters=True):
|
|
560
|
+
with vuetify.VCol(cols="12"):
|
|
561
|
+
vuetify.VSlider(
|
|
562
|
+
v_model=(
|
|
563
|
+
f"mpr_rotation_data.angles_list[{i}].angles[0]",
|
|
564
|
+
),
|
|
565
|
+
min=(
|
|
566
|
+
"angle_units === 'radians' ? -Math.PI : -180",
|
|
567
|
+
),
|
|
568
|
+
max=(
|
|
569
|
+
"angle_units === 'radians' ? Math.PI : 180",
|
|
570
|
+
),
|
|
571
|
+
step=(
|
|
572
|
+
"angle_units === 'radians' ? 0.01 : 1",
|
|
573
|
+
),
|
|
574
|
+
dense=True,
|
|
575
|
+
hide_details=True,
|
|
576
|
+
thumb_label=True,
|
|
577
|
+
)
|
|
578
|
+
with vuetify.VRow(
|
|
579
|
+
no_gutters=True, classes="align-center"
|
|
580
|
+
):
|
|
581
|
+
vuetify.VSpacer()
|
|
582
|
+
with vuetify.VCol(cols="4"):
|
|
583
|
+
vuetify.VSelect(
|
|
584
|
+
v_model=(
|
|
585
|
+
f"mpr_rotation_data.angles_list[{i}].axes",
|
|
586
|
+
),
|
|
587
|
+
items=(["X", "Y", "Z"],),
|
|
588
|
+
dense=True,
|
|
589
|
+
hide_details=True,
|
|
590
|
+
label="Axis",
|
|
591
|
+
)
|
|
592
|
+
vuetify.VSpacer()
|
|
593
|
+
with vuetify.VCol(cols="auto"):
|
|
594
|
+
vuetify.VCheckbox(
|
|
595
|
+
v_model=(
|
|
596
|
+
f"mpr_rotation_data.angles_list[{i}].visible",
|
|
597
|
+
),
|
|
598
|
+
true_icon="mdi-eye",
|
|
599
|
+
false_icon="mdi-eye-off",
|
|
600
|
+
hide_details=True,
|
|
601
|
+
dense=True,
|
|
602
|
+
title="Toggle this rotation",
|
|
603
|
+
)
|
|
604
|
+
vuetify.VSpacer()
|
|
605
|
+
with vuetify.VCol(cols="auto"):
|
|
606
|
+
vuetify.VBtn(
|
|
607
|
+
icon="mdi-restore",
|
|
608
|
+
click=ft.partial(
|
|
609
|
+
self.server.controller.reset_rotation_angle,
|
|
610
|
+
i,
|
|
611
|
+
),
|
|
612
|
+
small=True,
|
|
613
|
+
dense=True,
|
|
614
|
+
title="Reset angle to zero",
|
|
615
|
+
)
|
|
616
|
+
vuetify.VSpacer()
|
|
617
|
+
with vuetify.VCol(cols="auto"):
|
|
618
|
+
vuetify.VBtn(
|
|
619
|
+
icon="mdi-delete",
|
|
620
|
+
click=ft.partial(
|
|
621
|
+
self.server.controller.remove_rotation_event,
|
|
622
|
+
i,
|
|
623
|
+
),
|
|
624
|
+
small=True,
|
|
625
|
+
dense=True,
|
|
626
|
+
color="error",
|
|
627
|
+
title="Remove this rotation",
|
|
628
|
+
disabled=(
|
|
629
|
+
f"!mpr_rotation_data.angles_list[{i}].deletable",
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
vuetify.VSpacer()
|
|
621
633
|
|
|
622
634
|
# Angle units selector
|
|
623
635
|
with vuetify.VRow(
|
|
@@ -629,7 +641,7 @@ class UI:
|
|
|
629
641
|
vuetify.VLabel("Units:")
|
|
630
642
|
with vuetify.VCol(cols="8"):
|
|
631
643
|
vuetify.VSelect(
|
|
632
|
-
v_model=("angle_units", "
|
|
644
|
+
v_model=("angle_units", "radians"),
|
|
633
645
|
items=("angle_units_items", []),
|
|
634
646
|
item_title="text",
|
|
635
647
|
item_value="value",
|
|
@@ -638,10 +650,29 @@ class UI:
|
|
|
638
650
|
outlined=True,
|
|
639
651
|
)
|
|
640
652
|
|
|
653
|
+
# Axis convention selector
|
|
654
|
+
with vuetify.VRow(
|
|
655
|
+
v_if="!maximized_view && active_volume_label",
|
|
656
|
+
no_gutters=True,
|
|
657
|
+
classes="align-center mb-2",
|
|
658
|
+
):
|
|
659
|
+
with vuetify.VCol(cols="4"):
|
|
660
|
+
vuetify.VLabel("Convention:")
|
|
661
|
+
with vuetify.VCol(cols="8"):
|
|
662
|
+
vuetify.VSelect(
|
|
663
|
+
v_model=("index_order", "itk"),
|
|
664
|
+
items=("index_order_items", []),
|
|
665
|
+
item_title="text",
|
|
666
|
+
item_value="value",
|
|
667
|
+
dense=True,
|
|
668
|
+
hide_details=True,
|
|
669
|
+
outlined=True,
|
|
670
|
+
)
|
|
671
|
+
|
|
641
672
|
# Save rotations button
|
|
642
673
|
vuetify.VBtn(
|
|
643
674
|
"Save Rotations",
|
|
644
|
-
v_if="!maximized_view && active_volume_label &&
|
|
675
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
|
|
645
676
|
click=self.server.controller.save_rotation_angles,
|
|
646
677
|
small=True,
|
|
647
678
|
dense=True,
|
cardio/utils.py
CHANGED
|
@@ -10,13 +10,6 @@ class InterpolatorType(enum.Enum):
|
|
|
10
10
|
NEAREST = "nearest"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
class AngleUnit(enum.Enum):
|
|
14
|
-
"""Units for angle measurements."""
|
|
15
|
-
|
|
16
|
-
DEGREES = "degrees"
|
|
17
|
-
RADIANS = "radians"
|
|
18
|
-
|
|
19
|
-
|
|
20
13
|
def calculate_combined_bounds(actors):
|
|
21
14
|
"""Calculate combined bounds encompassing all VTK actors.
|
|
22
15
|
|
cardio/volume.py
CHANGED
|
@@ -206,17 +206,17 @@ class Volume(Object):
|
|
|
206
206
|
)
|
|
207
207
|
elif view_name == "sagittal":
|
|
208
208
|
view_crosshairs["line1"]["actor"].GetProperty().SetColor(
|
|
209
|
-
*colors.get("
|
|
209
|
+
*colors.get("coronal", (0, 1, 0))
|
|
210
210
|
)
|
|
211
211
|
view_crosshairs["line2"]["actor"].GetProperty().SetColor(
|
|
212
|
-
*colors.get("
|
|
212
|
+
*colors.get("axial", (0, 0, 1))
|
|
213
213
|
)
|
|
214
214
|
else: # coronal
|
|
215
215
|
view_crosshairs["line1"]["actor"].GetProperty().SetColor(
|
|
216
|
-
*colors.get("
|
|
216
|
+
*colors.get("sagittal", (1, 0, 0))
|
|
217
217
|
)
|
|
218
218
|
view_crosshairs["line2"]["actor"].GetProperty().SetColor(
|
|
219
|
-
*colors.get("
|
|
219
|
+
*colors.get("axial", (0, 0, 1))
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
crosshairs[view_name] = view_crosshairs
|
|
@@ -299,15 +299,20 @@ class Volume(Object):
|
|
|
299
299
|
return bounds
|
|
300
300
|
|
|
301
301
|
def _build_cumulative_rotation(
|
|
302
|
-
self, rotation_sequence: list, rotation_angles: dict
|
|
302
|
+
self, rotation_sequence: list, rotation_angles: dict, angle_units=None
|
|
303
303
|
) -> np.ndarray:
|
|
304
304
|
"""Build cumulative rotation matrix from sequence of rotations."""
|
|
305
|
+
from .orientation import AngleUnits
|
|
306
|
+
|
|
307
|
+
if angle_units is None:
|
|
308
|
+
angle_units = AngleUnits.DEGREES
|
|
309
|
+
|
|
305
310
|
cumulative_rotation = np.eye(3)
|
|
306
311
|
if rotation_sequence and rotation_angles:
|
|
307
312
|
for i, rotation in enumerate(rotation_sequence):
|
|
308
313
|
angle = rotation_angles.get(i, 0)
|
|
309
314
|
rotation_matrix = euler_angle_to_rotation_matrix(
|
|
310
|
-
EulerAxis(rotation["axis"]), angle
|
|
315
|
+
EulerAxis(rotation["axis"]), angle, angle_units
|
|
311
316
|
)
|
|
312
317
|
cumulative_rotation = cumulative_rotation @ rotation_matrix
|
|
313
318
|
return cumulative_rotation
|
|
@@ -317,6 +322,7 @@ class Volume(Object):
|
|
|
317
322
|
view_name: str,
|
|
318
323
|
rotation_sequence: list = None,
|
|
319
324
|
rotation_angles: dict = None,
|
|
325
|
+
angle_units=None,
|
|
320
326
|
) -> np.ndarray:
|
|
321
327
|
"""Get the current normal vector for a view after rotation.
|
|
322
328
|
|
|
@@ -324,6 +330,7 @@ class Volume(Object):
|
|
|
324
330
|
view_name: One of "axial", "sagittal", "coronal"
|
|
325
331
|
rotation_sequence: List of rotation definitions
|
|
326
332
|
rotation_angles: Dict mapping rotation index to angle
|
|
333
|
+
angle_units: AngleUnits enum (degrees or radians)
|
|
327
334
|
|
|
328
335
|
Returns:
|
|
329
336
|
3D unit vector representing the scroll direction for this view
|
|
@@ -338,7 +345,7 @@ class Volume(Object):
|
|
|
338
345
|
return np.array([0.0, 0.0, 1.0])
|
|
339
346
|
|
|
340
347
|
cumulative_rotation = self._build_cumulative_rotation(
|
|
341
|
-
rotation_sequence, rotation_angles
|
|
348
|
+
rotation_sequence, rotation_angles, angle_units
|
|
342
349
|
)
|
|
343
350
|
return cumulative_rotation @ base_normals[view_name]
|
|
344
351
|
|
|
@@ -348,6 +355,7 @@ class Volume(Object):
|
|
|
348
355
|
origin: list,
|
|
349
356
|
rotation_sequence: list = None,
|
|
350
357
|
rotation_angles: dict = None,
|
|
358
|
+
angle_units=None,
|
|
351
359
|
):
|
|
352
360
|
"""Update slice positions for MPR views with optional rotation.
|
|
353
361
|
|
|
@@ -356,7 +364,12 @@ class Volume(Object):
|
|
|
356
364
|
origin: [x, y, z] position in LPS coordinates (shared by all views)
|
|
357
365
|
rotation_sequence: List of rotation definitions
|
|
358
366
|
rotation_angles: Dict mapping rotation index to angle
|
|
367
|
+
angle_units: AngleUnits enum (degrees or radians), defaults to DEGREES
|
|
359
368
|
"""
|
|
369
|
+
from .orientation import AngleUnits
|
|
370
|
+
|
|
371
|
+
if angle_units is None:
|
|
372
|
+
angle_units = AngleUnits.DEGREES
|
|
360
373
|
if frame not in self._mpr_actors:
|
|
361
374
|
return
|
|
362
375
|
|
|
@@ -367,7 +380,7 @@ class Volume(Object):
|
|
|
367
380
|
|
|
368
381
|
# Build cumulative rotation matrix
|
|
369
382
|
cumulative_rotation = self._build_cumulative_rotation(
|
|
370
|
-
rotation_sequence, rotation_angles
|
|
383
|
+
rotation_sequence, rotation_angles, angle_units
|
|
371
384
|
)
|
|
372
385
|
|
|
373
386
|
# Apply rotation to base transforms
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cardio
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2026.1.2
|
|
4
4
|
Summary: A simple web-based viewer for 3D and 4D ('cine') medical imaging data.
|
|
5
5
|
Keywords: Medical,Imaging,3D,4D,Visualization
|
|
6
6
|
Author: Davis Marc Vigneault
|
|
@@ -60,8 +60,8 @@ Description-Content-Type: text/markdown
|
|
|
60
60
|
built primarily on [trame](https://github.com/kitware/trame),
|
|
61
61
|
[vtk](https://github.com/kitware/vtk), and
|
|
62
62
|
[itk](https://github.com/insightsoftwareconsortium/itk). `cardio` can render sequences
|
|
63
|
-
of mesh files (e.g.,
|
|
64
|
-
discrete labels) and volume renderings of grayscale images (e.g.,
|
|
63
|
+
of mesh files (e.g., `*.obj` files), segmentation files (e.g., `*.nii.gz` files with
|
|
64
|
+
discrete labels) and volume renderings of grayscale images (e.g., `*.nii.gz` files with
|
|
65
65
|
continuous values). `cardio` is launched from the commandline and may be configured via
|
|
66
66
|
commandline arguments, a static TOML configuration file, or a combination of the two.
|
|
67
67
|
|
|
@@ -75,7 +75,7 @@ $ uv init
|
|
|
75
75
|
$ uv add cardio
|
|
76
76
|
$ . ./.venv/bin/activate
|
|
77
77
|
(project) cardio --version
|
|
78
|
-
cardio
|
|
78
|
+
cardio 2026.1.2
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
### Developing
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cardio/__init__.py,sha256=
|
|
1
|
+
cardio/__init__.py,sha256=1g0tFk2oSQTiQO_WcuDGxiGlutHUn7H4yLY3iMyFIm8,601
|
|
2
2
|
cardio/app.py,sha256=h-Xh57Txw3Hom-fDv08YxjXzovuIxY8_5qPy6MJThZs,1354
|
|
3
3
|
cardio/assets/bone.toml,sha256=vv8uVYSHIoKuHkNCoBOkGe2_qoEbXMvQO6ypm3mMOtA,675
|
|
4
4
|
cardio/assets/vascular_closed.toml,sha256=XtaZS_Zd6NSAtY3ZlUfiog3T86u9Ii0oSutU2wBQy78,1267
|
|
@@ -6,24 +6,25 @@ cardio/assets/vascular_open.toml,sha256=1M3sV1IGt3zh_3vviysKEk9quKfjF9xUBcIq3kxV
|
|
|
6
6
|
cardio/assets/xray.toml,sha256=siPem0OZ2OkWH0e5pizftpItJKGJgxKJ_S2K0316ubQ,693
|
|
7
7
|
cardio/blend_transfer_functions.py,sha256=fkLDYGMj_QXYs0vmXYT_B1tgTfl3YICLYimtWlxrmbQ,2958
|
|
8
8
|
cardio/color_transfer_function.py,sha256=uTyPdwxi0HjR4wm418cQN9-q9SspkkeqvLNqXaF3zzg,792
|
|
9
|
-
cardio/logic.py,sha256=
|
|
9
|
+
cardio/logic.py,sha256=XR7e5sb8vm7t3himwxOBR3OBP2QZxadb2hoGhqSTwiw,42877
|
|
10
10
|
cardio/mesh.py,sha256=wYdU0BU84eXrrHp0U0VLwYW7ZpRJ6GbT5Kl_-K6CBzY,9356
|
|
11
11
|
cardio/object.py,sha256=98A32VpFR4UtVqW8dZsRJR13VVyUoJJ20uoOZBgN4js,6168
|
|
12
|
-
cardio/orientation.py,sha256=
|
|
12
|
+
cardio/orientation.py,sha256=xsWAFPwf3-toT4XFYyGiZJkcGFQ2uz3eLxpC1_i6yZ8,6266
|
|
13
13
|
cardio/piecewise_function.py,sha256=X-_C-BVStufmFjfF8IbkqKk9Xw_jh00JUmjC22ZqeqQ,764
|
|
14
14
|
cardio/property_config.py,sha256=YrWIyCoSAfJPigkhriIQybQpJantGnXjT4nJfrtIJco,1689
|
|
15
|
-
cardio/
|
|
15
|
+
cardio/rotation.py,sha256=IoIoyhAdtBB90myY2GezXMjPbQ-JLMRTgQCpFSVZKBg,5096
|
|
16
|
+
cardio/scene.py,sha256=FrUQIJrBR_jPrW7utOI3uDbaUxjmZEapzVTEVIgEWaA,15365
|
|
16
17
|
cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
|
|
17
18
|
cardio/segmentation.py,sha256=KT1ClgultXyGpZDdpYxor38GflY7uCgRzfA8JEGQVaU,6642
|
|
18
19
|
cardio/transfer_function_pair.py,sha256=_J0qXA0InUPpvfWPcW492KGSYAqeb12htCzKBSpWHwo,780
|
|
19
20
|
cardio/types.py,sha256=jxSZjvxEJ03OThfupT2CG9UHsFklwbWeFrUozNXro2I,333
|
|
20
|
-
cardio/ui.py,sha256=
|
|
21
|
-
cardio/utils.py,sha256=
|
|
22
|
-
cardio/volume.py,sha256=
|
|
21
|
+
cardio/ui.py,sha256=esmJzoJE-lKoWHYWg1Zbq_Z1ydIt4ggHXf1i14J0PrM,53181
|
|
22
|
+
cardio/utils.py,sha256=ao4a7_vMjGBxTOMhZ7r0D0W4ujiwKPS0i8Xfmn3Gv9k,1497
|
|
23
|
+
cardio/volume.py,sha256=sdQ7gtX4jQDD9U8U95xD9LIV_1hpykj8NTCo1_aIKVM,15035
|
|
23
24
|
cardio/volume_property.py,sha256=-EUMV9sWCaetgGjnqIWxPp39qrxEZKTNDJ5GHUgLMlk,1619
|
|
24
25
|
cardio/volume_property_presets.py,sha256=4-hjo-dukm5sMMmWidbWnVXq0IN4sWpBnDITY9MqUFg,1625
|
|
25
26
|
cardio/window_level.py,sha256=XMkwLAHcmsEYcI0SoHySQZvptq4VwX2gj--ps3hV8AQ,784
|
|
26
|
-
cardio-
|
|
27
|
-
cardio-
|
|
28
|
-
cardio-
|
|
29
|
-
cardio-
|
|
27
|
+
cardio-2026.1.2.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
28
|
+
cardio-2026.1.2.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
|
|
29
|
+
cardio-2026.1.2.dist-info/METADATA,sha256=STZ2oobYKN12HQD_jH_w0HANJ_b4L-PM9bB41eIJZ0Q,3518
|
|
30
|
+
cardio-2026.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|