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/volume.py CHANGED
@@ -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("coronal", (0, 1, 0))
210
+ )
211
+ view_crosshairs["line2"]["actor"].GetProperty().SetColor(
212
+ *colors.get("axial", (0, 0, 1))
213
+ )
214
+ else: # coronal
215
+ view_crosshairs["line1"]["actor"].GetProperty().SetColor(
216
+ *colors.get("sagittal", (1, 0, 0))
217
+ )
218
+ view_crosshairs["line2"]["actor"].GetProperty().SetColor(
219
+ *colors.get("axial", (0, 0, 1))
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,75 +298,104 @@ class Volume(Object):
197
298
 
198
299
  return bounds
199
300
 
301
+ def _build_cumulative_rotation(
302
+ self, rotation_sequence: list, rotation_angles: dict, angle_units=None
303
+ ) -> np.ndarray:
304
+ """Build cumulative rotation matrix from sequence of rotations."""
305
+ from .orientation import AngleUnits
306
+
307
+ if angle_units is None:
308
+ angle_units = AngleUnits.DEGREES
309
+
310
+ cumulative_rotation = np.eye(3)
311
+ if rotation_sequence and rotation_angles:
312
+ for i, rotation in enumerate(rotation_sequence):
313
+ angle = rotation_angles.get(i, 0)
314
+ rotation_matrix = euler_angle_to_rotation_matrix(
315
+ EulerAxis(rotation["axis"]), angle, angle_units
316
+ )
317
+ cumulative_rotation = cumulative_rotation @ rotation_matrix
318
+ return cumulative_rotation
319
+
320
+ def get_scroll_vector(
321
+ self,
322
+ view_name: str,
323
+ rotation_sequence: list = None,
324
+ rotation_angles: dict = None,
325
+ angle_units=None,
326
+ ) -> np.ndarray:
327
+ """Get the current normal vector for a view after rotation.
328
+
329
+ Args:
330
+ view_name: One of "axial", "sagittal", "coronal"
331
+ rotation_sequence: List of rotation definitions
332
+ rotation_angles: Dict mapping rotation index to angle
333
+ angle_units: AngleUnits enum (degrees or radians)
334
+
335
+ Returns:
336
+ 3D unit vector representing the scroll direction for this view
337
+ """
338
+ base_normals = {
339
+ "axial": np.array([0.0, 0.0, 1.0]),
340
+ "sagittal": np.array([1.0, 0.0, 0.0]),
341
+ "coronal": np.array([0.0, 1.0, 0.0]),
342
+ }
343
+
344
+ if view_name not in base_normals:
345
+ return np.array([0.0, 0.0, 1.0])
346
+
347
+ cumulative_rotation = self._build_cumulative_rotation(
348
+ rotation_sequence, rotation_angles, angle_units
349
+ )
350
+ return cumulative_rotation @ base_normals[view_name]
351
+
200
352
  def update_slice_positions(
201
353
  self,
202
354
  frame: int,
203
- axial_pos: float,
204
- sagittal_pos: float,
205
- coronal_pos: float,
355
+ origin: list,
206
356
  rotation_sequence: list = None,
207
357
  rotation_angles: dict = None,
358
+ angle_units=None,
208
359
  ):
209
360
  """Update slice positions for MPR views with optional rotation.
210
361
 
211
362
  Args:
212
363
  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)
364
+ origin: [x, y, z] position in LPS coordinates (shared by all views)
365
+ rotation_sequence: List of rotation definitions
366
+ rotation_angles: Dict mapping rotation index to angle
367
+ angle_units: AngleUnits enum (degrees or radians), defaults to DEGREES
216
368
  """
369
+ from .orientation import AngleUnits
370
+
371
+ if angle_units is None:
372
+ angle_units = AngleUnits.DEGREES
217
373
  if frame not in self._mpr_actors:
218
374
  return
219
375
 
220
- volume_actor = self._actors[frame]
221
- image_data = volume_actor.GetMapper().GetInput()
222
- bounds = image_data.GetBounds()
223
-
224
376
  actors = self._mpr_actors[frame]
225
377
 
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
378
  # Get coordinate system transformations for each MPR view
232
379
  transforms = self._get_mpr_coordinate_systems()
233
380
 
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]]
381
+ # Build cumulative rotation matrix
382
+ cumulative_rotation = self._build_cumulative_rotation(
383
+ rotation_sequence, rotation_angles, angle_units
384
+ )
240
385
 
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
386
+ # Apply rotation to base transforms
387
+ axial_transform = cumulative_rotation @ transforms["axial"]
388
+ sagittal_transform = cumulative_rotation @ transforms["sagittal"]
389
+ coronal_transform = cumulative_rotation @ transforms["coronal"]
250
390
 
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)
391
+ # All views share the same origin
392
+ axial_matrix = create_vtk_reslice_matrix(axial_transform, origin)
263
393
  actors["axial"]["reslice"].SetResliceAxes(axial_matrix)
264
394
 
265
- sagittal_matrix = create_vtk_reslice_matrix(sagittal_transform, sagittal_origin)
395
+ sagittal_matrix = create_vtk_reslice_matrix(sagittal_transform, origin)
266
396
  actors["sagittal"]["reslice"].SetResliceAxes(sagittal_matrix)
267
397
 
268
- coronal_matrix = create_vtk_reslice_matrix(coronal_transform, coronal_origin)
398
+ coronal_matrix = create_vtk_reslice_matrix(coronal_transform, origin)
269
399
  actors["coronal"]["reslice"].SetResliceAxes(coronal_matrix)
270
400
 
271
401
  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.1
3
+ Version: 2026.1.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.1
78
+ cardio 2026.1.0
80
79
  ```
81
80
 
82
81
  ### Developing
@@ -0,0 +1,30 @@
1
+ cardio/__init__.py,sha256=WZ7MfzISF8ryIC9NdIyub4mP6DQBWsMX766IPcWihXg,601
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=pjAqiJc_3pfUpB9hXWiSYJTmbG4c1omuUvuT2gshPNo,40431
10
+ cardio/mesh.py,sha256=wYdU0BU84eXrrHp0U0VLwYW7ZpRJ6GbT5Kl_-K6CBzY,9356
11
+ cardio/object.py,sha256=98A32VpFR4UtVqW8dZsRJR13VVyUoJJ20uoOZBgN4js,6168
12
+ cardio/orientation.py,sha256=zOw3R284izAcKXnCCYYFOzOPBqgiE8om8q2JDWTQFoY,6256
13
+ cardio/piecewise_function.py,sha256=X-_C-BVStufmFjfF8IbkqKk9Xw_jh00JUmjC22ZqeqQ,764
14
+ cardio/property_config.py,sha256=YrWIyCoSAfJPigkhriIQybQpJantGnXjT4nJfrtIJco,1689
15
+ cardio/rotation.py,sha256=eUUk74kBpPr-YCZdIAcU5pIX6FKeVGT8dRg2YnaS2yE,7204
16
+ cardio/scene.py,sha256=2oq-zVMSHj9PhNToSMbk8RzJ3jKzo7oaAvDn_U4vcPM,15583
17
+ cardio/screenshot.py,sha256=l8bLgxnU5O0FlnmsyVAzKwM9Y0401IzcdnDP0WqFSTY,640
18
+ cardio/segmentation.py,sha256=KT1ClgultXyGpZDdpYxor38GflY7uCgRzfA8JEGQVaU,6642
19
+ cardio/transfer_function_pair.py,sha256=_J0qXA0InUPpvfWPcW492KGSYAqeb12htCzKBSpWHwo,780
20
+ cardio/types.py,sha256=jxSZjvxEJ03OThfupT2CG9UHsFklwbWeFrUozNXro2I,333
21
+ cardio/ui.py,sha256=uYyO1-Lysi5guFDv4k3HSGJ12Ro27B1yxkmS_PFbd-M,53189
22
+ cardio/utils.py,sha256=ao4a7_vMjGBxTOMhZ7r0D0W4ujiwKPS0i8Xfmn3Gv9k,1497
23
+ cardio/volume.py,sha256=sdQ7gtX4jQDD9U8U95xD9LIV_1hpykj8NTCo1_aIKVM,15035
24
+ cardio/volume_property.py,sha256=-EUMV9sWCaetgGjnqIWxPp39qrxEZKTNDJ5GHUgLMlk,1619
25
+ cardio/volume_property_presets.py,sha256=4-hjo-dukm5sMMmWidbWnVXq0IN4sWpBnDITY9MqUFg,1625
26
+ cardio/window_level.py,sha256=XMkwLAHcmsEYcI0SoHySQZvptq4VwX2gj--ps3hV8AQ,784
27
+ cardio-2026.1.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
28
+ cardio-2026.1.0.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
29
+ cardio-2026.1.0.dist-info/METADATA,sha256=xulm4Dgn_80ZFMHSKEBtxnoEfYKFFf3Pfb3aNM1fgzw,3520
30
+ cardio-2026.1.0.dist-info/RECORD,,
@@ -1,29 +0,0 @@
1
- cardio/__init__.py,sha256=cVW5yofdKIbjqc0sf_VVnIMah9BvAgELy7IzZR50IZQ,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=XIJX2Ldv1Bw82yykE0N4IlQ0xE2r6xitjxsbEvEKrcI,9390
11
- cardio/object.py,sha256=FvMzhO9gyHhXgME8B6fcnWH74HmsYNoUjpVKUWEpWhM,6191
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=SK9f834lld08qYH9l2Ligml212u7MzfcOEgTz8wnpzs,6676
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=k7NYZCwsmoAbs8mNWDC51gS6nlzPqD9JhDr1iyUQ0cs,10377
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.1.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
27
- cardio-2025.10.1.dist-info/entry_points.txt,sha256=xRd1otqKtW9xmidJ4CFRX1V9KWmsBbtxrgMtDColq3w,40
28
- cardio-2025.10.1.dist-info/METADATA,sha256=0GkZB7yixZKd4nFaAHgWtgCA6VxlV_pbUYdHokFROD4,3540
29
- cardio-2025.10.1.dist-info/RECORD,,