cardio 2025.8.1__py3-none-any.whl → 2025.10.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 +9 -7
- cardio/app.py +6 -13
- cardio/blend_transfer_functions.py +84 -0
- cardio/color_transfer_function.py +29 -0
- cardio/logic.py +549 -6
- cardio/mesh.py +2 -3
- cardio/object.py +9 -13
- cardio/orientation.py +215 -0
- cardio/piecewise_function.py +29 -0
- cardio/scene.py +190 -28
- cardio/segmentation.py +4 -2
- cardio/transfer_function_pair.py +25 -0
- cardio/ui.py +501 -66
- cardio/utils.py +4 -47
- cardio/volume.py +227 -5
- cardio/volume_property.py +46 -0
- cardio/volume_property_presets.py +53 -0
- cardio/window_level.py +35 -0
- {cardio-2025.8.1.dist-info → cardio-2025.10.0.dist-info}/METADATA +5 -3
- cardio-2025.10.0.dist-info/RECORD +29 -0
- cardio/transfer_functions.py +0 -272
- cardio-2025.8.1.dist-info/RECORD +0 -22
- {cardio-2025.8.1.dist-info → cardio-2025.10.0.dist-info}/WHEEL +0 -0
- {cardio-2025.8.1.dist-info → cardio-2025.10.0.dist-info}/entry_points.txt +0 -0
cardio/object.py
CHANGED
|
@@ -3,7 +3,6 @@ import functools
|
|
|
3
3
|
import logging
|
|
4
4
|
import pathlib as pl
|
|
5
5
|
import re
|
|
6
|
-
import string
|
|
7
6
|
|
|
8
7
|
# Third Party
|
|
9
8
|
import pydantic as pc
|
|
@@ -22,13 +21,15 @@ class Object(pc.BaseModel):
|
|
|
22
21
|
pattern: str | None = pc.Field(
|
|
23
22
|
default=None, description="Filename pattern with ${frame} placeholder"
|
|
24
23
|
)
|
|
24
|
+
frame_start: pc.NonNegativeInt = 0
|
|
25
|
+
frame_interval: pc.PositiveInt = 1
|
|
25
26
|
file_paths: list[str] | None = pc.Field(
|
|
26
27
|
default=None, description="Static list of file paths relative to directory"
|
|
27
28
|
)
|
|
28
29
|
visible: bool = pc.Field(
|
|
29
30
|
default=True, description="Whether object is initially visible"
|
|
30
31
|
)
|
|
31
|
-
clipping_enabled: bool = pc.Field(default=
|
|
32
|
+
clipping_enabled: bool = pc.Field(default=True)
|
|
32
33
|
|
|
33
34
|
@pc.field_validator("label")
|
|
34
35
|
@classmethod
|
|
@@ -54,10 +55,10 @@ class Object(pc.BaseModel):
|
|
|
54
55
|
if not isinstance(v, str):
|
|
55
56
|
raise ValueError("pattern must be a string")
|
|
56
57
|
|
|
57
|
-
if not re.match(r"^[a-zA-Z0-9_\-.${}]+$", v):
|
|
58
|
+
if not re.match(r"^[a-zA-Z0-9_\-.${}:]+$", v):
|
|
58
59
|
raise ValueError("Pattern contains unsafe characters")
|
|
59
60
|
|
|
60
|
-
if "
|
|
61
|
+
if "frame" not in v:
|
|
61
62
|
raise ValueError("Pattern must contain $frame placeholder")
|
|
62
63
|
|
|
63
64
|
return v
|
|
@@ -69,12 +70,7 @@ class Object(pc.BaseModel):
|
|
|
69
70
|
if self.pattern is not None and self.file_paths is not None:
|
|
70
71
|
logging.info("Both pattern and file_paths specified; using file_paths.")
|
|
71
72
|
|
|
72
|
-
# Validate all paths for traversal attacks and file existence
|
|
73
73
|
for path in self.path_list:
|
|
74
|
-
if not path.resolve().is_relative_to(self.directory.resolve()):
|
|
75
|
-
raise ValueError(
|
|
76
|
-
f"Path {path} would access files outside base directory"
|
|
77
|
-
)
|
|
78
74
|
if not path.is_file():
|
|
79
75
|
raise ValueError(f"File does not exist: {path}")
|
|
80
76
|
|
|
@@ -83,8 +79,8 @@ class Object(pc.BaseModel):
|
|
|
83
79
|
def path_for_frame(self, frame: int) -> pl.Path:
|
|
84
80
|
if self.pattern is None:
|
|
85
81
|
raise ValueError("Cannot use path_for_frame with static file_paths")
|
|
86
|
-
|
|
87
|
-
filename
|
|
82
|
+
filename = self.pattern.format(frame=frame)
|
|
83
|
+
print(filename)
|
|
88
84
|
return self.directory / filename
|
|
89
85
|
|
|
90
86
|
@functools.cached_property
|
|
@@ -94,13 +90,13 @@ class Object(pc.BaseModel):
|
|
|
94
90
|
return [self.directory / path for path in self.file_paths]
|
|
95
91
|
|
|
96
92
|
paths = []
|
|
97
|
-
frame =
|
|
93
|
+
frame = self.frame_start
|
|
98
94
|
while True:
|
|
99
95
|
path = self.path_for_frame(frame)
|
|
100
96
|
if not path.is_file():
|
|
101
97
|
break
|
|
102
98
|
paths.append(path)
|
|
103
|
-
frame +=
|
|
99
|
+
frame += self.frame_interval
|
|
104
100
|
return paths
|
|
105
101
|
|
|
106
102
|
@property
|
cardio/orientation.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# System
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
# Third Party
|
|
5
|
+
import numpy as np
|
|
6
|
+
import itk
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# DICOM LPS canonical orientation vector mappings
|
|
10
|
+
class EulerAxis(Enum):
|
|
11
|
+
X = "X"
|
|
12
|
+
Y = "Y"
|
|
13
|
+
Z = "Z"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AngleUnits(Enum):
|
|
17
|
+
DEGREES = "degrees"
|
|
18
|
+
RADIANS = "radians"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
AXCODE_VECTORS = {
|
|
22
|
+
"L": (1, 0, 0),
|
|
23
|
+
"R": (-1, 0, 0),
|
|
24
|
+
"P": (0, 1, 0),
|
|
25
|
+
"A": (0, -1, 0),
|
|
26
|
+
"S": (0, 0, 1),
|
|
27
|
+
"I": (0, 0, -1),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_valid_axcode(axcode: str) -> bool:
|
|
32
|
+
"""Validate medical imaging axcode string.
|
|
33
|
+
|
|
34
|
+
Valid axcode must have exactly 3 uppercase characters with:
|
|
35
|
+
- One of L or R (Left/Right)
|
|
36
|
+
- One of A or P (Anterior/Posterior)
|
|
37
|
+
- One of S or I (Superior/Inferior)
|
|
38
|
+
- No repeated characters
|
|
39
|
+
"""
|
|
40
|
+
if len(axcode) != 3:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
if len(set(axcode)) != 3:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
has_lr = any(c in axcode for c in "LR")
|
|
47
|
+
has_ap = any(c in axcode for c in "AP")
|
|
48
|
+
has_si = any(c in axcode for c in "SI")
|
|
49
|
+
|
|
50
|
+
valid_chars = set("LRAPSI")
|
|
51
|
+
has_only_valid = all(c in valid_chars for c in axcode)
|
|
52
|
+
|
|
53
|
+
return has_lr and has_ap and has_si and has_only_valid
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_righthanded_axcode(axcode: str) -> bool:
|
|
57
|
+
"""Check if axcode represents a right-handed coordinate system.
|
|
58
|
+
|
|
59
|
+
Right-handed when cross product of first two axes equals third axis.
|
|
60
|
+
Uses DICOM LPS canonical orientation.
|
|
61
|
+
"""
|
|
62
|
+
if not is_valid_axcode(axcode):
|
|
63
|
+
raise ValueError(f"Invalid axcode: {axcode}")
|
|
64
|
+
|
|
65
|
+
v1 = np.array(AXCODE_VECTORS[axcode[0]])
|
|
66
|
+
v2 = np.array(AXCODE_VECTORS[axcode[1]])
|
|
67
|
+
v3 = np.array(AXCODE_VECTORS[axcode[2]])
|
|
68
|
+
|
|
69
|
+
cross = np.cross(v1, v2)
|
|
70
|
+
|
|
71
|
+
return np.array_equal(cross, v3)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def axcode_transform_matrix(from_axcode: str, to_axcode: str) -> np.ndarray:
|
|
75
|
+
"""Calculate transformation matrix between two coordinate spaces.
|
|
76
|
+
|
|
77
|
+
Returns matrix T such that: new_coords = T @ old_coords
|
|
78
|
+
Uses DICOM LPS canonical orientation for vector mappings.
|
|
79
|
+
"""
|
|
80
|
+
if not is_valid_axcode(from_axcode):
|
|
81
|
+
raise ValueError(f"Invalid source axcode: {from_axcode}")
|
|
82
|
+
if not is_valid_axcode(to_axcode):
|
|
83
|
+
raise ValueError(f"Invalid target axcode: {to_axcode}")
|
|
84
|
+
|
|
85
|
+
# Create basis matrices (each column is a basis vector)
|
|
86
|
+
from_basis = np.array([AXCODE_VECTORS[c] for c in from_axcode]).T
|
|
87
|
+
to_basis = np.array([AXCODE_VECTORS[c] for c in to_axcode]).T
|
|
88
|
+
|
|
89
|
+
# Transformation matrix: T = to_basis @ from_basis^(-1)
|
|
90
|
+
return to_basis @ np.linalg.inv(from_basis)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def euler_angle_to_rotation_matrix(
|
|
94
|
+
axis: EulerAxis, angle: float, units: AngleUnits = AngleUnits.DEGREES
|
|
95
|
+
) -> np.ndarray:
|
|
96
|
+
"""Create rotation matrix for given axis and angle.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
axis: Rotation axis (X, Y, or Z)
|
|
100
|
+
angle: Rotation angle
|
|
101
|
+
units: Angle units (degrees or radians)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
3x3 rotation matrix
|
|
105
|
+
"""
|
|
106
|
+
match units:
|
|
107
|
+
case AngleUnits.DEGREES:
|
|
108
|
+
angle_rad = np.radians(angle)
|
|
109
|
+
case AngleUnits.RADIANS:
|
|
110
|
+
angle_rad = angle
|
|
111
|
+
|
|
112
|
+
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
|
|
113
|
+
|
|
114
|
+
match axis:
|
|
115
|
+
case EulerAxis.X:
|
|
116
|
+
return np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]])
|
|
117
|
+
case EulerAxis.Y:
|
|
118
|
+
return np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]])
|
|
119
|
+
case EulerAxis.Z:
|
|
120
|
+
return np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_axis_aligned(image) -> bool:
|
|
124
|
+
"""Check if ITK image orientation is axis-aligned.
|
|
125
|
+
|
|
126
|
+
An axis-aligned image has a direction matrix where:
|
|
127
|
+
- Each column has exactly one non-zero entry
|
|
128
|
+
- Non-zero entries are ±1
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
image: ITK image object
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if image is axis-aligned, False otherwise
|
|
135
|
+
"""
|
|
136
|
+
direction = itk.array_from_matrix(image.GetDirection())
|
|
137
|
+
|
|
138
|
+
# Check each column has exactly one non-zero entry
|
|
139
|
+
for col in range(direction.shape[1]):
|
|
140
|
+
non_zero_count = np.count_nonzero(direction[:, col])
|
|
141
|
+
if non_zero_count != 1:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# Check non-zero entries are ±1
|
|
145
|
+
non_zero_values = direction[direction != 0]
|
|
146
|
+
if not np.allclose(np.abs(non_zero_values), 1.0):
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def reset_direction(image):
|
|
153
|
+
"""Reset image direction to identity matrix, preserving physical extent."""
|
|
154
|
+
assert is_axis_aligned(image), "Input image must be axis-aligned"
|
|
155
|
+
|
|
156
|
+
origin = np.array(image.GetOrigin())
|
|
157
|
+
spacing = np.array(image.GetSpacing())
|
|
158
|
+
direction = itk.array_from_matrix(image.GetDirection())
|
|
159
|
+
size = np.array(image.GetLargestPossibleRegion().GetSize())
|
|
160
|
+
pixel_array = itk.array_from_image(image)
|
|
161
|
+
|
|
162
|
+
permutation = []
|
|
163
|
+
flips = []
|
|
164
|
+
|
|
165
|
+
for col in range(3):
|
|
166
|
+
row = np.nonzero(direction[:, col])[0][0]
|
|
167
|
+
permutation.append(row)
|
|
168
|
+
flips.append(direction[row, col] < 0)
|
|
169
|
+
|
|
170
|
+
array_permutation = [2 - p for p in reversed(permutation)]
|
|
171
|
+
pixel_array = np.transpose(pixel_array, array_permutation)
|
|
172
|
+
|
|
173
|
+
for i, should_flip in enumerate(reversed(flips)):
|
|
174
|
+
if should_flip:
|
|
175
|
+
pixel_array = np.flip(pixel_array, axis=i)
|
|
176
|
+
|
|
177
|
+
new_spacing = spacing[permutation]
|
|
178
|
+
|
|
179
|
+
adjusted_origin = origin.copy()
|
|
180
|
+
for i, should_flip in enumerate(flips):
|
|
181
|
+
if should_flip:
|
|
182
|
+
image_axis = permutation[i]
|
|
183
|
+
extent_vector = (
|
|
184
|
+
direction[:, image_axis] * (size[image_axis] - 1) * spacing[image_axis]
|
|
185
|
+
)
|
|
186
|
+
adjusted_origin += extent_vector
|
|
187
|
+
|
|
188
|
+
new_origin = adjusted_origin[permutation]
|
|
189
|
+
|
|
190
|
+
output = itk.image_from_array(pixel_array)
|
|
191
|
+
output.SetOrigin(new_origin)
|
|
192
|
+
output.SetSpacing(new_spacing)
|
|
193
|
+
|
|
194
|
+
return output
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def create_vtk_reslice_matrix(transform_3x3, origin):
|
|
198
|
+
"""Create 4x4 VTK reslice matrix from 3x3 transform and origin.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
transform_3x3: 3x3 coordinate transformation matrix
|
|
202
|
+
origin: 3-element origin position
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
vtk.vtkMatrix4x4 for use with VTK reslice operations
|
|
206
|
+
"""
|
|
207
|
+
import vtk
|
|
208
|
+
|
|
209
|
+
matrix = vtk.vtkMatrix4x4()
|
|
210
|
+
for i in range(3):
|
|
211
|
+
for j in range(3):
|
|
212
|
+
matrix.SetElement(i, j, transform_3x3[i, j])
|
|
213
|
+
matrix.SetElement(i, 3, origin[i])
|
|
214
|
+
matrix.SetElement(3, 3, 1.0)
|
|
215
|
+
return matrix
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Third Party
|
|
2
|
+
import pydantic as pc
|
|
3
|
+
import vtk
|
|
4
|
+
|
|
5
|
+
# Internal
|
|
6
|
+
from .types import ScalarComponent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PiecewiseFunctionPoint(pc.BaseModel):
|
|
10
|
+
"""A single point in a piecewise function."""
|
|
11
|
+
|
|
12
|
+
x: float = pc.Field(description="Scalar value")
|
|
13
|
+
y: ScalarComponent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PiecewiseFunctionConfig(pc.BaseModel):
|
|
17
|
+
"""Configuration for a VTK piecewise function (opacity)."""
|
|
18
|
+
|
|
19
|
+
points: list[PiecewiseFunctionPoint] = pc.Field(
|
|
20
|
+
min_length=1, description="Points defining the piecewise function"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def vtk_function(self) -> vtk.vtkPiecewiseFunction:
|
|
25
|
+
"""Create VTK piecewise function from this configuration."""
|
|
26
|
+
otf = vtk.vtkPiecewiseFunction()
|
|
27
|
+
for point in self.points:
|
|
28
|
+
otf.AddPoint(point.x, point.y)
|
|
29
|
+
return otf
|
cardio/scene.py
CHANGED
|
@@ -1,54 +1,52 @@
|
|
|
1
1
|
# System
|
|
2
2
|
import logging
|
|
3
3
|
import pathlib as pl
|
|
4
|
-
|
|
5
|
-
# Type adapters for list types to improve CLI integration
|
|
6
|
-
from typing import Annotated
|
|
4
|
+
import typing as ty
|
|
7
5
|
|
|
8
6
|
# Third Party
|
|
9
7
|
import numpy as np
|
|
10
8
|
import pydantic as pc
|
|
11
9
|
import pydantic_settings as ps
|
|
12
10
|
import vtk
|
|
13
|
-
from pydantic import Field, PrivateAttr, TypeAdapter, field_validator, model_validator
|
|
14
11
|
|
|
15
12
|
# Internal
|
|
16
13
|
from .mesh import Mesh
|
|
17
14
|
from .segmentation import Segmentation
|
|
18
15
|
from .types import RGBColor
|
|
16
|
+
from .utils import AngleUnit
|
|
19
17
|
from .volume import Volume
|
|
20
18
|
|
|
21
|
-
MeshListAdapter = TypeAdapter(list[Mesh])
|
|
22
|
-
VolumeListAdapter = TypeAdapter(list[Volume])
|
|
23
|
-
SegmentationListAdapter = TypeAdapter(list[Segmentation])
|
|
19
|
+
MeshListAdapter = pc.TypeAdapter(list[Mesh])
|
|
20
|
+
VolumeListAdapter = pc.TypeAdapter(list[Volume])
|
|
21
|
+
SegmentationListAdapter = pc.TypeAdapter(list[Segmentation])
|
|
24
22
|
|
|
25
23
|
# Create annotated types for better CLI integration
|
|
26
|
-
MeshList = Annotated[
|
|
24
|
+
MeshList = ty.Annotated[
|
|
27
25
|
list[Mesh],
|
|
28
|
-
Field(
|
|
26
|
+
pc.Field(
|
|
29
27
|
description='List of mesh objects. CLI usage: --meshes \'[{"label":"mesh1","directory":"./data/mesh1"}]\''
|
|
30
28
|
),
|
|
31
29
|
]
|
|
32
|
-
VolumeList = Annotated[
|
|
30
|
+
VolumeList = ty.Annotated[
|
|
33
31
|
list[Volume],
|
|
34
|
-
Field(
|
|
32
|
+
pc.Field(
|
|
35
33
|
description='Volume objects. CLI: --volumes \'[{"label":"vol1","directory":"./data/vol1"}]\''
|
|
36
34
|
),
|
|
37
35
|
]
|
|
38
|
-
SegmentationList = Annotated[
|
|
36
|
+
SegmentationList = ty.Annotated[
|
|
39
37
|
list[Segmentation],
|
|
40
|
-
Field(
|
|
38
|
+
pc.Field(
|
|
41
39
|
description='Segmentation objects. CLI: --segmentations \'[{"label":"seg1","directory":"./data/seg1"}]\''
|
|
42
40
|
),
|
|
43
41
|
]
|
|
44
42
|
|
|
45
43
|
|
|
46
44
|
class Background(pc.BaseModel):
|
|
47
|
-
light: RGBColor = Field(
|
|
45
|
+
light: RGBColor = pc.Field(
|
|
48
46
|
default=(1.0, 1.0, 1.0),
|
|
49
47
|
description="Background color in light mode. CLI usage: --background.light '[0.8,0.9,1.0]'",
|
|
50
48
|
)
|
|
51
|
-
dark: RGBColor = Field(
|
|
49
|
+
dark: RGBColor = pc.Field(
|
|
52
50
|
default=(0.0, 0.0, 0.0),
|
|
53
51
|
description="Background color in dark mode. CLI usage: --background.dark '[0.1,0.1,0.2]'",
|
|
54
52
|
)
|
|
@@ -92,33 +90,83 @@ class Scene(ps.BaseSettings):
|
|
|
92
90
|
|
|
93
91
|
project_name: str = "Cardio"
|
|
94
92
|
current_frame: int = 0
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
serialization_directory: pl.Path = pc.Field(
|
|
94
|
+
default=pl.Path("./data"),
|
|
95
|
+
description="Base directory for all serialized data (screenshots, exports, etc.)",
|
|
96
|
+
)
|
|
97
|
+
timestamp_format: str = pc.Field(
|
|
98
|
+
default="%Y-%m-%d-%H-%M-%S",
|
|
99
|
+
description="Timestamp format for serialized data subdirectories",
|
|
100
|
+
)
|
|
97
101
|
rotation_factor: float = 3.0
|
|
98
|
-
background: Background = Field(
|
|
102
|
+
background: Background = pc.Field(
|
|
99
103
|
default_factory=Background,
|
|
100
104
|
description='Background colors. CLI usage: \'{"light": [0.8, 0.9, 1.0], "dark": [0.1, 0.1, 0.2]}\'',
|
|
101
105
|
)
|
|
102
|
-
meshes: MeshList = Field(default_factory=list)
|
|
103
|
-
volumes: VolumeList = Field(default_factory=list)
|
|
104
|
-
segmentations: SegmentationList = Field(default_factory=list)
|
|
106
|
+
meshes: MeshList = pc.Field(default_factory=list)
|
|
107
|
+
volumes: VolumeList = pc.Field(default_factory=list)
|
|
108
|
+
segmentations: SegmentationList = pc.Field(default_factory=list)
|
|
109
|
+
mpr_enabled: bool = pc.Field(
|
|
110
|
+
default=False,
|
|
111
|
+
description="Enable multi-planar reconstruction (MPR) mode with quad-view layout",
|
|
112
|
+
)
|
|
113
|
+
active_volume_label: str = pc.Field(
|
|
114
|
+
default="",
|
|
115
|
+
description="Label of the volume to use for multi-planar reconstruction",
|
|
116
|
+
)
|
|
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)",
|
|
128
|
+
)
|
|
129
|
+
mpr_window: float = pc.Field(
|
|
130
|
+
default=800.0, description="Window width for MPR image display"
|
|
131
|
+
)
|
|
132
|
+
mpr_level: float = pc.Field(
|
|
133
|
+
default=200.0, description="Window level for MPR image display"
|
|
134
|
+
)
|
|
135
|
+
mpr_window_level_preset: int = pc.Field(
|
|
136
|
+
default=7, description="Window/level preset key for MPR views"
|
|
137
|
+
)
|
|
138
|
+
mpr_rotation_sequence: list = pc.Field(
|
|
139
|
+
default_factory=list,
|
|
140
|
+
description="Dynamic rotation sequence for MPR views - list of rotation steps",
|
|
141
|
+
)
|
|
142
|
+
max_mpr_rotations: int = pc.Field(
|
|
143
|
+
default=20,
|
|
144
|
+
description="Maximum number of MPR rotations supported",
|
|
145
|
+
)
|
|
146
|
+
angle_units: AngleUnit = pc.Field(
|
|
147
|
+
default=AngleUnit.DEGREES,
|
|
148
|
+
description="Units for angle measurements in rotation serialization",
|
|
149
|
+
)
|
|
150
|
+
coordinate_system: str = pc.Field(
|
|
151
|
+
default="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
|
|
152
|
+
)
|
|
105
153
|
|
|
106
154
|
# Field validators for JSON string inputs
|
|
107
|
-
@field_validator("meshes", mode="before")
|
|
155
|
+
@pc.field_validator("meshes", mode="before")
|
|
108
156
|
@classmethod
|
|
109
157
|
def validate_meshes(cls, v):
|
|
110
158
|
if isinstance(v, str):
|
|
111
159
|
return MeshListAdapter.validate_json(v)
|
|
112
160
|
return v
|
|
113
161
|
|
|
114
|
-
@field_validator("volumes", mode="before")
|
|
162
|
+
@pc.field_validator("volumes", mode="before")
|
|
115
163
|
@classmethod
|
|
116
164
|
def validate_volumes(cls, v):
|
|
117
165
|
if isinstance(v, str):
|
|
118
166
|
return VolumeListAdapter.validate_json(v)
|
|
119
167
|
return v
|
|
120
168
|
|
|
121
|
-
@field_validator("segmentations", mode="before")
|
|
169
|
+
@pc.field_validator("segmentations", mode="before")
|
|
122
170
|
@classmethod
|
|
123
171
|
def validate_segmentations(cls, v):
|
|
124
172
|
if isinstance(v, str):
|
|
@@ -126,14 +174,19 @@ class Scene(ps.BaseSettings):
|
|
|
126
174
|
return v
|
|
127
175
|
|
|
128
176
|
# VTK objects as private attributes
|
|
129
|
-
_renderer: vtk.vtkRenderer = PrivateAttr(default_factory=vtk.vtkRenderer)
|
|
130
|
-
_renderWindow: vtk.vtkRenderWindow = PrivateAttr(
|
|
177
|
+
_renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
|
|
178
|
+
_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
|
|
131
179
|
default_factory=vtk.vtkRenderWindow
|
|
132
180
|
)
|
|
133
|
-
_renderWindowInteractor: vtk.vtkRenderWindowInteractor = PrivateAttr(
|
|
181
|
+
_renderWindowInteractor: vtk.vtkRenderWindowInteractor = pc.PrivateAttr(
|
|
134
182
|
default_factory=vtk.vtkRenderWindowInteractor
|
|
135
183
|
)
|
|
136
184
|
|
|
185
|
+
# MPR render windows
|
|
186
|
+
_axial_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
|
|
187
|
+
_coronal_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
|
|
188
|
+
_sagittal_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(default=None)
|
|
189
|
+
|
|
137
190
|
@property
|
|
138
191
|
def renderer(self) -> vtk.vtkRenderer:
|
|
139
192
|
return self._renderer
|
|
@@ -146,8 +199,26 @@ class Scene(ps.BaseSettings):
|
|
|
146
199
|
def renderWindowInteractor(self) -> vtk.vtkRenderWindowInteractor:
|
|
147
200
|
return self._renderWindowInteractor
|
|
148
201
|
|
|
149
|
-
@
|
|
202
|
+
@property
|
|
203
|
+
def axial_renderWindow(self) -> vtk.vtkRenderWindow:
|
|
204
|
+
return self._axial_renderWindow
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def coronal_renderWindow(self) -> vtk.vtkRenderWindow:
|
|
208
|
+
return self._coronal_renderWindow
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def sagittal_renderWindow(self) -> vtk.vtkRenderWindow:
|
|
212
|
+
return self._sagittal_renderWindow
|
|
213
|
+
|
|
214
|
+
@pc.model_validator(mode="after")
|
|
150
215
|
def setup_scene(self):
|
|
216
|
+
# Validate unique labels
|
|
217
|
+
self._validate_unique_labels()
|
|
218
|
+
|
|
219
|
+
# Validate active volume label
|
|
220
|
+
self._validate_active_volume_label()
|
|
221
|
+
|
|
151
222
|
# Configure VTK objects
|
|
152
223
|
self._renderer.SetBackground(
|
|
153
224
|
*self.background.light,
|
|
@@ -173,6 +244,54 @@ class Scene(ps.BaseSettings):
|
|
|
173
244
|
|
|
174
245
|
return self
|
|
175
246
|
|
|
247
|
+
def _validate_unique_labels(self):
|
|
248
|
+
mesh_labels = [mesh.label for mesh in self.meshes]
|
|
249
|
+
volume_labels = [volume.label for volume in self.volumes]
|
|
250
|
+
segmentation_labels = [seg.label for seg in self.segmentations]
|
|
251
|
+
|
|
252
|
+
if len(mesh_labels) != len(set(mesh_labels)):
|
|
253
|
+
duplicates = [
|
|
254
|
+
label for label in set(mesh_labels) if mesh_labels.count(label) > 1
|
|
255
|
+
]
|
|
256
|
+
raise ValueError(f"Duplicate mesh labels found: {duplicates}")
|
|
257
|
+
|
|
258
|
+
if len(volume_labels) != len(set(volume_labels)):
|
|
259
|
+
duplicates = [
|
|
260
|
+
label for label in set(volume_labels) if volume_labels.count(label) > 1
|
|
261
|
+
]
|
|
262
|
+
raise ValueError(f"Duplicate volume labels found: {duplicates}")
|
|
263
|
+
|
|
264
|
+
if len(segmentation_labels) != len(set(segmentation_labels)):
|
|
265
|
+
duplicates = [
|
|
266
|
+
label
|
|
267
|
+
for label in set(segmentation_labels)
|
|
268
|
+
if segmentation_labels.count(label) > 1
|
|
269
|
+
]
|
|
270
|
+
raise ValueError(f"Duplicate segmentation labels found: {duplicates}")
|
|
271
|
+
|
|
272
|
+
def _validate_active_volume_label(self):
|
|
273
|
+
"""Validate that active_volume_label refers to an existing volume."""
|
|
274
|
+
if self.active_volume_label and self.volumes:
|
|
275
|
+
volume_labels = [volume.label for volume in self.volumes]
|
|
276
|
+
if self.active_volume_label not in volume_labels:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"Active volume label '{self.active_volume_label}' not found in available volumes: {volume_labels}"
|
|
279
|
+
)
|
|
280
|
+
elif self.active_volume_label and not self.volumes:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"Active volume label specified but no volumes are available"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def screenshot_directory(self) -> pl.Path:
|
|
287
|
+
"""Computed property that returns the screenshots subdirectory."""
|
|
288
|
+
return self.serialization_directory / "screenshots"
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def rotations_directory(self) -> pl.Path:
|
|
292
|
+
"""Computed property that returns the rotations subdirectory."""
|
|
293
|
+
return self.serialization_directory / "rotations"
|
|
294
|
+
|
|
176
295
|
@property
|
|
177
296
|
def nframes(self) -> int:
|
|
178
297
|
ns = []
|
|
@@ -209,6 +328,49 @@ class Scene(ps.BaseSettings):
|
|
|
209
328
|
self.show_frame(self.current_frame)
|
|
210
329
|
self.renderer.ResetCamera()
|
|
211
330
|
|
|
331
|
+
# Set default camera elevation to -90 degrees
|
|
332
|
+
camera = self.renderer.GetActiveCamera()
|
|
333
|
+
camera.Elevation(-90)
|
|
334
|
+
|
|
335
|
+
def setup_mpr_render_windows(self):
|
|
336
|
+
"""Initialize MPR render windows when MPR mode is enabled."""
|
|
337
|
+
if self._axial_renderWindow is None:
|
|
338
|
+
# Create axial render window
|
|
339
|
+
self._axial_renderWindow = vtk.vtkRenderWindow()
|
|
340
|
+
axial_renderer = vtk.vtkRenderer()
|
|
341
|
+
axial_renderer.SetBackground(0.0, 0.0, 0.0) # Black background
|
|
342
|
+
self._axial_renderWindow.AddRenderer(axial_renderer)
|
|
343
|
+
self._axial_renderWindow.SetOffScreenRendering(True)
|
|
344
|
+
|
|
345
|
+
# Create and set interactor for axial
|
|
346
|
+
axial_interactor = vtk.vtkRenderWindowInteractor()
|
|
347
|
+
axial_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
|
|
348
|
+
self._axial_renderWindow.SetInteractor(axial_interactor)
|
|
349
|
+
|
|
350
|
+
# Create coronal render window
|
|
351
|
+
self._coronal_renderWindow = vtk.vtkRenderWindow()
|
|
352
|
+
coronal_renderer = vtk.vtkRenderer()
|
|
353
|
+
coronal_renderer.SetBackground(0.0, 0.0, 0.0) # Black background
|
|
354
|
+
self._coronal_renderWindow.AddRenderer(coronal_renderer)
|
|
355
|
+
self._coronal_renderWindow.SetOffScreenRendering(True)
|
|
356
|
+
|
|
357
|
+
# Create and set interactor for coronal
|
|
358
|
+
coronal_interactor = vtk.vtkRenderWindowInteractor()
|
|
359
|
+
coronal_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
|
|
360
|
+
self._coronal_renderWindow.SetInteractor(coronal_interactor)
|
|
361
|
+
|
|
362
|
+
# Create sagittal render window
|
|
363
|
+
self._sagittal_renderWindow = vtk.vtkRenderWindow()
|
|
364
|
+
sagittal_renderer = vtk.vtkRenderer()
|
|
365
|
+
sagittal_renderer.SetBackground(0.0, 0.0, 0.0) # Black background
|
|
366
|
+
self._sagittal_renderWindow.AddRenderer(sagittal_renderer)
|
|
367
|
+
self._sagittal_renderWindow.SetOffScreenRendering(True)
|
|
368
|
+
|
|
369
|
+
# Create and set interactor for sagittal
|
|
370
|
+
sagittal_interactor = vtk.vtkRenderWindowInteractor()
|
|
371
|
+
sagittal_interactor.SetInteractorStyle(vtk.vtkInteractorStyle())
|
|
372
|
+
self._sagittal_renderWindow.SetInteractor(sagittal_interactor)
|
|
373
|
+
|
|
212
374
|
def hide_all_frames(self):
|
|
213
375
|
for a in self.renderer.GetActors():
|
|
214
376
|
a.SetVisibility(False)
|
cardio/segmentation.py
CHANGED
|
@@ -8,9 +8,11 @@ import pydantic as pc
|
|
|
8
8
|
import vtk
|
|
9
9
|
|
|
10
10
|
# Internal
|
|
11
|
+
from .orientation import (
|
|
12
|
+
reset_direction,
|
|
13
|
+
)
|
|
11
14
|
from .object import Object
|
|
12
15
|
from .property_config import vtkPropertyConfig
|
|
13
|
-
from .utils import InterpolatorType, reset_direction
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class Segmentation(Object):
|
|
@@ -35,7 +37,7 @@ class Segmentation(Object):
|
|
|
35
37
|
|
|
36
38
|
# Read and process segmentation image
|
|
37
39
|
image = itk.imread(path)
|
|
38
|
-
image = reset_direction(image
|
|
40
|
+
image = reset_direction(image)
|
|
39
41
|
vtk_image = itk.vtk_image_from_image(image)
|
|
40
42
|
|
|
41
43
|
# Create SurfaceNets3D filter
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Third Party
|
|
2
|
+
import pydantic as pc
|
|
3
|
+
import vtk
|
|
4
|
+
|
|
5
|
+
# Internal
|
|
6
|
+
from .color_transfer_function import ColorTransferFunctionConfig
|
|
7
|
+
from .piecewise_function import PiecewiseFunctionConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransferFunctionPairConfig(pc.BaseModel):
|
|
11
|
+
"""Configuration for a pair of opacity and color transfer functions."""
|
|
12
|
+
|
|
13
|
+
opacity: PiecewiseFunctionConfig = pc.Field(
|
|
14
|
+
description="Opacity transfer function configuration"
|
|
15
|
+
)
|
|
16
|
+
color: ColorTransferFunctionConfig = pc.Field(
|
|
17
|
+
description="Color transfer function configuration"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def vtk_functions(
|
|
22
|
+
self,
|
|
23
|
+
) -> tuple[vtk.vtkPiecewiseFunction, vtk.vtkColorTransferFunction]:
|
|
24
|
+
"""Create VTK transfer functions from this pair configuration."""
|
|
25
|
+
return self.opacity.vtk_function, self.color.vtk_function
|