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/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=False)
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 "${frame}" not in v and "$frame" not in v:
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
- template = string.Template(self.pattern)
87
- filename = template.safe_substitute(frame=frame)
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 = 0
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 += 1
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
- screenshot_directory: pl.Path = pl.Path("./data/screenshots")
96
- screenshot_subdirectory_format: pl.Path = pl.Path("%Y-%m-%d-%H-%M-%S")
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
- @model_validator(mode="after")
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, InterpolatorType.NEAREST)
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