cardio 2026.1.0__py3-none-any.whl → 2026.1.3__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 +124 -24
- cardio/orientation.py +4 -4
- cardio/rotation.py +58 -117
- cardio/scene.py +4 -9
- cardio/ui.py +2 -2
- {cardio-2026.1.0.dist-info → cardio-2026.1.3.dist-info}/METADATA +4 -4
- {cardio-2026.1.0.dist-info → cardio-2026.1.3.dist-info}/RECORD +10 -10
- {cardio-2026.1.0.dist-info → cardio-2026.1.3.dist-info}/WHEEL +0 -0
- {cardio-2026.1.0.dist-info → cardio-2026.1.3.dist-info}/entry_points.txt +0 -0
cardio/__init__.py
CHANGED
cardio/logic.py
CHANGED
|
@@ -9,7 +9,13 @@ 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.
|
|
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
|
+
|
|
13
19
|
rotation_data = getattr(
|
|
14
20
|
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
15
21
|
)
|
|
@@ -26,6 +32,17 @@ class Logic:
|
|
|
26
32
|
rotation_angles[visible_index] = rotation["angles"][0]
|
|
27
33
|
visible_index += 1
|
|
28
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
|
+
|
|
29
46
|
return rotation_sequence, rotation_angles
|
|
30
47
|
|
|
31
48
|
def __init__(self, server, scene: Scene):
|
|
@@ -48,7 +65,7 @@ class Logic:
|
|
|
48
65
|
]
|
|
49
66
|
|
|
50
67
|
# Initialize axis convention items for dropdown
|
|
51
|
-
self.server.state.
|
|
68
|
+
self.server.state.index_order_items = [
|
|
52
69
|
{"text": "ITK (X=L, Y=P, Z=S)", "value": "itk"},
|
|
53
70
|
{"text": "Roma (X=S, Y=P, Z=L)", "value": "roma"},
|
|
54
71
|
]
|
|
@@ -71,7 +88,7 @@ class Logic:
|
|
|
71
88
|
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
|
72
89
|
self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
|
|
73
90
|
self.server.state.change("angle_units")(self.sync_angle_units)
|
|
74
|
-
self.server.state.change("
|
|
91
|
+
self.server.state.change("index_order")(self.sync_index_order)
|
|
75
92
|
|
|
76
93
|
# Initialize visibility state variables
|
|
77
94
|
for m in self.scene.meshes:
|
|
@@ -189,8 +206,12 @@ class Logic:
|
|
|
189
206
|
self.scene.mpr_rotation_sequence.to_dict_for_ui()
|
|
190
207
|
)
|
|
191
208
|
|
|
192
|
-
self.server.state.angle_units =
|
|
193
|
-
|
|
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
|
+
)
|
|
194
215
|
|
|
195
216
|
# Initialize MPR presets data
|
|
196
217
|
try:
|
|
@@ -317,16 +338,24 @@ class Logic:
|
|
|
317
338
|
|
|
318
339
|
def _apply_current_mpr_settings(self, active_volume, frame):
|
|
319
340
|
"""Apply current slice positions and window/level to MPR actors."""
|
|
341
|
+
from .orientation import IndexOrder
|
|
342
|
+
|
|
320
343
|
# Apply slice positions
|
|
321
344
|
origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
322
345
|
rotation_sequence, rotation_angles = self._get_visible_rotation_data()
|
|
323
346
|
|
|
347
|
+
# VTK needs origin in ITK convention - convert if necessary
|
|
348
|
+
current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
349
|
+
if current_convention == IndexOrder.ROMA:
|
|
350
|
+
# Convert Roma to ITK: swap X and Z
|
|
351
|
+
origin = [origin[2], origin[1], origin[0]]
|
|
352
|
+
|
|
324
353
|
active_volume.update_slice_positions(
|
|
325
354
|
frame,
|
|
326
355
|
origin,
|
|
327
356
|
rotation_sequence,
|
|
328
357
|
rotation_angles,
|
|
329
|
-
self.scene.angle_units,
|
|
358
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
330
359
|
)
|
|
331
360
|
|
|
332
361
|
# Apply window/level
|
|
@@ -517,16 +546,21 @@ class Logic:
|
|
|
517
546
|
)
|
|
518
547
|
rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
|
|
519
548
|
|
|
549
|
+
mpr_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
550
|
+
rotation_seq.mpr_origin = list(mpr_origin)
|
|
551
|
+
|
|
520
552
|
rotation_seq.metadata.timestamp = timestamp.isoformat()
|
|
521
553
|
rotation_seq.metadata.volume_label = active_volume_label
|
|
522
554
|
rotation_seq.metadata.coordinate_system = self.scene.coordinate_system
|
|
555
|
+
rotation_seq.metadata.index_order = (
|
|
556
|
+
self.scene.mpr_rotation_sequence.metadata.index_order
|
|
557
|
+
)
|
|
558
|
+
rotation_seq.metadata.angle_units = (
|
|
559
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
560
|
+
)
|
|
523
561
|
|
|
524
562
|
output_path = save_dir / f"{timestamp_str}.toml"
|
|
525
|
-
rotation_seq.to_file(
|
|
526
|
-
output_path,
|
|
527
|
-
target_convention=self.scene.axis_convention,
|
|
528
|
-
target_units=self.scene.angle_units,
|
|
529
|
-
)
|
|
563
|
+
rotation_seq.to_file(output_path)
|
|
530
564
|
|
|
531
565
|
def reset_all(self):
|
|
532
566
|
self.server.state.frame = 0
|
|
@@ -560,9 +594,9 @@ class Logic:
|
|
|
560
594
|
from .orientation import AngleUnits
|
|
561
595
|
|
|
562
596
|
# Get current units before changing
|
|
563
|
-
old_units = self.scene.angle_units
|
|
597
|
+
old_units = self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
564
598
|
|
|
565
|
-
# Update
|
|
599
|
+
# Update based on UI selection
|
|
566
600
|
new_units = None
|
|
567
601
|
if angle_units == "degrees":
|
|
568
602
|
new_units = AngleUnits.DEGREES
|
|
@@ -592,16 +626,53 @@ class Logic:
|
|
|
592
626
|
|
|
593
627
|
self.server.state.mpr_rotation_data = updated_data
|
|
594
628
|
|
|
595
|
-
self.scene.angle_units = new_units
|
|
629
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units = new_units
|
|
630
|
+
|
|
631
|
+
def sync_index_order(self, index_order, **kwargs):
|
|
632
|
+
"""Sync index order selection - converts existing rotations and updates scene."""
|
|
633
|
+
import copy
|
|
596
634
|
|
|
597
|
-
|
|
598
|
-
"""Sync axis convention selection - updates the scene configuration."""
|
|
599
|
-
from .orientation import AxisConvention
|
|
635
|
+
from .orientation import IndexOrder
|
|
600
636
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
637
|
+
old_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
638
|
+
|
|
639
|
+
# Convert string input to enum
|
|
640
|
+
if isinstance(index_order, str):
|
|
641
|
+
match index_order.lower():
|
|
642
|
+
case "itk":
|
|
643
|
+
new_convention = IndexOrder.ITK
|
|
644
|
+
case "roma":
|
|
645
|
+
new_convention = IndexOrder.ROMA
|
|
646
|
+
case _:
|
|
647
|
+
raise ValueError(f"Unrecognized index order: {index_order}")
|
|
648
|
+
else:
|
|
649
|
+
new_convention = index_order
|
|
650
|
+
|
|
651
|
+
if old_convention == new_convention:
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
rotation_data = getattr(
|
|
655
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
656
|
+
)
|
|
657
|
+
if rotation_data.get("angles_list"):
|
|
658
|
+
updated_data = copy.deepcopy(rotation_data)
|
|
659
|
+
|
|
660
|
+
for rotation in updated_data["angles_list"]:
|
|
661
|
+
current_axis = rotation.get("axes")
|
|
662
|
+
current_angle = rotation.get("angles", [0])[0]
|
|
663
|
+
|
|
664
|
+
# Conversion is the same for both ITK<->ROMA directions
|
|
665
|
+
rotation["axes"] = {"X": "Z", "Y": "Y", "Z": "X"}[current_axis]
|
|
666
|
+
rotation["angles"][0] = -current_angle
|
|
667
|
+
|
|
668
|
+
self.server.state.mpr_rotation_data = updated_data
|
|
669
|
+
|
|
670
|
+
# Transform mpr_origin: swap X and Z (indices 0 and 2)
|
|
671
|
+
mpr_origin = getattr(self.server.state, "mpr_origin", None)
|
|
672
|
+
if mpr_origin is not None and len(mpr_origin) == 3:
|
|
673
|
+
self.server.state.mpr_origin = [mpr_origin[2], mpr_origin[1], mpr_origin[0]]
|
|
674
|
+
|
|
675
|
+
self.scene.mpr_rotation_sequence.metadata.index_order = new_convention
|
|
605
676
|
|
|
606
677
|
def _initialize_clipping_state(self):
|
|
607
678
|
"""Initialize clipping state variables for all objects."""
|
|
@@ -686,6 +757,8 @@ class Logic:
|
|
|
686
757
|
|
|
687
758
|
# Initialize origin to volume center (in LPS coordinates)
|
|
688
759
|
try:
|
|
760
|
+
from .orientation import IndexOrder
|
|
761
|
+
|
|
689
762
|
current_frame = getattr(self.server.state, "frame", 0)
|
|
690
763
|
volume_actor = active_volume.actors[current_frame]
|
|
691
764
|
image_data = volume_actor.GetMapper().GetInput()
|
|
@@ -694,7 +767,16 @@ class Logic:
|
|
|
694
767
|
# Set origin to volume center if it's at default [0,0,0]
|
|
695
768
|
current_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
696
769
|
if current_origin == [0.0, 0.0, 0.0]:
|
|
697
|
-
|
|
770
|
+
# VTK returns center in ITK convention (X=L, Y=P, Z=S)
|
|
771
|
+
# Transform to current convention if needed
|
|
772
|
+
center_list = list(center)
|
|
773
|
+
if (
|
|
774
|
+
self.scene.mpr_rotation_sequence.metadata.index_order
|
|
775
|
+
== IndexOrder.ROMA
|
|
776
|
+
):
|
|
777
|
+
# Convert ITK -> Roma: swap X and Z
|
|
778
|
+
center_list = [center_list[2], center_list[1], center_list[0]]
|
|
779
|
+
self.server.state.mpr_origin = center_list
|
|
698
780
|
except (RuntimeError, IndexError) as e:
|
|
699
781
|
print(f"Error: Cannot get center for volume '{active_volume_label}': {e}")
|
|
700
782
|
return
|
|
@@ -763,6 +845,7 @@ class Logic:
|
|
|
763
845
|
|
|
764
846
|
def update_slice_positions(self, **kwargs):
|
|
765
847
|
"""Update MPR slice positions when sliders change."""
|
|
848
|
+
from .orientation import IndexOrder
|
|
766
849
|
|
|
767
850
|
if not getattr(self.server.state, "mpr_enabled", False):
|
|
768
851
|
return
|
|
@@ -787,13 +870,19 @@ class Logic:
|
|
|
787
870
|
|
|
788
871
|
rotation_sequence, rotation_angles = self._get_visible_rotation_data()
|
|
789
872
|
|
|
873
|
+
# VTK needs origin in ITK convention - convert if necessary
|
|
874
|
+
current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
875
|
+
if current_convention == IndexOrder.ROMA:
|
|
876
|
+
# Convert Roma to ITK: swap X and Z
|
|
877
|
+
origin = [origin[2], origin[1], origin[0]]
|
|
878
|
+
|
|
790
879
|
# Update slice positions with rotation
|
|
791
880
|
active_volume.update_slice_positions(
|
|
792
881
|
current_frame,
|
|
793
882
|
origin,
|
|
794
883
|
rotation_sequence,
|
|
795
884
|
rotation_angles,
|
|
796
|
-
self.scene.angle_units,
|
|
885
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
797
886
|
)
|
|
798
887
|
|
|
799
888
|
# Update all views
|
|
@@ -863,6 +952,8 @@ class Logic:
|
|
|
863
952
|
|
|
864
953
|
def update_mpr_rotation(self, **kwargs):
|
|
865
954
|
"""Update MPR views when rotation changes."""
|
|
955
|
+
from .orientation import IndexOrder
|
|
956
|
+
|
|
866
957
|
if not getattr(self.server.state, "mpr_enabled", False):
|
|
867
958
|
return
|
|
868
959
|
|
|
@@ -886,13 +977,19 @@ class Logic:
|
|
|
886
977
|
|
|
887
978
|
rotation_sequence, rotation_angles = self._get_visible_rotation_data()
|
|
888
979
|
|
|
980
|
+
# VTK needs origin in ITK convention - convert if necessary
|
|
981
|
+
current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
982
|
+
if current_convention == IndexOrder.ROMA:
|
|
983
|
+
# Convert Roma to ITK: swap X and Z
|
|
984
|
+
origin = [origin[2], origin[1], origin[0]]
|
|
985
|
+
|
|
889
986
|
# Update slice positions with rotation
|
|
890
987
|
active_volume.update_slice_positions(
|
|
891
988
|
current_frame,
|
|
892
989
|
origin,
|
|
893
990
|
rotation_sequence,
|
|
894
991
|
rotation_angles,
|
|
895
|
-
self.scene.angle_units,
|
|
992
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
896
993
|
)
|
|
897
994
|
|
|
898
995
|
# Update all views
|
|
@@ -984,6 +1081,9 @@ class Logic:
|
|
|
984
1081
|
self.sync_active_volume(self._pending_active_volume)
|
|
985
1082
|
delattr(self, "_pending_active_volume")
|
|
986
1083
|
|
|
1084
|
+
# Apply loaded rotation data to MPR views
|
|
1085
|
+
self.update_mpr_rotation()
|
|
1086
|
+
|
|
987
1087
|
@asynchronous.task
|
|
988
1088
|
async def close_application(self):
|
|
989
1089
|
"""Close the application by stopping the server."""
|
cardio/orientation.py
CHANGED
|
@@ -1,22 +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
15
|
ITK = "itk" # X=Left, Y=Posterior, Z=Superior
|
|
16
16
|
ROMA = "roma" # X=Superior, Y=Posterior, Z=Left
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class AngleUnits(
|
|
19
|
+
class AngleUnits(enum.StrEnum):
|
|
20
20
|
DEGREES = "degrees"
|
|
21
21
|
RADIANS = "radians"
|
|
22
22
|
|
cardio/rotation.py
CHANGED
|
@@ -9,11 +9,16 @@ import pydantic as pc
|
|
|
9
9
|
import tomlkit as tk
|
|
10
10
|
|
|
11
11
|
# Internal
|
|
12
|
-
from .orientation import AngleUnits,
|
|
12
|
+
from .orientation import AngleUnits, IndexOrder
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class RotationStep(pc.BaseModel):
|
|
16
|
-
"""Single rotation step
|
|
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
|
+
"""
|
|
17
22
|
|
|
18
23
|
axes: ty.Literal["X", "Y", "Z"]
|
|
19
24
|
angle: float = 0.0
|
|
@@ -37,59 +42,44 @@ class RotationStep(pc.BaseModel):
|
|
|
37
42
|
data["axes"] = data["axis"]
|
|
38
43
|
return data
|
|
39
44
|
|
|
40
|
-
def to_convention(
|
|
41
|
-
self, convention: AxisConvention, units: AngleUnits
|
|
42
|
-
) -> tuple[str, float]:
|
|
43
|
-
"""Convert to target convention/units for serialization."""
|
|
44
|
-
axis = self.axes
|
|
45
|
-
angle = self.angle
|
|
46
|
-
|
|
47
|
-
if convention == AxisConvention.ROMA:
|
|
48
|
-
axis = {"X": "Z", "Y": "Y", "Z": "X"}[axis]
|
|
49
|
-
angle = -angle
|
|
50
|
-
|
|
51
|
-
if units == AngleUnits.DEGREES:
|
|
52
|
-
angle = np.degrees(angle)
|
|
53
|
-
|
|
54
|
-
return axis, angle
|
|
55
|
-
|
|
56
|
-
@classmethod
|
|
57
|
-
def from_convention(
|
|
58
|
-
cls,
|
|
59
|
-
axes: str,
|
|
60
|
-
angle: float,
|
|
61
|
-
convention: AxisConvention,
|
|
62
|
-
units: AngleUnits,
|
|
63
|
-
**kwargs,
|
|
64
|
-
) -> "RotationStep":
|
|
65
|
-
"""Create from target convention/units (for deserialization)."""
|
|
66
|
-
if units == AngleUnits.DEGREES:
|
|
67
|
-
angle = np.radians(angle)
|
|
68
|
-
|
|
69
|
-
if convention == AxisConvention.ROMA:
|
|
70
|
-
axes = {"X": "Z", "Y": "Y", "Z": "X"}[axes]
|
|
71
|
-
angle = -angle
|
|
72
|
-
|
|
73
|
-
return cls(axes=axes, angle=angle, **kwargs)
|
|
74
|
-
|
|
75
45
|
|
|
76
46
|
class RotationMetadata(pc.BaseModel):
|
|
77
47
|
"""Metadata for TOML files."""
|
|
78
48
|
|
|
79
49
|
coordinate_system: ty.Literal["LPS"] = "LPS"
|
|
80
|
-
|
|
81
|
-
|
|
50
|
+
index_order: IndexOrder = IndexOrder.ITK
|
|
51
|
+
angle_units: AngleUnits = AngleUnits.RADIANS
|
|
82
52
|
timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
|
|
83
53
|
volume_label: str = ""
|
|
84
54
|
|
|
85
55
|
|
|
86
56
|
class RotationSequence(pc.BaseModel):
|
|
87
|
-
"""Complete rotation sequence
|
|
57
|
+
"""Complete rotation sequence.
|
|
58
|
+
|
|
59
|
+
All data (angles, axes, and origin) are 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
|
+
- Origin: stored in axis order specified by metadata.index_order
|
|
63
|
+
|
|
64
|
+
When convention/units change in the UI, all existing data is converted.
|
|
65
|
+
"""
|
|
88
66
|
|
|
89
67
|
model_config = pc.ConfigDict(frozen=False)
|
|
90
68
|
|
|
91
69
|
metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
|
|
92
70
|
angles_list: list[RotationStep] = pc.Field(default_factory=list)
|
|
71
|
+
mpr_origin: list[float] = pc.Field(
|
|
72
|
+
default_factory=lambda: [0.0, 0.0, 0.0],
|
|
73
|
+
description="MPR origin position [x, y, z] in current index_order convention",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@pc.field_validator("mpr_origin")
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_mpr_origin(cls, v):
|
|
79
|
+
"""Ensure mpr_origin is a 3-element list of floats."""
|
|
80
|
+
if not isinstance(v, list) or len(v) != 3:
|
|
81
|
+
raise ValueError("mpr_origin must be a 3-element list [x, y, z]")
|
|
82
|
+
return [float(x) for x in v]
|
|
93
83
|
|
|
94
84
|
@pc.field_validator("angles_list", mode="before")
|
|
95
85
|
@classmethod
|
|
@@ -100,9 +90,10 @@ class RotationSequence(pc.BaseModel):
|
|
|
100
90
|
return v
|
|
101
91
|
|
|
102
92
|
def to_dict_for_ui(self) -> dict:
|
|
103
|
-
"""Convert to UI format
|
|
93
|
+
"""Convert to UI format.
|
|
104
94
|
|
|
105
|
-
|
|
95
|
+
Angles are passed through in their current units (metadata.angle_units).
|
|
96
|
+
UI expects 'angles' as a list for backward compatibility.
|
|
106
97
|
"""
|
|
107
98
|
return {
|
|
108
99
|
"angles_list": [
|
|
@@ -115,83 +106,38 @@ class RotationSequence(pc.BaseModel):
|
|
|
115
106
|
"deletable": step.deletable,
|
|
116
107
|
}
|
|
117
108
|
for step in self.angles_list
|
|
118
|
-
]
|
|
109
|
+
],
|
|
110
|
+
"mpr_origin": self.mpr_origin,
|
|
119
111
|
}
|
|
120
112
|
|
|
121
113
|
@classmethod
|
|
122
114
|
def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
|
|
123
|
-
"""Create from UI format
|
|
115
|
+
"""Create from UI format.
|
|
116
|
+
|
|
117
|
+
Angles from UI are stored as-is (in current UI units).
|
|
118
|
+
Caller should set metadata.angle_units to match the UI's current units.
|
|
119
|
+
"""
|
|
124
120
|
angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
|
|
125
121
|
metadata = RotationMetadata(volume_label=volume_label)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
metadata_table["timestamp"] = self.metadata.timestamp
|
|
139
|
-
metadata_table["volume_label"] = self.metadata.volume_label
|
|
140
|
-
doc["metadata"] = metadata_table
|
|
141
|
-
|
|
142
|
-
rotations_array = tk.aot()
|
|
143
|
-
for step in self.angles_list:
|
|
144
|
-
rotation = tk.table()
|
|
145
|
-
converted_axis, converted_angle = step.to_convention(
|
|
146
|
-
target_convention, target_units
|
|
147
|
-
)
|
|
148
|
-
rotation["axes"] = converted_axis
|
|
149
|
-
rotation["angle"] = converted_angle
|
|
150
|
-
rotation["visible"] = step.visible
|
|
151
|
-
rotation["name"] = step.name
|
|
152
|
-
rotation["name_editable"] = step.name_editable
|
|
153
|
-
rotation["deletable"] = step.deletable
|
|
154
|
-
rotations_array.append(rotation)
|
|
155
|
-
|
|
156
|
-
doc["angles_list"] = rotations_array
|
|
157
|
-
|
|
158
|
-
return tk.dumps(doc)
|
|
122
|
+
mpr_origin = data.get("mpr_origin", [0.0, 0.0, 0.0])
|
|
123
|
+
return cls(metadata=metadata, angles_list=angles_list, mpr_origin=mpr_origin)
|
|
124
|
+
|
|
125
|
+
def to_toml(self) -> str:
|
|
126
|
+
"""Serialize to TOML using stored serialization preferences."""
|
|
127
|
+
from . import __version__
|
|
128
|
+
|
|
129
|
+
data = self.model_dump(mode="json")
|
|
130
|
+
toml_str = tk.dumps(data)
|
|
131
|
+
|
|
132
|
+
version_comment = f"# Generated by cardio version {__version__}\n\n"
|
|
133
|
+
return version_comment + toml_str
|
|
159
134
|
|
|
160
135
|
@classmethod
|
|
161
136
|
def from_toml(cls, toml_content: str) -> "RotationSequence":
|
|
162
|
-
"""Deserialize from TOML
|
|
137
|
+
"""Deserialize from TOML (no conversions - loads as-is)."""
|
|
163
138
|
doc = tk.loads(toml_content)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
convention = AxisConvention(metadata_dict.get("axis_convention", "itk"))
|
|
167
|
-
units = AngleUnits(metadata_dict.get("units", "radians"))
|
|
168
|
-
|
|
169
|
-
metadata = RotationMetadata(
|
|
170
|
-
coordinate_system=metadata_dict.get("coordinate_system", "LPS"),
|
|
171
|
-
axis_convention=convention,
|
|
172
|
-
units=units,
|
|
173
|
-
timestamp=metadata_dict.get("timestamp", dt.datetime.now().isoformat()),
|
|
174
|
-
volume_label=metadata_dict.get("volume_label", ""),
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
angles_list_data = doc.get("angles_list", [])
|
|
178
|
-
angles_list = []
|
|
179
|
-
for item in angles_list_data:
|
|
180
|
-
axes = item.get("axes", "X")
|
|
181
|
-
angle = item.get("angle", 0.0)
|
|
182
|
-
step = RotationStep.from_convention(
|
|
183
|
-
axes=axes,
|
|
184
|
-
angle=angle,
|
|
185
|
-
convention=convention,
|
|
186
|
-
units=units,
|
|
187
|
-
visible=item.get("visible", True),
|
|
188
|
-
name=item.get("name", ""),
|
|
189
|
-
name_editable=item.get("name_editable", True),
|
|
190
|
-
deletable=item.get("deletable", True),
|
|
191
|
-
)
|
|
192
|
-
angles_list.append(step)
|
|
193
|
-
|
|
194
|
-
return cls(metadata=metadata, angles_list=angles_list)
|
|
139
|
+
data = dict(doc)
|
|
140
|
+
return cls(**data)
|
|
195
141
|
|
|
196
142
|
@classmethod
|
|
197
143
|
def from_file(cls, path: pl.Path) -> "RotationSequence":
|
|
@@ -199,13 +145,8 @@ class RotationSequence(pc.BaseModel):
|
|
|
199
145
|
with open(path, "r") as f:
|
|
200
146
|
return cls.from_toml(f.read())
|
|
201
147
|
|
|
202
|
-
def to_file(
|
|
203
|
-
|
|
204
|
-
path: pl.Path,
|
|
205
|
-
target_convention: AxisConvention,
|
|
206
|
-
target_units: AngleUnits,
|
|
207
|
-
):
|
|
208
|
-
"""Save to TOML file."""
|
|
148
|
+
def to_file(self, path: pl.Path):
|
|
149
|
+
"""Save to TOML file using stored serialization preferences."""
|
|
209
150
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
210
151
|
with open(path, "w") as f:
|
|
211
|
-
f.write(self.to_toml(
|
|
152
|
+
f.write(self.to_toml())
|
cardio/scene.py
CHANGED
|
@@ -8,7 +8,7 @@ import pydantic_settings as ps
|
|
|
8
8
|
import vtk
|
|
9
9
|
|
|
10
10
|
from .mesh import Mesh
|
|
11
|
-
from .orientation import AngleUnits,
|
|
11
|
+
from .orientation import AngleUnits, IndexOrder
|
|
12
12
|
from .rotation import RotationSequence
|
|
13
13
|
from .segmentation import Segmentation
|
|
14
14
|
from .types import RGBColor
|
|
@@ -137,17 +137,9 @@ class Scene(ps.BaseSettings):
|
|
|
137
137
|
default=20,
|
|
138
138
|
description="Maximum number of MPR rotations supported",
|
|
139
139
|
)
|
|
140
|
-
angle_units: AngleUnits = pc.Field(
|
|
141
|
-
default=AngleUnits.RADIANS,
|
|
142
|
-
description="Units for angle measurements in rotation serialization",
|
|
143
|
-
)
|
|
144
140
|
coordinate_system: str = pc.Field(
|
|
145
141
|
default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
|
|
146
142
|
)
|
|
147
|
-
axis_convention: AxisConvention = pc.Field(
|
|
148
|
-
default=AxisConvention.ITK,
|
|
149
|
-
description="Axis convention for saved rotations (ITK: X=L,Y=P,Z=S; Roma: X=S,Y=P,Z=L)",
|
|
150
|
-
)
|
|
151
143
|
mpr_crosshairs_enabled: bool = pc.Field(
|
|
152
144
|
default=True, description="Show crosshair lines indicating slice intersections"
|
|
153
145
|
)
|
|
@@ -200,6 +192,9 @@ class Scene(ps.BaseSettings):
|
|
|
200
192
|
self.mpr_rotation_sequence.metadata.volume_label
|
|
201
193
|
)
|
|
202
194
|
|
|
195
|
+
if self.mpr_rotation_sequence.mpr_origin:
|
|
196
|
+
self.mpr_origin = list(self.mpr_rotation_sequence.mpr_origin)
|
|
197
|
+
|
|
203
198
|
return self
|
|
204
199
|
|
|
205
200
|
# VTK objects as private attributes
|
cardio/ui.py
CHANGED
|
@@ -660,8 +660,8 @@ class UI:
|
|
|
660
660
|
vuetify.VLabel("Convention:")
|
|
661
661
|
with vuetify.VCol(cols="8"):
|
|
662
662
|
vuetify.VSelect(
|
|
663
|
-
v_model=("
|
|
664
|
-
items=("
|
|
663
|
+
v_model=("index_order", "itk"),
|
|
664
|
+
items=("index_order_items", []),
|
|
665
665
|
item_title="text",
|
|
666
666
|
item_value="value",
|
|
667
667
|
dense=True,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cardio
|
|
3
|
-
Version: 2026.1.
|
|
3
|
+
Version: 2026.1.3
|
|
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 2026.1.
|
|
78
|
+
cardio 2026.1.3
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
### Developing
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cardio/__init__.py,sha256=
|
|
1
|
+
cardio/__init__.py,sha256=07x-I5z0KKgiJwm-tqtY1qZTh5YKg32IScpzzFVEeno,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,25 +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=AuKQZ_ACTRrtB4oxPuaKYjajsoA3yoszw7GnZK11UdQ,44721
|
|
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/rotation.py,sha256=
|
|
16
|
-
cardio/scene.py,sha256=
|
|
15
|
+
cardio/rotation.py,sha256=BCh3x347AdnsRMK-6-N9o12jnGiwg7vG-DuJirzn468,5341
|
|
16
|
+
cardio/scene.py,sha256=FrUQIJrBR_jPrW7utOI3uDbaUxjmZEapzVTEVIgEWaA,15365
|
|
17
17
|
cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
|
|
18
18
|
cardio/segmentation.py,sha256=KT1ClgultXyGpZDdpYxor38GflY7uCgRzfA8JEGQVaU,6642
|
|
19
19
|
cardio/transfer_function_pair.py,sha256=_J0qXA0InUPpvfWPcW492KGSYAqeb12htCzKBSpWHwo,780
|
|
20
20
|
cardio/types.py,sha256=jxSZjvxEJ03OThfupT2CG9UHsFklwbWeFrUozNXro2I,333
|
|
21
|
-
cardio/ui.py,sha256=
|
|
21
|
+
cardio/ui.py,sha256=esmJzoJE-lKoWHYWg1Zbq_Z1ydIt4ggHXf1i14J0PrM,53181
|
|
22
22
|
cardio/utils.py,sha256=ao4a7_vMjGBxTOMhZ7r0D0W4ujiwKPS0i8Xfmn3Gv9k,1497
|
|
23
23
|
cardio/volume.py,sha256=sdQ7gtX4jQDD9U8U95xD9LIV_1hpykj8NTCo1_aIKVM,15035
|
|
24
24
|
cardio/volume_property.py,sha256=-EUMV9sWCaetgGjnqIWxPp39qrxEZKTNDJ5GHUgLMlk,1619
|
|
25
25
|
cardio/volume_property_presets.py,sha256=4-hjo-dukm5sMMmWidbWnVXq0IN4sWpBnDITY9MqUFg,1625
|
|
26
26
|
cardio/window_level.py,sha256=XMkwLAHcmsEYcI0SoHySQZvptq4VwX2gj--ps3hV8AQ,784
|
|
27
|
-
cardio-2026.1.
|
|
28
|
-
cardio-2026.1.
|
|
29
|
-
cardio-2026.1.
|
|
30
|
-
cardio-2026.1.
|
|
27
|
+
cardio-2026.1.3.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
28
|
+
cardio-2026.1.3.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
|
|
29
|
+
cardio-2026.1.3.dist-info/METADATA,sha256=BE_DMgEZ959_kbfIfIMTbDs-hIhjep_SGFSmoeMm3Ag,3518
|
|
30
|
+
cardio-2026.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|