cardio 2026.1.3__tar.gz → 2026.2.0__tar.gz

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.
Files changed (30) hide show
  1. {cardio-2026.1.3 → cardio-2026.2.0}/PKG-INFO +2 -2
  2. {cardio-2026.1.3 → cardio-2026.2.0}/README.md +1 -1
  3. {cardio-2026.1.3 → cardio-2026.2.0}/pyproject.toml +2 -2
  4. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/__init__.py +1 -1
  5. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/logic.py +58 -47
  6. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/rotation.py +8 -63
  7. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/ui.py +29 -19
  8. {cardio-2026.1.3 → cardio-2026.2.0}/LICENSE +0 -0
  9. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/app.py +0 -0
  10. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/assets/bone.toml +0 -0
  11. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/assets/vascular_closed.toml +0 -0
  12. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/assets/vascular_open.toml +0 -0
  13. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/assets/xray.toml +0 -0
  14. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/blend_transfer_functions.py +0 -0
  15. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/color_transfer_function.py +0 -0
  16. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/mesh.py +0 -0
  17. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/object.py +0 -0
  18. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/orientation.py +0 -0
  19. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/piecewise_function.py +0 -0
  20. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/property_config.py +0 -0
  21. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/scene.py +0 -0
  22. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/screenshot.py +0 -0
  23. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/segmentation.py +0 -0
  24. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/transfer_function_pair.py +0 -0
  25. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/types.py +0 -0
  26. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/utils.py +0 -0
  27. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/volume.py +0 -0
  28. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/volume_property.py +0 -0
  29. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/volume_property_presets.py +0 -0
  30. {cardio-2026.1.3 → cardio-2026.2.0}/src/cardio/window_level.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cardio
3
- Version: 2026.1.3
3
+ Version: 2026.2.0
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
@@ -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.3
78
+ cardio 2026.2.0
79
79
  ```
80
80
 
81
81
  ### Developing
@@ -19,7 +19,7 @@ $ uv init
19
19
  $ uv add cardio
20
20
  $ . ./.venv/bin/activate
21
21
  (project) cardio --version
22
- cardio 2026.1.3
22
+ cardio 2026.2.0
23
23
  ```
24
24
 
25
25
  ### Developing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "cardio"
7
- version = "2026.1.3"
7
+ version = "2026.2.0"
8
8
  authors = [
9
9
  {name = "Davis Marc Vigneault", email = "davis.vigneault@gmail.com"}
10
10
  ]
@@ -62,7 +62,7 @@ repository = "https://github.com/sudomakeinstall/cardio"
62
62
  profile = "black"
63
63
 
64
64
  [tool.bumpver]
65
- current_version = "2026.1.3"
65
+ current_version = "2026.2.0"
66
66
  version_pattern = "YYYY.MM.INC0"
67
67
  commit_message = "ENH: Bump version from {old_version} => {new_version}"
68
68
  commit = true
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2026.1.3"
29
+ __version__ = "2026.2.0"
@@ -28,8 +28,8 @@ class Logic:
28
28
  visible_index = 0
29
29
  for rotation in angles_list:
30
30
  if rotation.get("visible", True):
31
- rotation_sequence.append({"axis": rotation["axes"]})
32
- rotation_angles[visible_index] = rotation["angles"][0]
31
+ rotation_sequence.append({"axis": rotation["axis"]})
32
+ rotation_angles[visible_index] = rotation["angle"]
33
33
  visible_index += 1
34
34
 
35
35
  # CRITICAL: VTK always needs rotations in ITK convention
@@ -201,17 +201,18 @@ class Logic:
201
201
  self.server.state.mpr_level = self.scene.mpr_level
202
202
  self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
203
203
 
204
- # Initialize rotation data from RotationSequence
204
+ # Initialize rotation data from RotationSequence (includes metadata)
205
205
  self.server.state.mpr_rotation_data = (
206
- self.scene.mpr_rotation_sequence.to_dict_for_ui()
206
+ self.scene.mpr_rotation_sequence.model_dump(mode="json")
207
207
  )
208
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
- )
209
+ # Keep mirror variables for UI binding convenience
210
+ self.server.state.angle_units = self.server.state.mpr_rotation_data["metadata"][
211
+ "angle_units"
212
+ ]
213
+ self.server.state.index_order = self.server.state.mpr_rotation_data["metadata"][
214
+ "index_order"
215
+ ]
215
216
 
216
217
  # Initialize MPR presets data
217
218
  try:
@@ -541,23 +542,14 @@ class Logic:
541
542
  save_dir = self.scene.rotations_directory / active_volume_label
542
543
  save_dir.mkdir(parents=True, exist_ok=True)
543
544
 
544
- rotation_data = getattr(
545
- self.server.state, "mpr_rotation_data", {"angles_list": []}
546
- )
547
- rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
545
+ rotation_data = getattr(self.server.state, "mpr_rotation_data", {})
548
546
 
549
- mpr_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
550
- rotation_seq.mpr_origin = list(mpr_origin)
547
+ # Create RotationSequence directly from full data structure
548
+ rotation_seq = RotationSequence(**rotation_data)
551
549
 
550
+ # Update only timestamp and volume_label (rest already in metadata)
552
551
  rotation_seq.metadata.timestamp = timestamp.isoformat()
553
552
  rotation_seq.metadata.volume_label = active_volume_label
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
- )
561
553
 
562
554
  output_path = save_dir / f"{timestamp_str}.toml"
563
555
  rotation_seq.to_file(output_path)
@@ -606,26 +598,31 @@ class Logic:
606
598
  if new_units is None or old_units == new_units:
607
599
  return
608
600
 
609
- # Convert all existing rotation angles
610
- rotation_data = getattr(
611
- self.server.state, "mpr_rotation_data", {"angles_list": []}
601
+ # Get current rotation data (now includes metadata)
602
+ rotation_data = copy.deepcopy(
603
+ getattr(self.server.state, "mpr_rotation_data", {})
612
604
  )
613
- if rotation_data.get("angles_list"):
614
- updated_data = copy.deepcopy(rotation_data)
615
605
 
616
- for rotation in updated_data["angles_list"]:
617
- current_angle = rotation.get("angles", [0])[0]
606
+ # Convert all existing rotation angles
607
+ if rotation_data.get("angles_list"):
608
+ for rotation in rotation_data["angles_list"]:
609
+ current_angle = rotation.get("angle", 0)
618
610
 
619
611
  # Convert based on old -> new units
620
612
  if old_units == AngleUnits.DEGREES and new_units == AngleUnits.RADIANS:
621
- rotation["angles"][0] = np.radians(current_angle)
613
+ rotation["angle"] = np.radians(current_angle)
622
614
  elif (
623
615
  old_units == AngleUnits.RADIANS and new_units == AngleUnits.DEGREES
624
616
  ):
625
- rotation["angles"][0] = np.degrees(current_angle)
617
+ rotation["angle"] = np.degrees(current_angle)
626
618
 
627
- self.server.state.mpr_rotation_data = updated_data
619
+ # Update nested metadata
620
+ rotation_data["metadata"]["angle_units"] = angle_units
628
621
 
622
+ # Update state (triggers re-render)
623
+ self.server.state.mpr_rotation_data = rotation_data
624
+
625
+ # Update scene
629
626
  self.scene.mpr_rotation_sequence.metadata.angle_units = new_units
630
627
 
631
628
  def sync_index_order(self, index_order, **kwargs):
@@ -651,27 +648,33 @@ class Logic:
651
648
  if old_convention == new_convention:
652
649
  return
653
650
 
654
- rotation_data = getattr(
655
- self.server.state, "mpr_rotation_data", {"angles_list": []}
651
+ # Get current rotation data (now includes metadata)
652
+ rotation_data = copy.deepcopy(
653
+ getattr(self.server.state, "mpr_rotation_data", {})
656
654
  )
657
- if rotation_data.get("angles_list"):
658
- updated_data = copy.deepcopy(rotation_data)
659
655
 
660
- for rotation in updated_data["angles_list"]:
661
- current_axis = rotation.get("axes")
662
- current_angle = rotation.get("angles", [0])[0]
656
+ # Convert rotation axes and angles
657
+ if rotation_data.get("angles_list"):
658
+ for rotation in rotation_data["angles_list"]:
659
+ current_axis = rotation.get("axis")
660
+ current_angle = rotation.get("angle", 0)
663
661
 
664
662
  # 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
663
+ rotation["axis"] = {"X": "Z", "Y": "Y", "Z": "X"}[current_axis]
664
+ rotation["angle"] = -current_angle
665
+
666
+ # Update nested metadata
667
+ rotation_data["metadata"]["index_order"] = index_order
667
668
 
668
- self.server.state.mpr_rotation_data = updated_data
669
+ # Update state (triggers re-render)
670
+ self.server.state.mpr_rotation_data = rotation_data
669
671
 
670
672
  # Transform mpr_origin: swap X and Z (indices 0 and 2)
671
673
  mpr_origin = getattr(self.server.state, "mpr_origin", None)
672
674
  if mpr_origin is not None and len(mpr_origin) == 3:
673
675
  self.server.state.mpr_origin = [mpr_origin[2], mpr_origin[1], mpr_origin[0]]
674
676
 
677
+ # Update scene
675
678
  self.scene.mpr_rotation_sequence.metadata.index_order = new_convention
676
679
 
677
680
  def _initialize_clipping_state(self):
@@ -1030,8 +1033,8 @@ class Logic:
1030
1033
 
1031
1034
  angles_list.append(
1032
1035
  {
1033
- "axes": axis,
1034
- "angles": [0],
1036
+ "axis": axis,
1037
+ "angle": 0,
1035
1038
  "visible": True,
1036
1039
  "name": "",
1037
1040
  "name_editable": True,
@@ -1065,13 +1068,21 @@ class Logic:
1065
1068
  angles_list = current_data["angles_list"]
1066
1069
 
1067
1070
  if 0 <= index < len(angles_list):
1068
- angles_list[index]["angles"][0] = 0
1071
+ angles_list[index]["angle"] = 0
1069
1072
  current_data["angles_list"] = angles_list
1070
1073
  self.server.state.mpr_rotation_data = current_data
1071
1074
 
1072
1075
  def reset_mpr_rotations(self):
1073
1076
  """Reset all MPR rotations."""
1074
- self.server.state.mpr_rotation_data = {"angles_list": []}
1077
+ from .rotation import RotationSequence
1078
+
1079
+ # Create fresh RotationSequence and serialize
1080
+ new_rotation_seq = RotationSequence()
1081
+ self.server.state.mpr_rotation_data = new_rotation_seq.model_dump(mode="json")
1082
+
1083
+ # Update mirror variables
1084
+ self.server.state.angle_units = "radians"
1085
+ self.server.state.index_order = "itk"
1075
1086
 
1076
1087
  def finalize_mpr_initialization(self, **kwargs):
1077
1088
  """Set the active volume label after UI is ready to avoid race condition."""
@@ -15,33 +15,18 @@ from .orientation import AngleUnits, IndexOrder
15
15
  class RotationStep(pc.BaseModel):
16
16
  """Single rotation step.
17
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
18
+ Both angle and axis are stored in the current convention/units.
19
+ - Angle: stored in units specified by parent metadata.angle_units
20
+ - Axis: stored in convention specified by parent metadata.index_order
21
21
  """
22
22
 
23
- axes: ty.Literal["X", "Y", "Z"]
23
+ axis: ty.Literal["X", "Y", "Z"]
24
24
  angle: float = 0.0
25
25
  visible: bool = True
26
26
  name: str = ""
27
27
  name_editable: bool = True
28
28
  deletable: bool = True
29
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
30
 
46
31
  class RotationMetadata(pc.BaseModel):
47
32
  """Metadata for TOML files."""
@@ -51,14 +36,15 @@ class RotationMetadata(pc.BaseModel):
51
36
  angle_units: AngleUnits = AngleUnits.RADIANS
52
37
  timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
53
38
  volume_label: str = ""
39
+ deletable: bool = True
54
40
 
55
41
 
56
42
  class RotationSequence(pc.BaseModel):
57
43
  """Complete rotation sequence.
58
44
 
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
45
+ All data (angle, axis, and origin) are stored in the current convention/units:
46
+ - Angle: stored in units specified by metadata.angle_units
47
+ - Axis: stored in convention specified by metadata.index_order
62
48
  - Origin: stored in axis order specified by metadata.index_order
63
49
 
64
50
  When convention/units change in the UI, all existing data is converted.
@@ -81,47 +67,6 @@ class RotationSequence(pc.BaseModel):
81
67
  raise ValueError("mpr_origin must be a 3-element list [x, y, z]")
82
68
  return [float(x) for x in v]
83
69
 
84
- @pc.field_validator("angles_list", mode="before")
85
- @classmethod
86
- def convert_legacy_list(cls, v):
87
- """Convert legacy list of dicts to list of RotationStep."""
88
- if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
89
- return [RotationStep(**item) for item in v]
90
- return v
91
-
92
- def to_dict_for_ui(self) -> dict:
93
- """Convert to UI format.
94
-
95
- Angles are passed through in their current units (metadata.angle_units).
96
- UI expects 'angles' as a list for backward compatibility.
97
- """
98
- return {
99
- "angles_list": [
100
- {
101
- "axes": step.axes,
102
- "angles": [step.angle],
103
- "visible": step.visible,
104
- "name": step.name,
105
- "name_editable": step.name_editable,
106
- "deletable": step.deletable,
107
- }
108
- for step in self.angles_list
109
- ],
110
- "mpr_origin": self.mpr_origin,
111
- }
112
-
113
- @classmethod
114
- def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
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
- """
120
- angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
121
- metadata = RotationMetadata(volume_label=volume_label)
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
70
  def to_toml(self) -> str:
126
71
  """Serialize to TOML using stored serialization preferences."""
127
72
  from . import __version__
@@ -8,7 +8,12 @@ from trame.widgets import html
8
8
  from trame.widgets import vtk as vtk_widgets
9
9
  from trame.widgets import vuetify3 as vuetify
10
10
 
11
- from .orientation import AngleUnits, EulerAxis, euler_angle_to_rotation_matrix
11
+ from .orientation import (
12
+ AngleUnits,
13
+ EulerAxis,
14
+ IndexOrder,
15
+ euler_angle_to_rotation_matrix,
16
+ )
12
17
  from .scene import Scene
13
18
  from .volume_property_presets import list_volume_property_presets
14
19
  from .window_level import presets
@@ -151,6 +156,10 @@ class UI:
151
156
  if view_name not in base_normals:
152
157
  return np.array([0.0, 0.0, 1.0])
153
158
 
159
+ current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
160
+ if current_convention == IndexOrder.ROMA:
161
+ base_normals = {k: v[::-1] for k, v in base_normals.items()}
162
+
154
163
  # Build cumulative rotation from visible rotations
155
164
  rotation_data = getattr(
156
165
  self.server.state, "mpr_rotation_data", {"angles_list": []}
@@ -164,9 +173,9 @@ class UI:
164
173
 
165
174
  for rotation in angles_list:
166
175
  if rotation.get("visible", True):
167
- angle = rotation.get("angles", [0])[0]
176
+ angle = rotation.get("angle", 0)
168
177
  rotation_matrix = euler_angle_to_rotation_matrix(
169
- EulerAxis(rotation["axes"]), angle, angle_units
178
+ EulerAxis(rotation["axis"]), angle, angle_units
170
179
  )
171
180
  cumulative_rotation = cumulative_rotation @ rotation_matrix
172
181
 
@@ -517,20 +526,6 @@ class UI:
517
526
  color="primary",
518
527
  )
519
528
 
520
- # Reset rotations button
521
- vuetify.VBtn(
522
- "Remove All Rotations",
523
- v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
524
- click=self.server.controller.reset_rotations,
525
- small=True,
526
- dense=True,
527
- outlined=True,
528
- color="warning",
529
- block=True,
530
- classes="mb-2",
531
- prepend_icon="mdi-refresh",
532
- )
533
-
534
529
  # Individual rotation sliders with DeepReactive
535
530
  with client.DeepReactive("mpr_rotation_data"):
536
531
  for i in range(self.scene.max_mpr_rotations):
@@ -560,7 +555,7 @@ class UI:
560
555
  with vuetify.VCol(cols="12"):
561
556
  vuetify.VSlider(
562
557
  v_model=(
563
- f"mpr_rotation_data.angles_list[{i}].angles[0]",
558
+ f"mpr_rotation_data.angles_list[{i}].angle",
564
559
  ),
565
560
  min=(
566
561
  "angle_units === 'radians' ? -Math.PI : -180",
@@ -582,7 +577,7 @@ class UI:
582
577
  with vuetify.VCol(cols="4"):
583
578
  vuetify.VSelect(
584
579
  v_model=(
585
- f"mpr_rotation_data.angles_list[{i}].axes",
580
+ f"mpr_rotation_data.angles_list[{i}].axis",
586
581
  ),
587
582
  items=(["X", "Y", "Z"],),
588
583
  dense=True,
@@ -683,6 +678,21 @@ class UI:
683
678
  prepend_icon="mdi-content-save",
684
679
  )
685
680
 
681
+ # Delete rotations button
682
+ vuetify.VBtn(
683
+ "Delete All Rotations",
684
+ v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
685
+ click=self.server.controller.reset_rotations,
686
+ small=True,
687
+ dense=True,
688
+ outlined=True,
689
+ color="error",
690
+ block=True,
691
+ classes="mb-2",
692
+ prepend_icon="mdi-refresh",
693
+ disabled=("!mpr_rotation_data.metadata.deletable",),
694
+ )
695
+
686
696
  vuetify.VDivider(classes="my-2")
687
697
 
688
698
  vuetify.VListSubheader("Playback Controls")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes