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 CHANGED
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2026.1.0"
29
+ __version__ = "2026.1.3"
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.axis_convention_items = [
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("axis_convention")(self.sync_axis_convention)
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 = self.scene.angle_units.value
193
- self.server.state.axis_convention = self.scene.axis_convention.value
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 the scene's angle_units field based on UI selection
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
- def sync_axis_convention(self, axis_convention, **kwargs):
598
- """Sync axis convention selection - updates the scene configuration."""
599
- from .orientation import AxisConvention
635
+ from .orientation import IndexOrder
600
636
 
601
- if axis_convention == "itk":
602
- self.scene.axis_convention = AxisConvention.ITK
603
- elif axis_convention == "roma":
604
- self.scene.axis_convention = AxisConvention.ROMA
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
- self.server.state.mpr_origin = list(center)
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
- from enum import Enum
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(Enum):
8
+ class EulerAxis(enum.StrEnum):
9
9
  X = "X"
10
10
  Y = "Y"
11
11
  Z = "Z"
12
12
 
13
13
 
14
- class AxisConvention(Enum):
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(Enum):
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, AxisConvention
12
+ from .orientation import AngleUnits, IndexOrder
13
13
 
14
14
 
15
15
  class RotationStep(pc.BaseModel):
16
- """Single rotation step (stored in ITK convention, radians)."""
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
- axis_convention: AxisConvention = AxisConvention.ITK
81
- units: AngleUnits = AngleUnits.RADIANS
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 (stored in ITK convention, radians)."""
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 (always ITK/radians internally).
93
+ """Convert to UI format.
104
94
 
105
- Note: UI expects 'angles' as a list for backward compatibility.
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 (assumes ITK/radians)."""
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
- return cls(metadata=metadata, angles_list=angles_list)
127
-
128
- def to_toml(
129
- self, target_convention: AxisConvention, target_units: AngleUnits
130
- ) -> str:
131
- """Serialize to TOML with conversions."""
132
- doc = tk.document()
133
-
134
- metadata_table = tk.table()
135
- metadata_table["coordinate_system"] = self.metadata.coordinate_system
136
- metadata_table["axis_convention"] = target_convention.value
137
- metadata_table["units"] = target_units.value
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 with conversions."""
137
+ """Deserialize from TOML (no conversions - loads as-is)."""
163
138
  doc = tk.loads(toml_content)
164
-
165
- metadata_dict = doc.get("metadata", {})
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
- self,
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(target_convention, target_units))
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, AxisConvention
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=("axis_convention", "itk"),
664
- items=("axis_convention_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.0
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., `\*.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
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.0
78
+ cardio 2026.1.3
79
79
  ```
80
80
 
81
81
  ### Developing
@@ -1,4 +1,4 @@
1
- cardio/__init__.py,sha256=WZ7MfzISF8ryIC9NdIyub4mP6DQBWsMX766IPcWihXg,601
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=pjAqiJc_3pfUpB9hXWiSYJTmbG4c1omuUvuT2gshPNo,40431
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=zOw3R284izAcKXnCCYYFOzOPBqgiE8om8q2JDWTQFoY,6256
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=eUUk74kBpPr-YCZdIAcU5pIX6FKeVGT8dRg2YnaS2yE,7204
16
- cardio/scene.py,sha256=2oq-zVMSHj9PhNToSMbk8RzJ3jKzo7oaAvDn_U4vcPM,15583
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=uYyO1-Lysi5guFDv4k3HSGJ12Ro27B1yxkmS_PFbd-M,53189
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.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
28
- cardio-2026.1.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
29
- cardio-2026.1.0.dist-info/METADATA,sha256=xulm4Dgn_80ZFMHSKEBtxnoEfYKFFf3Pfb3aNM1fgzw,3520
30
- cardio-2026.1.0.dist-info/RECORD,,
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,,