cardio 2025.10.0__py3-none-any.whl → 2025.12.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
@@ -20,7 +20,7 @@ class Volume(Object):
20
20
  """Volume object with transfer functions and clipping support."""
21
21
 
22
22
  pattern: str = pc.Field(
23
- default="${frame}.nii.gz",
23
+ default="{frame}.nii.gz",
24
24
  description="Filename pattern with $frame placeholder",
25
25
  )
26
26
  transfer_function_preset: str = pc.Field(
@@ -30,6 +30,9 @@ class Volume(Object):
30
30
  _mpr_actors: dict[str, list[vtk.vtkImageActor]] = pc.PrivateAttr(
31
31
  default_factory=dict
32
32
  )
33
+ _crosshair_actors: dict[str, dict[str, vtk.vtkActor]] = pc.PrivateAttr(
34
+ default_factory=dict
35
+ )
33
36
 
34
37
  @pc.model_validator(mode="after")
35
38
  def initialize_volume(self):
@@ -134,6 +137,104 @@ class Volume(Object):
134
137
  return self.create_mpr_actors(frame)
135
138
  return self._mpr_actors[frame]
136
139
 
140
+ def create_crosshair_actors(self, colors: dict, line_width: float = 1.5) -> dict:
141
+ """Create 2D crosshair overlay actors for each MPR view.
142
+
143
+ Uses screen-space 2D actors that always appear centered in the view.
144
+
145
+ Args:
146
+ colors: Dict mapping view names to RGB tuples
147
+ line_width: Width of the crosshair lines
148
+
149
+ Returns:
150
+ Dict mapping view names to dicts with line actors
151
+ """
152
+ crosshairs = {}
153
+
154
+ for view_name in ["axial", "sagittal", "coronal"]:
155
+ view_crosshairs = {}
156
+
157
+ for line_name in ["line1", "line2"]:
158
+ # Create 2D line using normalized viewport coordinates
159
+ points = vtk.vtkPoints()
160
+ lines = vtk.vtkCellArray()
161
+
162
+ # Placeholder points - will set based on line orientation
163
+ if line_name == "line1":
164
+ # Vertical line (one of the other planes)
165
+ points.InsertNextPoint(0.5, 0.0, 0.0)
166
+ points.InsertNextPoint(0.5, 1.0, 0.0)
167
+ else:
168
+ # Horizontal line (other plane)
169
+ points.InsertNextPoint(0.0, 0.5, 0.0)
170
+ points.InsertNextPoint(1.0, 0.5, 0.0)
171
+
172
+ line = vtk.vtkLine()
173
+ line.GetPointIds().SetId(0, 0)
174
+ line.GetPointIds().SetId(1, 1)
175
+ lines.InsertNextCell(line)
176
+
177
+ polydata = vtk.vtkPolyData()
178
+ polydata.SetPoints(points)
179
+ polydata.SetLines(lines)
180
+
181
+ # Use coordinate transform to map normalized coords to viewport
182
+ coord = vtk.vtkCoordinate()
183
+ coord.SetCoordinateSystemToNormalizedViewport()
184
+
185
+ mapper = vtk.vtkPolyDataMapper2D()
186
+ mapper.SetInputData(polydata)
187
+ mapper.SetTransformCoordinate(coord)
188
+
189
+ actor = vtk.vtkActor2D()
190
+ actor.SetMapper(mapper)
191
+ actor.GetProperty().SetLineWidth(line_width)
192
+ actor.SetVisibility(False)
193
+
194
+ view_crosshairs[line_name] = {
195
+ "polydata": polydata,
196
+ "actor": actor,
197
+ }
198
+
199
+ # Set colors based on which planes the lines represent
200
+ if view_name == "axial":
201
+ view_crosshairs["line1"]["actor"].GetProperty().SetColor(
202
+ *colors.get("sagittal", (1, 0, 0))
203
+ )
204
+ view_crosshairs["line2"]["actor"].GetProperty().SetColor(
205
+ *colors.get("coronal", (0, 1, 0))
206
+ )
207
+ elif view_name == "sagittal":
208
+ view_crosshairs["line1"]["actor"].GetProperty().SetColor(
209
+ *colors.get("axial", (0, 0, 1))
210
+ )
211
+ view_crosshairs["line2"]["actor"].GetProperty().SetColor(
212
+ *colors.get("coronal", (0, 1, 0))
213
+ )
214
+ else: # coronal
215
+ view_crosshairs["line1"]["actor"].GetProperty().SetColor(
216
+ *colors.get("axial", (0, 0, 1))
217
+ )
218
+ view_crosshairs["line2"]["actor"].GetProperty().SetColor(
219
+ *colors.get("sagittal", (1, 0, 0))
220
+ )
221
+
222
+ crosshairs[view_name] = view_crosshairs
223
+
224
+ self._crosshair_actors = crosshairs
225
+ return crosshairs
226
+
227
+ @property
228
+ def crosshair_actors(self) -> dict:
229
+ """Get crosshair actors."""
230
+ return self._crosshair_actors
231
+
232
+ def set_crosshairs_visible(self, visible: bool):
233
+ """Set visibility of all crosshair actors."""
234
+ for view_crosshairs in self._crosshair_actors.values():
235
+ for line_data in view_crosshairs.values():
236
+ line_data["actor"].SetVisibility(visible)
237
+
137
238
  def _get_mpr_coordinate_systems(self):
138
239
  """Get coordinate system transformation matrices for MPR views."""
139
240
  view_axcodes = {
@@ -197,12 +298,54 @@ class Volume(Object):
197
298
 
198
299
  return bounds
199
300
 
301
+ def _build_cumulative_rotation(
302
+ self, rotation_sequence: list, rotation_angles: dict
303
+ ) -> np.ndarray:
304
+ """Build cumulative rotation matrix from sequence of rotations."""
305
+ cumulative_rotation = np.eye(3)
306
+ if rotation_sequence and rotation_angles:
307
+ for i, rotation in enumerate(rotation_sequence):
308
+ angle = rotation_angles.get(i, 0)
309
+ rotation_matrix = euler_angle_to_rotation_matrix(
310
+ EulerAxis(rotation["axis"]), angle
311
+ )
312
+ cumulative_rotation = cumulative_rotation @ rotation_matrix
313
+ return cumulative_rotation
314
+
315
+ def get_scroll_vector(
316
+ self,
317
+ view_name: str,
318
+ rotation_sequence: list = None,
319
+ rotation_angles: dict = None,
320
+ ) -> np.ndarray:
321
+ """Get the current normal vector for a view after rotation.
322
+
323
+ Args:
324
+ view_name: One of "axial", "sagittal", "coronal"
325
+ rotation_sequence: List of rotation definitions
326
+ rotation_angles: Dict mapping rotation index to angle
327
+
328
+ Returns:
329
+ 3D unit vector representing the scroll direction for this view
330
+ """
331
+ base_normals = {
332
+ "axial": np.array([0.0, 0.0, 1.0]),
333
+ "sagittal": np.array([1.0, 0.0, 0.0]),
334
+ "coronal": np.array([0.0, 1.0, 0.0]),
335
+ }
336
+
337
+ if view_name not in base_normals:
338
+ return np.array([0.0, 0.0, 1.0])
339
+
340
+ cumulative_rotation = self._build_cumulative_rotation(
341
+ rotation_sequence, rotation_angles
342
+ )
343
+ return cumulative_rotation @ base_normals[view_name]
344
+
200
345
  def update_slice_positions(
201
346
  self,
202
347
  frame: int,
203
- axial_pos: float,
204
- sagittal_pos: float,
205
- coronal_pos: float,
348
+ origin: list,
206
349
  rotation_sequence: list = None,
207
350
  rotation_angles: dict = None,
208
351
  ):
@@ -210,62 +353,36 @@ class Volume(Object):
210
353
 
211
354
  Args:
212
355
  frame: Frame index
213
- axial_pos: Physical position along Z axis (LAS Superior)
214
- sagittal_pos: Physical position along X axis (LAS Left)
215
- coronal_pos: Physical position along Y axis (LAS Anterior)
356
+ origin: [x, y, z] position in LPS coordinates (shared by all views)
357
+ rotation_sequence: List of rotation definitions
358
+ rotation_angles: Dict mapping rotation index to angle
216
359
  """
217
360
  if frame not in self._mpr_actors:
218
361
  return
219
362
 
220
- volume_actor = self._actors[frame]
221
- image_data = volume_actor.GetMapper().GetInput()
222
- bounds = image_data.GetBounds()
223
-
224
363
  actors = self._mpr_actors[frame]
225
364
 
226
- # Clamp positions to volume bounds
227
- axial_pos = max(bounds[4], min(bounds[5], axial_pos)) # Z bounds
228
- sagittal_pos = max(bounds[0], min(bounds[1], sagittal_pos)) # X bounds
229
- coronal_pos = max(bounds[2], min(bounds[3], coronal_pos)) # Y bounds
230
-
231
365
  # Get coordinate system transformations for each MPR view
232
366
  transforms = self._get_mpr_coordinate_systems()
233
367
 
234
- center = image_data.GetCenter()
235
-
236
- # Step 1: Apply translation to determine slice origins in physical space
237
- axial_origin = [center[0], center[1], axial_pos]
238
- sagittal_origin = [sagittal_pos, center[1], center[2]]
239
- coronal_origin = [center[0], coronal_pos, center[2]]
368
+ # Build cumulative rotation matrix
369
+ cumulative_rotation = self._build_cumulative_rotation(
370
+ rotation_sequence, rotation_angles
371
+ )
240
372
 
241
- # Step 2: Apply cumulative rotation around the translated origins
242
- if rotation_sequence and rotation_angles:
243
- cumulative_rotation = np.eye(3)
244
- for i, rotation in enumerate(rotation_sequence):
245
- angle = rotation_angles.get(i, 0)
246
- rotation_matrix = euler_angle_to_rotation_matrix(
247
- EulerAxis(rotation["axis"]), angle
248
- )
249
- cumulative_rotation = cumulative_rotation @ rotation_matrix
373
+ # Apply rotation to base transforms
374
+ axial_transform = cumulative_rotation @ transforms["axial"]
375
+ sagittal_transform = cumulative_rotation @ transforms["sagittal"]
376
+ coronal_transform = cumulative_rotation @ transforms["coronal"]
250
377
 
251
- # Apply rotation to base transforms
252
- axial_transform = cumulative_rotation @ transforms["axial"]
253
- sagittal_transform = cumulative_rotation @ transforms["sagittal"]
254
- coronal_transform = cumulative_rotation @ transforms["coronal"]
255
- else:
256
- # Use base transforms without rotation
257
- axial_transform = transforms["axial"]
258
- sagittal_transform = transforms["sagittal"]
259
- coronal_transform = transforms["coronal"]
260
-
261
- # Update slices with translated origins and rotated transforms
262
- axial_matrix = create_vtk_reslice_matrix(axial_transform, axial_origin)
378
+ # All views share the same origin
379
+ axial_matrix = create_vtk_reslice_matrix(axial_transform, origin)
263
380
  actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
264
381
 
265
- sagittal_matrix = create_vtk_reslice_matrix(sagittal_transform, sagittal_origin)
382
+ sagittal_matrix = create_vtk_reslice_matrix(sagittal_transform, origin)
266
383
  actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
267
384
 
268
- coronal_matrix = create_vtk_reslice_matrix(coronal_transform, coronal_origin)
385
+ coronal_matrix = create_vtk_reslice_matrix(coronal_transform, origin)
269
386
  actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
270
387
 
271
388
  def update_mpr_window_level(self, frame: int, window: float, level: float):
cardio/volume_property.py CHANGED
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .blend_transfer_functions import blend_transfer_functions
7
5
  from .transfer_function_pair import TransferFunctionPairConfig
8
6
  from .types import ScalarComponent
@@ -1,11 +1,8 @@
1
- # System
2
1
  import pathlib as pl
3
2
 
4
- # Third Party
5
3
  import pydantic as pc
6
4
  import tomlkit as tk
7
5
 
8
- # Internal
9
6
  from .volume_property import VolumePropertyConfig
10
7
 
11
8
 
cardio/window_level.py CHANGED
@@ -1,7 +1,5 @@
1
- # System
2
1
  import functools as ft
3
2
 
4
- # Third Party
5
3
  import pydantic as pc
6
4
 
7
5
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cardio
3
- Version: 2025.10.0
3
+ Version: 2025.12.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
@@ -59,12 +59,11 @@ Description-Content-Type: text/markdown
59
59
  `cardio` is a simple web-based viewer for 3D and 4D ('cine') medical imaging data,
60
60
  built primarily on [trame](https://github.com/kitware/trame),
61
61
  [vtk](https://github.com/kitware/vtk), and
62
- [itk](https://github.com/insightsoftwareconsortium/itk). `cardio` is able to
63
- render sequences of mesh files (e.g., `\*.obj` files), segmentation files (e.g.
64
- `\*nii.gz` files with discrete labels) and volume renderings of grayscale images
65
- (e.g. \*.nii.gz files with continuous values). `cardio` is launched from the
66
- commandline and may be configured either directly from the commandline, via a static
67
- TOML configuration file, or a combination of the two.
62
+ [itk](https://github.com/insightsoftwareconsortium/itk). `cardio` can render sequences
63
+ of mesh files (e.g., `\*.obj` files), segmentation files (e.g., `\*nii.gz` files with
64
+ discrete labels) and volume renderings of grayscale images (e.g., `\*.nii.gz` files with
65
+ continuous values). `cardio` is launched from the commandline and may be configured via
66
+ commandline arguments, a static TOML configuration file, or a combination of the two.
68
67
 
69
68
  ## Quickstart
70
69
 
@@ -76,7 +75,7 @@ $ uv init
76
75
  $ uv add cardio
77
76
  $ . ./.venv/bin/activate
78
77
  (project) cardio --version
79
- cardio 2025.10.0
78
+ cardio 2025.12.0
80
79
  ```
81
80
 
82
81
  ### Developing
@@ -0,0 +1,29 @@
1
+ cardio/__init__.py,sha256=laKThzytBHNMY_Z5Qy9-jF25NpfSyfDM9VGrG4bhJ14,602
2
+ cardio/app.py,sha256=h-Xh57Txw3Hom-fDv08YxjXzovuIxY8_5qPy6MJThZs,1354
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=fkLDYGMj_QXYs0vmXYT_B1tgTfl3YICLYimtWlxrmbQ,2958
8
+ cardio/color_transfer_function.py,sha256=uTyPdwxi0HjR4wm418cQN9-q9SspkkeqvLNqXaF3zzg,792
9
+ cardio/logic.py,sha256=e7OaroXgpq6oE3Zfa2afusVsEzopkpL4YdEGPuFINzc,39885
10
+ cardio/mesh.py,sha256=wYdU0BU84eXrrHp0U0VLwYW7ZpRJ6GbT5Kl_-K6CBzY,9356
11
+ cardio/object.py,sha256=98A32VpFR4UtVqW8dZsRJR13VVyUoJJ20uoOZBgN4js,6168
12
+ cardio/orientation.py,sha256=GRm6Ix3hzMXybgKgpJip0Mlodqn_LCV9VaGFjTAmS-A,6122
13
+ cardio/piecewise_function.py,sha256=X-_C-BVStufmFjfF8IbkqKk9Xw_jh00JUmjC22ZqeqQ,764
14
+ cardio/property_config.py,sha256=YrWIyCoSAfJPigkhriIQybQpJantGnXjT4nJfrtIJco,1689
15
+ cardio/scene.py,sha256=H_GxJ6Dq2QVx7RJYwwvleLL5M_egTzFd0bJ95TqtrhI,14530
16
+ cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
17
+ cardio/segmentation.py,sha256=KT1ClgultXyGpZDdpYxor38GflY7uCgRzfA8JEGQVaU,6642
18
+ cardio/transfer_function_pair.py,sha256=_J0qXA0InUPpvfWPcW492KGSYAqeb12htCzKBSpWHwo,780
19
+ cardio/types.py,sha256=jxSZjvxEJ03OThfupT2CG9UHsFklwbWeFrUozNXro2I,333
20
+ cardio/ui.py,sha256=KwRGb_vDZ6pi8TQ82Ios7zcG9xmvd2hzCv45h0voNMM,50109
21
+ cardio/utils.py,sha256=tFUQ4FxfidTH6GjEIKQwguqhO9T_wJ2Vk0IhbEfxRGA,1616
22
+ cardio/volume.py,sha256=wKvEhjCGhnn8Se-VVNyurspfH9IgAvqp7Br_j0wUdDM,14536
23
+ cardio/volume_property.py,sha256=-EUMV9sWCaetgGjnqIWxPp39qrxEZKTNDJ5GHUgLMlk,1619
24
+ cardio/volume_property_presets.py,sha256=4-hjo-dukm5sMMmWidbWnVXq0IN4sWpBnDITY9MqUFg,1625
25
+ cardio/window_level.py,sha256=XMkwLAHcmsEYcI0SoHySQZvptq4VwX2gj--ps3hV8AQ,784
26
+ cardio-2025.12.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
27
+ cardio-2025.12.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
28
+ cardio-2025.12.0.dist-info/METADATA,sha256=y1706xsek2kYRBsXskEqYxAHdBl5ovdWOIkXULc572o,3522
29
+ cardio-2025.12.0.dist-info/RECORD,,
@@ -1,29 +0,0 @@
1
- cardio/__init__.py,sha256=5lINeD9qQQm_u9eJfPIzdEMfYW62UZ2asOmQIS4PcFk,602
2
- cardio/app.py,sha256=TEzgA03EAgI7HSCHhYwYb8tsnAptsrpcysV5CytfAq4,1379
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=a73JJ2b8t-TYggYMGI1IGpn0Q6AFD8I1JNz9fn6QeDk,37557
10
- cardio/mesh.py,sha256=xL4hadrVF3GVtMFxpq79DKApbmstZEI_HEwTAqc4ZMI,9391
11
- cardio/object.py,sha256=fvLSZtWf1zDbYMh-AMpgVLwD-9S1LCzuRt7HmkXxb3A,6215
12
- cardio/orientation.py,sha256=J3bqZbv8vfl4loGl7ksmuyqWb3zFAz-TVSIahKcg0pc,6145
13
- cardio/piecewise_function.py,sha256=bwtwgrAMGkgu1krnvsOF9gRMaZb6smsS9jLrgBecSbo,789
14
- cardio/property_config.py,sha256=XJYcKeRcq8s9W9jqxzVer75r5jBLuvebv780FYdPV8U,1723
15
- cardio/scene.py,sha256=9jskdEARyJjk7QBIcMt5X2HsnikTAJdoRoLSI0LxcJE,14301
16
- cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
17
- cardio/segmentation.py,sha256=QqeG2C3BVbO9fUJDSWf0mZXzgfrTx4LHWwaz4vtv8ZM,6677
18
- cardio/transfer_function_pair.py,sha256=90PQXByCL6mMaODo7Yfd-lmdFtCKhcBZbNaiH-PTds0,805
19
- cardio/types.py,sha256=DYDgA5QmYdU3QQrEgZMouEbMEIf40DJCeXo4V7cDXtg,356
20
- cardio/ui.py,sha256=wsRujwiNtMeq65R4bx-myxSPDhJT0RskwvBWlGenmHk,42901
21
- cardio/utils.py,sha256=tFUQ4FxfidTH6GjEIKQwguqhO9T_wJ2Vk0IhbEfxRGA,1616
22
- cardio/volume.py,sha256=TxUfOvEoQw-kbEAQLSTfVOU3DhJjnjB8iy4qygCmVXA,10378
23
- cardio/volume_property.py,sha256=6T2r67SSIDl8F6ZlQvgMCZESLxuXVVAUjOC50lgQEmk,1644
24
- cardio/volume_property_presets.py,sha256=U2a2MnyCjryzOLEADs3OLSMMmAUnXq82mYK7OVXbQV0,1659
25
- cardio/window_level.py,sha256=gjk39Iv6jMJ52y6jydOjxBZBsI1nZQMs2CdWWTshQoE,807
26
- cardio-2025.10.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
27
- cardio-2025.10.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
28
- cardio-2025.10.0.dist-info/METADATA,sha256=VIqGgz8ueoxxEahbi1hshAnK90zWx_IUlyihRJHdwH8,3540
29
- cardio-2025.10.0.dist-info/RECORD,,