cardio 2025.8.0__py3-none-any.whl → 2025.9.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/volume.py CHANGED
@@ -6,8 +6,38 @@ import pydantic as pc
6
6
  import vtk
7
7
 
8
8
  from .object import Object
9
- from .transfer_functions import load_preset
10
9
  from .utils import InterpolatorType, reset_direction
10
+ from .volume_property_presets import load_volume_property_preset
11
+
12
+
13
+ def create_rotation_matrix(axis, angle_degrees):
14
+ """Create rotation matrix for given axis and angle."""
15
+ angle = np.radians(angle_degrees)
16
+ cos_a, sin_a = np.cos(angle), np.sin(angle)
17
+ if axis == "X":
18
+ return np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]])
19
+ elif axis == "Y":
20
+ return np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]])
21
+ elif axis == "Z":
22
+ return np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]])
23
+ return np.eye(3)
24
+
25
+
26
+ def create_reslice_matrix(normal, up, origin):
27
+ """Create a 4x4 reslice matrix from normal vector, up vector, and origin"""
28
+ normal = normal / np.linalg.norm(normal)
29
+ up = up / np.linalg.norm(up)
30
+ right = np.cross(normal, up)
31
+ right = right / np.linalg.norm(right)
32
+ up = np.cross(right, normal)
33
+ matrix = vtk.vtkMatrix4x4()
34
+ for i in range(3):
35
+ matrix.SetElement(i, 0, right[i])
36
+ matrix.SetElement(i, 1, up[i])
37
+ matrix.SetElement(i, 2, normal[i])
38
+ matrix.SetElement(i, 3, origin[i])
39
+ matrix.SetElement(3, 3, 1.0)
40
+ return matrix
11
41
 
12
42
 
13
43
  class Volume(Object):
@@ -17,8 +47,13 @@ class Volume(Object):
17
47
  default="${frame}.nii.gz",
18
48
  description="Filename pattern with $frame placeholder",
19
49
  )
20
- transfer_function_preset: str = pc.Field(description="Transfer function preset key")
50
+ transfer_function_preset: str = pc.Field(
51
+ default="bone", description="Transfer function preset key"
52
+ )
21
53
  _actors: list[vtk.vtkVolume] = pc.PrivateAttr(default_factory=list)
54
+ _mpr_actors: dict[str, list[vtk.vtkImageActor]] = pc.PrivateAttr(
55
+ default_factory=dict
56
+ )
22
57
 
23
58
  @pc.model_validator(mode="after")
24
59
  def initialize_volume(self):
@@ -47,7 +82,7 @@ class Volume(Object):
47
82
  @property
48
83
  def preset(self):
49
84
  """Load preset based on transfer_function_preset."""
50
- return load_preset(self.transfer_function_preset)
85
+ return load_volume_property_preset(self.transfer_function_preset)
51
86
 
52
87
  def configure_actors(self):
53
88
  """Configure volume properties without adding to renderer."""
@@ -59,3 +94,181 @@ class Volume(Object):
59
94
  """Apply the current preset to all actors."""
60
95
  for actor in self._actors:
61
96
  actor.SetProperty(self.preset.vtk_property)
97
+
98
+ def create_mpr_actors(self, frame: int = 0):
99
+ """Create MPR (reslice) actors for axial, sagittal, and coronal views."""
100
+ if frame >= len(self._actors):
101
+ frame = 0
102
+
103
+ # Get the image data from the volume actor
104
+ volume_actor = self._actors[frame]
105
+ image_data = volume_actor.GetMapper().GetInput()
106
+
107
+ # Create reslice actors for each orientation
108
+ mpr_actors = {}
109
+
110
+ for orientation in ["axial", "sagittal", "coronal"]:
111
+ # Create reslice filter
112
+ reslice = vtk.vtkImageReslice()
113
+ reslice.SetInputData(image_data)
114
+ reslice.SetOutputDimensionality(2)
115
+ reslice.SetInterpolationModeToLinear()
116
+ reslice.SetBackgroundLevel(-1000.0) # Set background to air value
117
+
118
+ # Create image actor
119
+ actor = vtk.vtkImageActor()
120
+ actor.GetMapper().SetInputConnection(reslice.GetOutputPort())
121
+ actor.SetVisibility(False) # Start hidden
122
+
123
+ mpr_actors[orientation] = {"reslice": reslice, "actor": actor}
124
+
125
+ # Store actors for this frame
126
+ if frame not in self._mpr_actors:
127
+ self._mpr_actors[frame] = {}
128
+ self._mpr_actors[frame] = mpr_actors
129
+
130
+ # Set up initial reslice matrices for center slices
131
+ self._setup_center_slices(image_data, frame)
132
+
133
+ return mpr_actors
134
+
135
+ def _setup_center_slices(self, image_data, frame: int):
136
+ """Set up reslice matrices to show center slices using LAS coordinate system."""
137
+ center = image_data.GetCenter()
138
+
139
+ actors = self._mpr_actors[frame]
140
+
141
+ # Base LAS vectors (Left-Anterior-Superior coordinate system)
142
+ base_axial_normal = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
143
+ base_axial_up = np.array([0.0, -1.0, 0.0]) # -Y axis (Anterior)
144
+
145
+ base_sagittal_normal = np.array([1.0, 0.0, 0.0]) # X axis (Left)
146
+ base_sagittal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
147
+
148
+ base_coronal_normal = np.array([0.0, 1.0, 0.0]) # Y axis (Posterior in data)
149
+ base_coronal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
150
+
151
+ # Create reslice matrices with proper LAS vectors
152
+ axial_origin = [center[0], center[1], center[2]]
153
+ axial_matrix = create_reslice_matrix(
154
+ base_axial_normal, base_axial_up, axial_origin
155
+ )
156
+ actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
157
+
158
+ sagittal_origin = [center[0], center[1], center[2]]
159
+ sagittal_matrix = create_reslice_matrix(
160
+ base_sagittal_normal, base_sagittal_up, sagittal_origin
161
+ )
162
+ actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
163
+
164
+ # Coronal view: LPS->LAS Y coordinate conversion
165
+ coronal_origin = [center[0], center[1], center[2]]
166
+ coronal_matrix = create_reslice_matrix(
167
+ base_coronal_normal, base_coronal_up, coronal_origin
168
+ )
169
+ actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
170
+
171
+ @property
172
+ def mpr_actors(self) -> dict[str, list[vtk.vtkImageActor]]:
173
+ """Get MPR actors for all frames."""
174
+ return self._mpr_actors
175
+
176
+ def get_mpr_actors_for_frame(self, frame: int) -> dict:
177
+ """Get MPR actors for a specific frame."""
178
+ if frame not in self._mpr_actors:
179
+ return self.create_mpr_actors(frame)
180
+ return self._mpr_actors[frame]
181
+
182
+ def update_slice_positions(
183
+ self,
184
+ frame: int,
185
+ axial_frac: float,
186
+ sagittal_frac: float,
187
+ coronal_frac: float,
188
+ rotation_sequence: list = None,
189
+ rotation_angles: dict = None,
190
+ ):
191
+ """Update slice positions for MPR views with optional rotation."""
192
+ if frame not in self._mpr_actors:
193
+ return
194
+
195
+ volume_actor = self._actors[frame]
196
+ image_data = volume_actor.GetMapper().GetInput()
197
+ bounds = image_data.GetBounds()
198
+
199
+ actors = self._mpr_actors[frame]
200
+
201
+ # Calculate slice positions from fractions
202
+ axial_pos = bounds[4] + axial_frac * (bounds[5] - bounds[4]) # Z bounds
203
+ sagittal_pos = bounds[0] + sagittal_frac * (bounds[1] - bounds[0]) # X bounds
204
+ # Coronal: LPS->LAS Y coordinate conversion (flip direction)
205
+ coronal_pos = bounds[3] - coronal_frac * (
206
+ bounds[3] - bounds[2]
207
+ ) # Flipped Y bounds
208
+
209
+ # Base LAS vectors (Left-Anterior-Superior coordinate system)
210
+ base_axial_normal = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
211
+ base_axial_up = np.array([0.0, -1.0, 0.0]) # -Y axis (Anterior)
212
+ base_sagittal_normal = np.array([1.0, 0.0, 0.0]) # X axis (Left)
213
+ base_sagittal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
214
+ base_coronal_normal = np.array([0.0, 1.0, 0.0]) # Y axis (Posterior in data)
215
+ base_coronal_up = np.array([0.0, 0.0, 1.0]) # Z axis (Superior)
216
+
217
+ # Apply cumulative rotation if provided
218
+ if rotation_sequence and rotation_angles:
219
+ cumulative_rotation = np.eye(3)
220
+ for i, rotation in enumerate(rotation_sequence):
221
+ angle = rotation_angles.get(i, 0)
222
+ rotation_matrix = create_rotation_matrix(rotation["axis"], angle)
223
+ cumulative_rotation = cumulative_rotation @ rotation_matrix
224
+
225
+ # Apply rotation to base view vectors
226
+ axial_normal = cumulative_rotation @ base_axial_normal
227
+ axial_up = cumulative_rotation @ base_axial_up
228
+ sagittal_normal = cumulative_rotation @ base_sagittal_normal
229
+ sagittal_up = cumulative_rotation @ base_sagittal_up
230
+ coronal_normal = cumulative_rotation @ base_coronal_normal
231
+ coronal_up = cumulative_rotation @ base_coronal_up
232
+ else:
233
+ # Use base vectors without rotation
234
+ axial_normal = base_axial_normal
235
+ axial_up = base_axial_up
236
+ sagittal_normal = base_sagittal_normal
237
+ sagittal_up = base_sagittal_up
238
+ coronal_normal = base_coronal_normal
239
+ coronal_up = base_coronal_up
240
+
241
+ center = image_data.GetCenter()
242
+
243
+ # Update axial slice
244
+ axial_origin = [center[0], center[1], axial_pos]
245
+ axial_matrix = create_reslice_matrix(axial_normal, axial_up, axial_origin)
246
+ actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
247
+
248
+ # Update sagittal slice
249
+ sagittal_origin = [sagittal_pos, center[1], center[2]]
250
+ sagittal_matrix = create_reslice_matrix(
251
+ sagittal_normal, sagittal_up, sagittal_origin
252
+ )
253
+ actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
254
+
255
+ # Update coronal slice
256
+ coronal_origin = [center[0], coronal_pos, center[2]]
257
+ coronal_matrix = create_reslice_matrix(
258
+ coronal_normal, coronal_up, coronal_origin
259
+ )
260
+ actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
261
+
262
+ def update_mpr_window_level(self, frame: int, window: float, level: float):
263
+ """Update window/level properties for MPR actors."""
264
+ if frame not in self._mpr_actors:
265
+ return
266
+
267
+ actors = self._mpr_actors[frame]
268
+
269
+ for orientation in ["axial", "sagittal", "coronal"]:
270
+ if orientation in actors:
271
+ actor = actors[orientation]["actor"]
272
+ property_obj = actor.GetProperty()
273
+ property_obj.SetColorWindow(window)
274
+ property_obj.SetColorLevel(level)
@@ -0,0 +1,46 @@
1
+ # Third Party
2
+ import pydantic as pc
3
+ import vtk
4
+
5
+ # Internal
6
+ from .blend_transfer_functions import blend_transfer_functions
7
+ from .transfer_function_pair import TransferFunctionPairConfig
8
+ from .types import ScalarComponent
9
+
10
+
11
+ class VolumePropertyConfig(pc.BaseModel):
12
+ """Configuration for volume rendering properties and transfer functions."""
13
+
14
+ name: str = pc.Field(description="Display name of the preset")
15
+ description: str = pc.Field(description="Description of the preset")
16
+
17
+ # Lighting parameters
18
+ ambient: ScalarComponent
19
+ diffuse: ScalarComponent
20
+ specular: ScalarComponent
21
+
22
+ # Transfer functions
23
+ transfer_functions: list[TransferFunctionPairConfig] = pc.Field(
24
+ min_length=1, description="List of transfer function pairs to blend"
25
+ )
26
+
27
+ @property
28
+ def vtk_property(self) -> vtk.vtkVolumeProperty:
29
+ """Create a fully configured VTK volume property from this configuration."""
30
+ # Get VTK transfer functions from each pair config
31
+ tfs = [pair.vtk_functions for pair in self.transfer_functions]
32
+
33
+ # Blend all transfer functions into a single composite
34
+ blended_otf, blended_ctf = blend_transfer_functions(tfs)
35
+
36
+ # Create and configure the volume property
37
+ _vtk_property = vtk.vtkVolumeProperty()
38
+ _vtk_property.SetScalarOpacity(blended_otf)
39
+ _vtk_property.SetColor(blended_ctf)
40
+ _vtk_property.ShadeOn()
41
+ _vtk_property.SetInterpolationTypeToLinear()
42
+ _vtk_property.SetAmbient(self.ambient)
43
+ _vtk_property.SetDiffuse(self.diffuse)
44
+ _vtk_property.SetSpecular(self.specular)
45
+
46
+ return _vtk_property
@@ -0,0 +1,53 @@
1
+ # System
2
+ import pathlib as pl
3
+
4
+ # Third Party
5
+ import pydantic as pc
6
+ import tomlkit as tk
7
+
8
+ # Internal
9
+ from .volume_property import VolumePropertyConfig
10
+
11
+
12
+ def load_volume_property_preset(preset_name: str) -> VolumePropertyConfig:
13
+ """Load a specific volume property preset from its individual file."""
14
+ assets_dir = pl.Path(__file__).parent / "assets"
15
+ preset_file = assets_dir / f"{preset_name}.toml"
16
+
17
+ if not preset_file.exists():
18
+ available = list(list_volume_property_presets().keys())
19
+ raise KeyError(
20
+ f"Volume property preset '{preset_name}' not found. "
21
+ f"Available presets: {available}"
22
+ )
23
+
24
+ try:
25
+ with preset_file.open("rt", encoding="utf-8") as fp:
26
+ raw_data = tk.load(fp)
27
+ return VolumePropertyConfig.model_validate(raw_data)
28
+ except (pc.ValidationError, Exception) as e:
29
+ raise ValueError(f"Invalid preset file '{preset_name}.toml': {e}") from e
30
+
31
+
32
+ def list_volume_property_presets() -> dict[str, str]:
33
+ """
34
+ List all available volume property presets.
35
+
36
+ Returns:
37
+ Dictionary mapping preset names to descriptions
38
+ """
39
+ assets_dir = pl.Path(__file__).parent / "assets"
40
+ preset_files = assets_dir.glob("*.toml")
41
+
42
+ presets = {}
43
+ for preset_file in preset_files:
44
+ preset_name = preset_file.stem
45
+ try:
46
+ with preset_file.open("rt", encoding="utf-8") as fp:
47
+ preset_data = tk.load(fp)
48
+ presets[preset_name] = preset_data["description"]
49
+ except (KeyError, OSError):
50
+ # Skip files that don't have the expected structure
51
+ continue
52
+
53
+ return presets
cardio/window_level.py ADDED
@@ -0,0 +1,35 @@
1
+ # System
2
+ import functools as ft
3
+
4
+ # Third Party
5
+ import pydantic as pc
6
+
7
+
8
+ @pc.dataclasses.dataclass(config=dict(frozen=True))
9
+ class WindowLevel:
10
+ name: str
11
+ window: float
12
+ level: float
13
+
14
+ @pc.computed_field
15
+ @ft.cached_property
16
+ def lower(self) -> float:
17
+ return self.level - self.window / 2
18
+
19
+ @pc.computed_field
20
+ @ft.cached_property
21
+ def upper(self) -> float:
22
+ return self.level + self.window / 2
23
+
24
+
25
+ presets = {
26
+ 1: WindowLevel("Abdomen", 400, 40),
27
+ 2: WindowLevel("Lung", 1500, -700),
28
+ 3: WindowLevel("Liver", 100, 110),
29
+ 4: WindowLevel("Bone", 1500, 500),
30
+ 5: WindowLevel("Brain", 85, 42),
31
+ 6: WindowLevel("Stroke", 36, 28),
32
+ 7: WindowLevel("Vascular", 800, 200),
33
+ 8: WindowLevel("Subdural", 160, 60),
34
+ 9: WindowLevel("Normalized", 0, 1),
35
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cardio
3
- Version: 2025.8.0
3
+ Version: 2025.9.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
@@ -34,17 +34,19 @@ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
34
34
  Classifier: Topic :: Scientific/Engineering :: Visualization
35
35
  Requires-Dist: trame-vuetify>=3.0.2
36
36
  Requires-Dist: trame-vtk>=2.9.1
37
- Requires-Dist: trame>=3.11.0
38
37
  Requires-Dist: itk>=5.4.4.post1
39
38
  Requires-Dist: vtk>=9.5.0
40
39
  Requires-Dist: tomlkit>=0.13.3
41
40
  Requires-Dist: numpy>=2.2.6
42
41
  Requires-Dist: pydantic>=2.11.7
43
42
  Requires-Dist: pydantic-settings>=2.0.0
44
- Requires-Dist: isort ; extra == 'dev'
45
- Requires-Dist: pytest ; extra == 'dev'
46
- Requires-Dist: bumpver ; extra == 'dev'
43
+ Requires-Dist: trame>=3.12.0
44
+ Requires-Dist: trame-client>=3.10.1
47
45
  Requires-Dist: ruff>=0.12.10 ; extra == 'dev'
46
+ Requires-Dist: isort>=6.0.1 ; extra == 'dev'
47
+ Requires-Dist: bumpver>=2025.1131 ; extra == 'dev'
48
+ Requires-Dist: pytest>=8.4.1 ; extra == 'dev'
49
+ Requires-Dist: coverage>=7.10.4 ; extra == 'dev'
48
50
  Maintainer: Davis Marc Vigneault
49
51
  Maintainer-email: Davis Marc Vigneault <davis.vigneault@gmail.com>
50
52
  Requires-Python: >=3.10
@@ -74,7 +76,7 @@ $ uv init
74
76
  $ uv add cardio
75
77
  $ . ./.venv/bin/activate
76
78
  (project) cardio --version
77
- cardio 2025.8.0
79
+ cardio 2025.9.0
78
80
  ```
79
81
 
80
82
  ### Developing
@@ -92,3 +94,11 @@ $ isort .
92
94
  $ ruff format
93
95
  $ pytest -v
94
96
  ```
97
+
98
+ Uploading:
99
+
100
+ ```bash
101
+ $ bumpver update
102
+ $ uv build --no-sources
103
+ $ uv publish --token <pypi_api_key>
104
+ ```
@@ -0,0 +1,28 @@
1
+ cardio/__init__.py,sha256=tEZTwdKZnx4WPaNTsZZSwBqLfCQgB0e3Rzov5-ci1GI,581
2
+ cardio/app.py,sha256=jcT0AUccoB6CPAKQ_gAJE3Q-jsrc9OT4XlK1XskdrI4,1791
3
+ cardio/assets/bone.toml,sha256=vv8uVYSHIoKuHkNCoBOkGe2_qoEbXMvQO6ypm3mMOtA,675
4
+ cardio/assets/vascular_closed.toml,sha256=XtaZS_Zd6NSAtY3ZlUfiog3T86u9Ii0oSutU2wBQy78,1267
5
+ cardio/assets/vascular_open.toml,sha256=1M3sV1IGt3zh_3vviysKEk9quKfjF9xUBcIq3kxVHFM,879
6
+ cardio/assets/xray.toml,sha256=siPem0OZ2OkWH0e5pizftpItJKGJgxKJ_S2K0316ubQ,693
7
+ cardio/blend_transfer_functions.py,sha256=s5U4hO810oE434wIkPmAP2mrAfqFb4xxxi3hHf_k8og,2982
8
+ cardio/color_transfer_function.py,sha256=KV4j11AXYeaYGeJWBc9I-WZf7Shrm5xjQVq-0bq9Qc8,817
9
+ cardio/logic.py,sha256=usIAZkF68op3apQJXR8m2y8ozuulVHlGSgX7qqyctV4,31462
10
+ cardio/mesh.py,sha256=Q-5MgEoX3nInwUZe4o2MU4nKy9oR34Qj41JwevxW8bc,9410
11
+ cardio/object.py,sha256=zly-2bGnB7K45gRLnMgxE-q7cUtdlOLLr4z4QoVkZJ0,6172
12
+ cardio/piecewise_function.py,sha256=bwtwgrAMGkgu1krnvsOF9gRMaZb6smsS9jLrgBecSbo,789
13
+ cardio/property_config.py,sha256=XJYcKeRcq8s9W9jqxzVer75r5jBLuvebv780FYdPV8U,1723
14
+ cardio/scene.py,sha256=2GAbSvFMPQAHoNz2EO4ukXkIXmfy3-Km3ik5OHdDzJE,13015
15
+ cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
16
+ cardio/segmentation.py,sha256=enj1V5Rw42dxjqSRUiylEQcdgvj8A-Do9Z638tf7nc8,6706
17
+ cardio/transfer_function_pair.py,sha256=90PQXByCL6mMaODo7Yfd-lmdFtCKhcBZbNaiH-PTds0,805
18
+ cardio/types.py,sha256=DYDgA5QmYdU3QQrEgZMouEbMEIf40DJCeXo4V7cDXtg,356
19
+ cardio/ui.py,sha256=ON8SUuxyo1W3m1IX18K-8wxd1dCFlNcRufxH88iXEZk,33738
20
+ cardio/utils.py,sha256=zgyJ2PWTAWIaU8SVA9KF_XFB4251QxYAT6w3cqaIerA,3051
21
+ cardio/volume.py,sha256=Wt1EoOJ_U2RZLjK67_iIYMjFiXIKV2DNLkXd-8qFslU,10613
22
+ cardio/volume_property.py,sha256=6T2r67SSIDl8F6ZlQvgMCZESLxuXVVAUjOC50lgQEmk,1644
23
+ cardio/volume_property_presets.py,sha256=U2a2MnyCjryzOLEADs3OLSMMmAUnXq82mYK7OVXbQV0,1659
24
+ cardio/window_level.py,sha256=gjk39Iv6jMJ52y6jydOjxBZBsI1nZQMs2CdWWTshQoE,807
25
+ cardio-2025.9.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
26
+ cardio-2025.9.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
27
+ cardio-2025.9.0.dist-info/METADATA,sha256=5Sf5ChcVgdhHQNZM45oqsIVPCKGkhPqqCkunHvpR0vQ,3538
28
+ cardio-2025.9.0.dist-info/RECORD,,