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/__init__.py +1 -1
- cardio/app.py +0 -2
- cardio/blend_transfer_functions.py +0 -3
- cardio/color_transfer_function.py +0 -2
- cardio/logic.py +254 -172
- cardio/mesh.py +0 -3
- cardio/object.py +0 -2
- cardio/orientation.py +6 -3
- cardio/piecewise_function.py +0 -2
- cardio/property_config.py +0 -3
- cardio/rotation.py +211 -0
- cardio/scene.py +51 -22
- cardio/segmentation.py +1 -4
- cardio/transfer_function_pair.py +0 -2
- cardio/types.py +0 -2
- cardio/ui.py +350 -173
- cardio/utils.py +0 -7
- cardio/volume.py +174 -44
- cardio/volume_property.py +0 -2
- cardio/volume_property_presets.py +0 -3
- cardio/window_level.py +0 -2
- {cardio-2025.10.1.dist-info → cardio-2026.1.0.dist-info}/METADATA +7 -8
- cardio-2026.1.0.dist-info/RECORD +30 -0
- cardio-2025.10.1.dist-info/RECORD +0 -29
- {cardio-2025.10.1.dist-info → cardio-2026.1.0.dist-info}/WHEEL +0 -0
- {cardio-2025.10.1.dist-info → cardio-2026.1.0.dist-info}/entry_points.txt +0 -0
cardio/mesh.py
CHANGED
cardio/object.py
CHANGED
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"
|
cardio/piecewise_function.py
CHANGED
cardio/property_config.py
CHANGED
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=
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
description="
|
|
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:
|
|
139
|
-
default_factory=
|
|
140
|
-
description="Dynamic rotation sequence for MPR views
|
|
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:
|
|
147
|
-
default=
|
|
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="
|
|
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
|
-
|
|
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
|
|
cardio/transfer_function_pair.py
CHANGED