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 CHANGED
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2025.12.0"
29
+ __version__ = "2026.1.2"
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
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
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
- for i in range(len(rotation_sequence)):
16
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
17
- if is_visible:
18
- rotation_angles[i] = getattr(
19
- self.server.state, f"mpr_rotation_angle_{i}", 0
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("mpr_rotation_sequence")(self.update_mpr_rotation)
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
- self.server.state.mpr_rotation_sequence = self.scene.mpr_rotation_sequence
180
- self.server.state.angle_units = self.scene.angle_units.value
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 a TOML file."""
494
- import tomlkit as tk
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 for rotation saving")
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
- # Create TOML structure
512
- doc = tk.document()
513
-
514
- # Metadata section
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
- # Origin position section
523
- origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
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
- doc["rotations"] = rotations_array
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
- with open(output_path, "w") as f:
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
- from .utils import AngleUnit
582
+ import copy
583
+
584
+ import numpy as np
577
585
 
578
- # Update the scene's angle_units field based on UI selection
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
- self.scene.angle_units = AngleUnit.DEGREES
594
+ new_units = AngleUnits.DEGREES
581
595
  elif angle_units == "radians":
582
- self.scene.angle_units = AngleUnit.RADIANS
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
- current_sequence = copy.deepcopy(
905
- getattr(self.server.state, "mpr_rotation_sequence", [])
986
+ current_data = copy.deepcopy(
987
+ getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
906
988
  )
907
- current_sequence.append({"axis": axis, "angle": 0})
908
- self.server.state.mpr_rotation_sequence = current_sequence
909
- self.update_mpr_rotation_labels()
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 and all subsequent rotations."""
913
- sequence = list(getattr(self.server.state, "mpr_rotation_sequence", []))
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
- # Reset angle states for all removed rotations
919
- for i in range(index, self.scene.max_mpr_rotations):
920
- setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
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
- self.update_mpr_rotation_labels()
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.mpr_rotation_sequence = []
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
- 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 AngleUnits(Enum):
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: list = pc.Field(
128
- default_factory=list,
129
- description="Dynamic rotation sequence for MPR views - list of rotation steps",
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="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
141
+ default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
141
142
  )
142
143
  mpr_crosshairs_enabled: bool = pc.Field(
143
- default=False, description="Show crosshair lines indicating slice intersections"
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
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
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
- for i in range(len(rotation_sequence)):
158
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
159
- if is_visible:
160
- angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
161
- rotation = rotation_sequence[i]
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["axis"]), angle
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
- "Reset",
568
- v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
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
- for i in range(self.scene.max_mpr_rotations):
581
- with vuetify.VRow(
582
- v_if=f"!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
583
- no_gutters=True,
584
- classes="align-center mb-1",
585
- ):
586
- with vuetify.VCol(cols="8"):
587
- vuetify.VSlider(
588
- v_model=(f"mpr_rotation_angle_{i}", 0),
589
- min=-180,
590
- max=180,
591
- step=1,
592
- hint=(
593
- f"mpr_rotation_axis_{i}",
594
- f"Rotation {i + 1}",
595
- ),
596
- persistent_hint=True,
597
- dense=True,
598
- hide_details=False,
599
- thumb_label=True,
600
- )
601
- with vuetify.VCol(cols="2"):
602
- vuetify.VCheckbox(
603
- v_model=(f"mpr_rotation_visible_{i}", True),
604
- true_icon="mdi-eye",
605
- false_icon="mdi-eye-off",
606
- hide_details=True,
607
- dense=True,
608
- title="Toggle this rotation and all subsequent ones",
609
- )
610
- with vuetify.VCol(cols="2"):
611
- vuetify.VBtn(
612
- icon="mdi-delete",
613
- click=ft.partial(
614
- self.server.controller.remove_rotation_event, i
615
- ),
616
- small=True,
617
- dense=True,
618
- color="error",
619
- title="Remove this rotation and all subsequent ones",
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", "degrees"),
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 && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
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("axial", (0, 0, 1))
209
+ *colors.get("coronal", (0, 1, 0))
210
210
  )
211
211
  view_crosshairs["line2"]["actor"].GetProperty().SetColor(
212
- *colors.get("coronal", (0, 1, 0))
212
+ *colors.get("axial", (0, 0, 1))
213
213
  )
214
214
  else: # coronal
215
215
  view_crosshairs["line1"]["actor"].GetProperty().SetColor(
216
- *colors.get("axial", (0, 0, 1))
216
+ *colors.get("sagittal", (1, 0, 0))
217
217
  )
218
218
  view_crosshairs["line2"]["actor"].GetProperty().SetColor(
219
- *colors.get("sagittal", (1, 0, 0))
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: 2025.12.0
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., `\*.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 2025.12.0
78
+ cardio 2026.1.2
79
79
  ```
80
80
 
81
81
  ### Developing
@@ -1,4 +1,4 @@
1
- cardio/__init__.py,sha256=laKThzytBHNMY_Z5Qy9-jF25NpfSyfDM9VGrG4bhJ14,602
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=e7OaroXgpq6oE3Zfa2afusVsEzopkpL4YdEGPuFINzc,39885
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=GRm6Ix3hzMXybgKgpJip0Mlodqn_LCV9VaGFjTAmS-A,6122
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/scene.py,sha256=H_GxJ6Dq2QVx7RJYwwvleLL5M_egTzFd0bJ95TqtrhI,14530
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=KwRGb_vDZ6pi8TQ82Ios7zcG9xmvd2hzCv45h0voNMM,50109
21
- cardio/utils.py,sha256=tFUQ4FxfidTH6GjEIKQwguqhO9T_wJ2Vk0IhbEfxRGA,1616
22
- cardio/volume.py,sha256=wKvEhjCGhnn8Se-VVNyurspfH9IgAvqp7Br_j0wUdDM,14536
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-2025.12.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
27
- cardio-2025.12.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
28
- cardio-2025.12.0.dist-info/METADATA,sha256=y1706xsek2kYRBsXskEqYxAHdBl5ovdWOIkXULc572o,3522
29
- cardio-2025.12.0.dist-info/RECORD,,
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,,