cardio 2025.10.1__py3-none-any.whl → 2026.1.0__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/mesh.py CHANGED
@@ -1,12 +1,9 @@
1
- # System
2
1
  import enum
3
2
  import logging
4
3
 
5
- # Third Party
6
4
  import pydantic as pc
7
5
  import vtk
8
6
 
9
- # Internal
10
7
  from .object import Object
11
8
  from .property_config import Representation, vtkPropertyConfig
12
9
 
cardio/object.py CHANGED
@@ -1,10 +1,8 @@
1
- # System
2
1
  import functools
3
2
  import logging
4
3
  import pathlib as pl
5
4
  import re
6
5
 
7
- # Third Party
8
6
  import pydantic as pc
9
7
  import vtk
10
8
 
cardio/orientation.py CHANGED
@@ -1,9 +1,7 @@
1
- # System
2
1
  from enum import Enum
3
2
 
4
- # Third Party
5
- import numpy as np
6
3
  import itk
4
+ import numpy as np
7
5
 
8
6
 
9
7
  # DICOM LPS canonical orientation vector mappings
@@ -13,6 +11,11 @@ class EulerAxis(Enum):
13
11
  Z = "Z"
14
12
 
15
13
 
14
+ class AxisConvention(Enum):
15
+ ITK = "itk" # X=Left, Y=Posterior, Z=Superior
16
+ ROMA = "roma" # X=Superior, Y=Posterior, Z=Left
17
+
18
+
16
19
  class AngleUnits(Enum):
17
20
  DEGREES = "degrees"
18
21
  RADIANS = "radians"
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .types import ScalarComponent
7
5
 
8
6
 
cardio/property_config.py CHANGED
@@ -1,12 +1,9 @@
1
- # System
2
1
  import enum
3
2
  import functools
4
3
 
5
- # Third Party
6
4
  import pydantic as pc
7
5
  import vtk
8
6
 
9
- # Internal
10
7
  from .types import RGBColor, ScalarComponent
11
8
 
12
9
 
cardio/rotation.py ADDED
@@ -0,0 +1,211 @@
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, AxisConvention
13
+
14
+
15
+ class RotationStep(pc.BaseModel):
16
+ """Single rotation step (stored in ITK convention, radians)."""
17
+
18
+ axes: ty.Literal["X", "Y", "Z"]
19
+ angle: float = 0.0
20
+ visible: bool = True
21
+ name: str = ""
22
+ name_editable: bool = True
23
+ deletable: bool = True
24
+
25
+ @pc.model_validator(mode="before")
26
+ @classmethod
27
+ def handle_legacy_format(cls, data):
28
+ """Handle legacy 'angles' list format and 'axis' field."""
29
+ if isinstance(data, dict):
30
+ if "angles" in data and "angle" not in data:
31
+ angles = data["angles"]
32
+ if isinstance(angles, list) and len(angles) > 0:
33
+ data["angle"] = angles[0]
34
+ else:
35
+ data["angle"] = angles
36
+ if "axis" in data and "axes" not in data:
37
+ data["axes"] = data["axis"]
38
+ return data
39
+
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
+
76
+ class RotationMetadata(pc.BaseModel):
77
+ """Metadata for TOML files."""
78
+
79
+ coordinate_system: ty.Literal["LPS"] = "LPS"
80
+ axis_convention: AxisConvention = AxisConvention.ITK
81
+ units: AngleUnits = AngleUnits.RADIANS
82
+ timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
83
+ volume_label: str = ""
84
+
85
+
86
+ class RotationSequence(pc.BaseModel):
87
+ """Complete rotation sequence (stored in ITK convention, radians)."""
88
+
89
+ model_config = pc.ConfigDict(frozen=False)
90
+
91
+ metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
92
+ angles_list: list[RotationStep] = pc.Field(default_factory=list)
93
+
94
+ @pc.field_validator("angles_list", mode="before")
95
+ @classmethod
96
+ def convert_legacy_list(cls, v):
97
+ """Convert legacy list of dicts to list of RotationStep."""
98
+ if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
99
+ return [RotationStep(**item) for item in v]
100
+ return v
101
+
102
+ def to_dict_for_ui(self) -> dict:
103
+ """Convert to UI format (always ITK/radians internally).
104
+
105
+ Note: UI expects 'angles' as a list for backward compatibility.
106
+ """
107
+ return {
108
+ "angles_list": [
109
+ {
110
+ "axes": step.axes,
111
+ "angles": [step.angle],
112
+ "visible": step.visible,
113
+ "name": step.name,
114
+ "name_editable": step.name_editable,
115
+ "deletable": step.deletable,
116
+ }
117
+ for step in self.angles_list
118
+ ]
119
+ }
120
+
121
+ @classmethod
122
+ def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
123
+ """Create from UI format (assumes ITK/radians)."""
124
+ angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
125
+ 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)
159
+
160
+ @classmethod
161
+ def from_toml(cls, toml_content: str) -> "RotationSequence":
162
+ """Deserialize from TOML with conversions."""
163
+ 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)
195
+
196
+ @classmethod
197
+ def from_file(cls, path: pl.Path) -> "RotationSequence":
198
+ """Load from TOML file."""
199
+ with open(path, "r") as f:
200
+ return cls.from_toml(f.read())
201
+
202
+ def to_file(
203
+ self,
204
+ path: pl.Path,
205
+ target_convention: AxisConvention,
206
+ target_units: AngleUnits,
207
+ ):
208
+ """Save to TOML file."""
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ with open(path, "w") as f:
211
+ f.write(self.to_toml(target_convention, target_units))
cardio/scene.py CHANGED
@@ -1,19 +1,17 @@
1
- # System
2
1
  import logging
3
2
  import pathlib as pl
4
3
  import typing as ty
5
4
 
6
- # Third Party
7
5
  import numpy as np
8
6
  import pydantic as pc
9
7
  import pydantic_settings as ps
10
8
  import vtk
11
9
 
12
- # Internal
13
10
  from .mesh import Mesh
11
+ from .orientation import AngleUnits, AxisConvention
12
+ from .rotation import RotationSequence
14
13
  from .segmentation import Segmentation
15
14
  from .types import RGBColor
16
- from .utils import AngleUnit
17
15
  from .volume import Volume
18
16
 
19
17
  MeshListAdapter = pc.TypeAdapter(list[Mesh])
@@ -107,24 +105,16 @@ class Scene(ps.BaseSettings):
107
105
  volumes: VolumeList = pc.Field(default_factory=list)
108
106
  segmentations: SegmentationList = pc.Field(default_factory=list)
109
107
  mpr_enabled: bool = pc.Field(
110
- default=False,
108
+ default=True,
111
109
  description="Enable multi-planar reconstruction (MPR) mode with quad-view layout",
112
110
  )
113
111
  active_volume_label: str = pc.Field(
114
112
  default="",
115
113
  description="Label of the volume to use for multi-planar reconstruction",
116
114
  )
117
- axial_slice: float = pc.Field(
118
- default=0.0,
119
- description="Axial slice position in physical coordinates (LAS Z axis)",
120
- )
121
- sagittal_slice: float = pc.Field(
122
- default=0.0,
123
- description="Sagittal slice position in physical coordinates (LAS X axis)",
124
- )
125
- coronal_slice: float = pc.Field(
126
- default=0.0,
127
- description="Coronal slice position in physical coordinates (LAS Y axis)",
115
+ mpr_origin: list = pc.Field(
116
+ default_factory=lambda: [0.0, 0.0, 0.0],
117
+ description="MPR origin position [x, y, z] in LPS coordinates",
128
118
  )
129
119
  mpr_window: float = pc.Field(
130
120
  default=800.0, description="Window width for MPR image display"
@@ -135,20 +125,42 @@ class Scene(ps.BaseSettings):
135
125
  mpr_window_level_preset: int = pc.Field(
136
126
  default=7, description="Window/level preset key for MPR views"
137
127
  )
138
- mpr_rotation_sequence: list = pc.Field(
139
- default_factory=list,
140
- 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",
141
135
  )
142
136
  max_mpr_rotations: int = pc.Field(
143
137
  default=20,
144
138
  description="Maximum number of MPR rotations supported",
145
139
  )
146
- angle_units: AngleUnit = pc.Field(
147
- default=AngleUnit.DEGREES,
140
+ angle_units: AngleUnits = pc.Field(
141
+ default=AngleUnits.RADIANS,
148
142
  description="Units for angle measurements in rotation serialization",
149
143
  )
150
144
  coordinate_system: str = pc.Field(
151
- default="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
145
+ default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
146
+ )
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
+ mpr_crosshairs_enabled: bool = pc.Field(
152
+ default=True, description="Show crosshair lines indicating slice intersections"
153
+ )
154
+ mpr_crosshair_colors: dict = pc.Field(
155
+ default_factory=lambda: {
156
+ "axial": (0.0, 0.5, 1.0),
157
+ "sagittal": (1.0, 0.3, 0.3),
158
+ "coronal": (0.3, 1.0, 0.3),
159
+ },
160
+ description="RGB colors for crosshair lines (keyed by view name)",
161
+ )
162
+ mpr_crosshair_width: float = pc.Field(
163
+ default=1.5, description="Line width for crosshair lines"
152
164
  )
153
165
 
154
166
  # Field validators for JSON string inputs
@@ -173,6 +185,23 @@ class Scene(ps.BaseSettings):
173
185
  return SegmentationListAdapter.validate_json(v)
174
186
  return v
175
187
 
188
+ @pc.model_validator(mode="after")
189
+ def load_rotation_file(self):
190
+ """Load rotation sequence from TOML file if specified."""
191
+ if self.mpr_rotation_file is not None and self.mpr_rotation_file.exists():
192
+ self.mpr_rotation_sequence = RotationSequence.from_file(
193
+ self.mpr_rotation_file
194
+ )
195
+ if (
196
+ not self.active_volume_label
197
+ and self.mpr_rotation_sequence.metadata.volume_label
198
+ ):
199
+ self.active_volume_label = (
200
+ self.mpr_rotation_sequence.metadata.volume_label
201
+ )
202
+
203
+ return self
204
+
176
205
  # VTK objects as private attributes
177
206
  _renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
178
207
  _renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
cardio/segmentation.py CHANGED
@@ -1,17 +1,14 @@
1
- # System
2
1
  import logging
3
2
 
4
- # Third Party
5
3
  import itk
6
4
  import numpy as np
7
5
  import pydantic as pc
8
6
  import vtk
9
7
 
10
- # Internal
8
+ from .object import Object
11
9
  from .orientation import (
12
10
  reset_direction,
13
11
  )
14
- from .object import Object
15
12
  from .property_config import vtkPropertyConfig
16
13
 
17
14
 
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .color_transfer_function import ColorTransferFunctionConfig
7
5
  from .piecewise_function import PiecewiseFunctionConfig
8
6
 
cardio/types.py CHANGED
@@ -1,7 +1,5 @@
1
- # System
2
1
  import typing
3
2
 
4
- # Third Party
5
3
  import pydantic as pc
6
4
 
7
5
  ScalarComponent: typing.TypeAlias = typing.Annotated[