cardio 2025.10.0__tar.gz → 2025.12.0__tar.gz

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.
Files changed (29) hide show
  1. {cardio-2025.10.0 → cardio-2025.12.0}/PKG-INFO +7 -8
  2. {cardio-2025.10.0 → cardio-2025.12.0}/README.md +6 -7
  3. {cardio-2025.10.0 → cardio-2025.12.0}/pyproject.toml +2 -2
  4. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/__init__.py +1 -1
  5. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/app.py +0 -2
  6. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/blend_transfer_functions.py +0 -3
  7. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/color_transfer_function.py +0 -2
  8. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/logic.py +131 -83
  9. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/mesh.py +1 -4
  10. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/object.py +0 -3
  11. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/orientation.py +1 -3
  12. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/piecewise_function.py +0 -2
  13. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/property_config.py +0 -3
  14. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/scene.py +18 -15
  15. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/segmentation.py +2 -5
  16. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/transfer_function_pair.py +0 -2
  17. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/types.py +0 -2
  18. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/ui.py +236 -90
  19. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume.py +162 -45
  20. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume_property.py +0 -2
  21. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume_property_presets.py +0 -3
  22. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/window_level.py +0 -2
  23. {cardio-2025.10.0 → cardio-2025.12.0}/LICENSE +0 -0
  24. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/bone.toml +0 -0
  25. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/vascular_closed.toml +0 -0
  26. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/vascular_open.toml +0 -0
  27. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/xray.toml +0 -0
  28. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/screenshot.py +0 -0
  29. {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/utils.py +0 -0
@@ -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
@@ -3,12 +3,11 @@
3
3
  `cardio` is a simple web-based viewer for 3D and 4D ('cine') medical imaging data,
4
4
  built primarily on [trame](https://github.com/kitware/trame),
5
5
  [vtk](https://github.com/kitware/vtk), and
6
- [itk](https://github.com/insightsoftwareconsortium/itk). `cardio` is able to
7
- render sequences of mesh files (e.g., `\*.obj` files), segmentation files (e.g.
8
- `\*nii.gz` files with discrete labels) and volume renderings of grayscale images
9
- (e.g. \*.nii.gz files with continuous values). `cardio` is launched from the
10
- commandline and may be configured either directly from the commandline, via a static
11
- TOML configuration file, or a combination of the two.
6
+ [itk](https://github.com/insightsoftwareconsortium/itk). `cardio` can render sequences
7
+ of mesh files (e.g., `\*.obj` files), segmentation files (e.g., `\*nii.gz` files with
8
+ discrete labels) and volume renderings of grayscale images (e.g., `\*.nii.gz` files with
9
+ continuous values). `cardio` is launched from the commandline and may be configured via
10
+ commandline arguments, a static TOML configuration file, or a combination of the two.
12
11
 
13
12
  ## Quickstart
14
13
 
@@ -20,7 +19,7 @@ $ uv init
20
19
  $ uv add cardio
21
20
  $ . ./.venv/bin/activate
22
21
  (project) cardio --version
23
- cardio 2025.10.0
22
+ cardio 2025.12.0
24
23
  ```
25
24
 
26
25
  ### Developing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "cardio"
7
- version = "2025.10.0"
7
+ version = "2025.12.0"
8
8
  authors = [
9
9
  {name = "Davis Marc Vigneault", email = "davis.vigneault@gmail.com"}
10
10
  ]
@@ -62,7 +62,7 @@ repository = "https://github.com/sudomakeinstall/cardio"
62
62
  profile = "black"
63
63
 
64
64
  [tool.bumpver]
65
- current_version = "2025.10.0"
65
+ current_version = "2025.12.0"
66
66
  version_pattern = "YYYY.MM.INC0"
67
67
  commit_message = "ENH: Bump version from {old_version} => {new_version}"
68
68
  commit = true
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2025.10.0"
29
+ __version__ = "2025.12.0"
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env python
2
2
 
3
- # Third Party
4
3
  import pydantic_settings as ps
5
4
  import trame as tm
6
5
  import trame.decorators
7
6
 
8
- # Internal
9
7
  from . import __version__
10
8
  from .logic import Logic
11
9
  from .scene import Scene
@@ -1,7 +1,4 @@
1
- # System
2
1
  import numpy as np
3
-
4
- # Third Party
5
2
  import vtk
6
3
 
7
4
 
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .types import RGBColor
7
5
 
8
6
 
@@ -8,6 +8,18 @@ from .screenshot import Screenshot
8
8
 
9
9
 
10
10
  class Logic:
11
+ def _get_visible_rotation_data(self):
12
+ """Get rotation sequence and angles for visible rotations only."""
13
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
14
+ rotation_angles = {}
15
+ for i in range(len(rotation_sequence)):
16
+ is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
17
+ if is_visible:
18
+ rotation_angles[i] = getattr(
19
+ self.server.state, f"mpr_rotation_angle_{i}", 0
20
+ )
21
+ return rotation_sequence, rotation_angles
22
+
11
23
  def __init__(self, server, scene: Scene):
12
24
  self.server = server
13
25
  self.scene = scene
@@ -27,18 +39,17 @@ class Logic:
27
39
  {"text": "Radians", "value": "radians"},
28
40
  ]
29
41
 
30
- # Initialize slice bounds (will be updated when active volume changes)
31
- self.server.state.axial_slice_bounds = [0.0, 100.0]
32
- self.server.state.sagittal_slice_bounds = [0.0, 100.0]
33
- self.server.state.coronal_slice_bounds = [0.0, 100.0]
42
+ # Initialize MPR origin (will be updated when active volume changes)
43
+ self.server.state.mpr_origin = [0.0, 0.0, 0.0]
44
+ self.server.state.mpr_crosshairs_enabled = self.scene.mpr_crosshairs_enabled
34
45
 
35
46
  self.server.state.change("frame")(self.update_frame)
36
47
  self.server.state.change("playing")(self.play)
37
48
  self.server.state.change("theme_mode")(self.sync_background_color)
38
- self.server.state.change("mpr_enabled")(self.sync_mpr_mode)
39
49
  self.server.state.change("active_volume_label")(self.sync_active_volume)
40
- self.server.state.change("axial_slice", "sagittal_slice", "coronal_slice")(
41
- self.update_slice_positions
50
+ self.server.state.change("mpr_origin")(self.update_slice_positions)
51
+ self.server.state.change("mpr_crosshairs_enabled")(
52
+ self.sync_crosshairs_visibility
42
53
  )
43
54
  self.server.state.change("mpr_window", "mpr_level")(
44
55
  self.update_mpr_window_level
@@ -140,6 +151,9 @@ class Logic:
140
151
  self.server.controller.save_rotation_angles = self.save_rotation_angles
141
152
  self.server.controller.reset_all = self.reset_all
142
153
  self.server.controller.close_application = self.close_application
154
+ self.server.controller.finalize_mpr_initialization = (
155
+ self.finalize_mpr_initialization
156
+ )
143
157
 
144
158
  # MPR rotation controllers
145
159
  self.server.controller.add_x_rotation = lambda: self.add_mpr_rotation("X")
@@ -150,10 +164,15 @@ class Logic:
150
164
 
151
165
  # Initialize MPR state
152
166
  self.server.state.mpr_enabled = self.scene.mpr_enabled
153
- self.server.state.active_volume_label = self.scene.active_volume_label
154
- self.server.state.axial_slice = self.scene.axial_slice
155
- self.server.state.sagittal_slice = self.scene.sagittal_slice
156
- self.server.state.coronal_slice = self.scene.coronal_slice
167
+ # Initialize active_volume_label to empty string (actual value set after UI ready)
168
+ self.server.state.active_volume_label = ""
169
+ # Store the actual volume label to set later, avoiding race condition
170
+ self._pending_active_volume = (
171
+ self.scene.volumes[0].label
172
+ if self.scene.volumes and not self.scene.active_volume_label
173
+ else self.scene.active_volume_label
174
+ )
175
+ self.server.state.mpr_origin = list(self.scene.mpr_origin)
157
176
  self.server.state.mpr_window = self.scene.mpr_window
158
177
  self.server.state.mpr_level = self.scene.mpr_level
159
178
  self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
@@ -236,6 +255,10 @@ class Logic:
236
255
  # Get or create MPR actors for the new frame
237
256
  mpr_actors = active_volume.get_mpr_actors_for_frame(frame)
238
257
 
258
+ # Get crosshair visibility state
259
+ crosshairs_visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
260
+ crosshairs = active_volume.crosshair_actors
261
+
239
262
  # Update each MPR renderer with the new frame's actors
240
263
  if self.scene.axial_renderWindow:
241
264
  axial_renderer = (
@@ -245,8 +268,11 @@ class Logic:
245
268
  axial_renderer.RemoveAllViewProps()
246
269
  axial_renderer.AddActor(mpr_actors["axial"]["actor"])
247
270
  mpr_actors["axial"]["actor"].SetVisibility(True)
248
- # Apply current slice position and window/level
249
- self._apply_current_mpr_settings(active_volume, frame)
271
+ # Re-add crosshairs
272
+ if crosshairs and "axial" in crosshairs:
273
+ for line_data in crosshairs["axial"].values():
274
+ axial_renderer.AddActor2D(line_data["actor"])
275
+ line_data["actor"].SetVisibility(crosshairs_visible)
250
276
  axial_renderer.ResetCamera()
251
277
 
252
278
  if self.scene.coronal_renderWindow:
@@ -257,6 +283,11 @@ class Logic:
257
283
  coronal_renderer.RemoveAllViewProps()
258
284
  coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
259
285
  mpr_actors["coronal"]["actor"].SetVisibility(True)
286
+ # Re-add crosshairs
287
+ if crosshairs and "coronal" in crosshairs:
288
+ for line_data in crosshairs["coronal"].values():
289
+ coronal_renderer.AddActor2D(line_data["actor"])
290
+ line_data["actor"].SetVisibility(crosshairs_visible)
260
291
  coronal_renderer.ResetCamera()
261
292
 
262
293
  if self.scene.sagittal_renderWindow:
@@ -267,28 +298,25 @@ class Logic:
267
298
  sagittal_renderer.RemoveAllViewProps()
268
299
  sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
269
300
  mpr_actors["sagittal"]["actor"].SetVisibility(True)
301
+ # Re-add crosshairs
302
+ if crosshairs and "sagittal" in crosshairs:
303
+ for line_data in crosshairs["sagittal"].values():
304
+ sagittal_renderer.AddActor2D(line_data["actor"])
305
+ line_data["actor"].SetVisibility(crosshairs_visible)
270
306
  sagittal_renderer.ResetCamera()
271
307
 
308
+ # Apply current slice position and window/level to all views
309
+ self._apply_current_mpr_settings(active_volume, frame)
310
+
272
311
  def _apply_current_mpr_settings(self, active_volume, frame):
273
312
  """Apply current slice positions and window/level to MPR actors."""
274
313
  # Apply slice positions
275
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
276
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
277
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
278
-
279
- # Get rotation data
280
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
281
- rotation_angles = {}
282
- for i in range(len(rotation_sequence)):
283
- rotation_angles[i] = getattr(
284
- self.server.state, f"mpr_rotation_angle_{i}", 0
285
- )
314
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
315
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
286
316
 
287
317
  active_volume.update_slice_positions(
288
318
  frame,
289
- axial_slice,
290
- sagittal_slice,
291
- coronal_slice,
319
+ origin,
292
320
  rotation_sequence,
293
321
  rotation_angles,
294
322
  )
@@ -491,12 +519,13 @@ class Logic:
491
519
  metadata["volume_label"] = active_volume_label
492
520
  doc["metadata"] = metadata
493
521
 
494
- # Slice positions section
495
- slice_positions = tk.table()
496
- slice_positions["axial"] = getattr(self.server.state, "axial_slice", 0.5)
497
- slice_positions["sagittal"] = getattr(self.server.state, "sagittal_slice", 0.5)
498
- slice_positions["coronal"] = getattr(self.server.state, "coronal_slice", 0.5)
499
- doc["slice_positions"] = slice_positions
522
+ # Origin position section
523
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
524
+ origin_table = tk.table()
525
+ origin_table["x"] = origin[0]
526
+ origin_table["y"] = origin[1]
527
+ origin_table["z"] = origin[2]
528
+ doc["origin"] = origin_table
500
529
 
501
530
  # Rotations section (array of tables)
502
531
  rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
@@ -633,32 +662,32 @@ class Logic:
633
662
  if not active_volume:
634
663
  return
635
664
 
636
- # Update slice bounds based on active volume
665
+ # Initialize origin to volume center (in LPS coordinates)
637
666
  try:
638
- bounds = active_volume.get_physical_bounds()
639
- self.server.state.axial_slice_bounds = [bounds[4], bounds[5]] # Z bounds
640
- self.server.state.sagittal_slice_bounds = [bounds[0], bounds[1]] # X bounds
641
- self.server.state.coronal_slice_bounds = [bounds[2], bounds[3]] # Y bounds
642
-
643
- # Initialize slice positions to volume center if they are currently 0.0 (scene defaults)
644
- if self.server.state.axial_slice == 0.0:
645
- self.server.state.axial_slice = (bounds[4] + bounds[5]) / 2 # Z center
646
- if self.server.state.sagittal_slice == 0.0:
647
- self.server.state.sagittal_slice = (
648
- bounds[0] + bounds[1]
649
- ) / 2 # X center
650
- if self.server.state.coronal_slice == 0.0:
651
- self.server.state.coronal_slice = (
652
- bounds[2] + bounds[3]
653
- ) / 2 # Y center
667
+ current_frame = getattr(self.server.state, "frame", 0)
668
+ volume_actor = active_volume.actors[current_frame]
669
+ image_data = volume_actor.GetMapper().GetInput()
670
+ center = image_data.GetCenter()
671
+
672
+ # Set origin to volume center if it's at default [0,0,0]
673
+ current_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
674
+ if current_origin == [0.0, 0.0, 0.0]:
675
+ self.server.state.mpr_origin = list(center)
654
676
  except (RuntimeError, IndexError) as e:
655
- print(f"Error: Cannot get bounds for volume '{active_volume_label}': {e}")
677
+ print(f"Error: Cannot get center for volume '{active_volume_label}': {e}")
656
678
  return
657
679
 
658
680
  # Create MPR actors for current frame
659
681
  current_frame = getattr(self.server.state, "frame", 0)
660
682
  mpr_actors = active_volume.get_mpr_actors_for_frame(current_frame)
661
683
 
684
+ # Create crosshair actors
685
+ crosshairs = active_volume.create_crosshair_actors(
686
+ colors=self.scene.mpr_crosshair_colors,
687
+ line_width=self.scene.mpr_crosshair_width,
688
+ )
689
+ crosshairs_visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
690
+
662
691
  # Add MPR actors to their respective renderers
663
692
  if self.scene.axial_renderWindow:
664
693
  axial_renderer = (
@@ -668,6 +697,10 @@ class Logic:
668
697
  axial_renderer.RemoveAllViewProps() # Clear existing actors
669
698
  axial_renderer.AddActor(mpr_actors["axial"]["actor"])
670
699
  mpr_actors["axial"]["actor"].SetVisibility(True)
700
+ # Add 2D crosshair overlay actors
701
+ for line_data in crosshairs["axial"].values():
702
+ axial_renderer.AddActor2D(line_data["actor"])
703
+ line_data["actor"].SetVisibility(crosshairs_visible)
671
704
  axial_renderer.ResetCamera()
672
705
 
673
706
  if self.scene.coronal_renderWindow:
@@ -678,6 +711,10 @@ class Logic:
678
711
  coronal_renderer.RemoveAllViewProps()
679
712
  coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
680
713
  mpr_actors["coronal"]["actor"].SetVisibility(True)
714
+ # Add 2D crosshair overlay actors
715
+ for line_data in crosshairs["coronal"].values():
716
+ coronal_renderer.AddActor2D(line_data["actor"])
717
+ line_data["actor"].SetVisibility(crosshairs_visible)
681
718
  coronal_renderer.ResetCamera()
682
719
 
683
720
  if self.scene.sagittal_renderWindow:
@@ -688,6 +725,10 @@ class Logic:
688
725
  sagittal_renderer.RemoveAllViewProps()
689
726
  sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
690
727
  mpr_actors["sagittal"]["actor"].SetVisibility(True)
728
+ # Add 2D crosshair overlay actors
729
+ for line_data in crosshairs["sagittal"].values():
730
+ sagittal_renderer.AddActor2D(line_data["actor"])
731
+ line_data["actor"].SetVisibility(crosshairs_visible)
691
732
  sagittal_renderer.ResetCamera()
692
733
 
693
734
  # Apply current window/level settings to the MPR actors
@@ -718,26 +759,16 @@ class Logic:
718
759
  if not active_volume:
719
760
  return
720
761
 
721
- # Get current slice positions
722
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
723
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
724
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
762
+ # Get current origin and frame
763
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
725
764
  current_frame = getattr(self.server.state, "frame", 0)
726
765
 
727
- # Get rotation data
728
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
729
- rotation_angles = {}
730
- for i in range(len(rotation_sequence)):
731
- rotation_angles[i] = getattr(
732
- self.server.state, f"mpr_rotation_angle_{i}", 0
733
- )
766
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
734
767
 
735
768
  # Update slice positions with rotation
736
769
  active_volume.update_slice_positions(
737
770
  current_frame,
738
- axial_slice,
739
- sagittal_slice,
740
- coronal_slice,
771
+ origin,
741
772
  rotation_sequence,
742
773
  rotation_angles,
743
774
  )
@@ -826,30 +857,16 @@ class Logic:
826
857
  if not active_volume:
827
858
  return
828
859
 
829
- # Get current slice positions
830
- axial_slice = getattr(self.server.state, "axial_slice", 0.5)
831
- sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
832
- coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
860
+ # Get current origin and frame
861
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
833
862
  current_frame = getattr(self.server.state, "frame", 0)
834
863
 
835
- # Get rotation data - include all visible rotations
836
- rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
837
- rotation_angles = {}
838
-
839
- # Include all visible rotations regardless of position
840
- for i in range(len(rotation_sequence)):
841
- is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
842
- if is_visible:
843
- rotation_angles[i] = getattr(
844
- self.server.state, f"mpr_rotation_angle_{i}", 0
845
- )
864
+ rotation_sequence, rotation_angles = self._get_visible_rotation_data()
846
865
 
847
866
  # Update slice positions with rotation
848
867
  active_volume.update_slice_positions(
849
868
  current_frame,
850
- axial_slice,
851
- sagittal_slice,
852
- coronal_slice,
869
+ origin,
853
870
  rotation_sequence,
854
871
  rotation_angles,
855
872
  )
@@ -857,6 +874,29 @@ class Logic:
857
874
  # Update all views
858
875
  self.server.controller.view_update()
859
876
 
877
+ def sync_crosshairs_visibility(self, **kwargs):
878
+ """Toggle crosshair visibility on all MPR views."""
879
+ if not getattr(self.server.state, "mpr_enabled", False):
880
+ return
881
+
882
+ active_volume_label = getattr(self.server.state, "active_volume_label", "")
883
+ if not active_volume_label:
884
+ return
885
+
886
+ # Find the active volume
887
+ active_volume = None
888
+ for volume in self.scene.volumes:
889
+ if volume.label == active_volume_label:
890
+ active_volume = volume
891
+ break
892
+
893
+ if not active_volume:
894
+ return
895
+
896
+ visible = getattr(self.server.state, "mpr_crosshairs_enabled", True)
897
+ active_volume.set_crosshairs_visible(visible)
898
+ self.server.controller.view_update()
899
+
860
900
  def add_mpr_rotation(self, axis):
861
901
  """Add a new rotation to the MPR rotation sequence."""
862
902
  import copy
@@ -902,6 +942,14 @@ class Logic:
902
942
  for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
903
943
  setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
904
944
 
945
+ def finalize_mpr_initialization(self, **kwargs):
946
+ """Set the active volume label after UI is ready to avoid race condition."""
947
+ if hasattr(self, "_pending_active_volume") and self._pending_active_volume:
948
+ self.server.state.active_volume_label = self._pending_active_volume
949
+ # Manually trigger sync_active_volume since state change may not fire during on_server_ready
950
+ self.sync_active_volume(self._pending_active_volume)
951
+ delattr(self, "_pending_active_volume")
952
+
905
953
  @asynchronous.task
906
954
  async def close_application(self):
907
955
  """Close the application by stopping the server."""
@@ -1,12 +1,9 @@
1
- # System
2
1
  import enum
3
2
  import logging
4
3
 
5
- # Third Party
6
4
  import pydantic as pc
7
5
  import vtk
8
6
 
9
- # Internal
10
7
  from .object import Object
11
8
  from .property_config import Representation, vtkPropertyConfig
12
9
 
@@ -49,7 +46,7 @@ class Mesh(Object):
49
46
  """Mesh object with subdivision support."""
50
47
 
51
48
  pattern: str = pc.Field(
52
- default="${frame}.obj", description="Filename pattern with $frame placeholder"
49
+ default="{frame}.obj", description="Filename pattern with $frame placeholder"
53
50
  )
54
51
  _actors: list[vtk.vtkActor] = pc.PrivateAttr(default_factory=list)
55
52
  properties: vtkPropertyConfig = pc.Field(
@@ -1,10 +1,8 @@
1
- # System
2
1
  import functools
3
2
  import logging
4
3
  import pathlib as pl
5
4
  import re
6
5
 
7
- # Third Party
8
6
  import pydantic as pc
9
7
  import vtk
10
8
 
@@ -80,7 +78,6 @@ class Object(pc.BaseModel):
80
78
  if self.pattern is None:
81
79
  raise ValueError("Cannot use path_for_frame with static file_paths")
82
80
  filename = self.pattern.format(frame=frame)
83
- print(filename)
84
81
  return self.directory / filename
85
82
 
86
83
  @functools.cached_property
@@ -1,9 +1,7 @@
1
- # System
2
1
  from enum import Enum
3
2
 
4
- # Third Party
5
- import numpy as np
6
3
  import itk
4
+ import numpy as np
7
5
 
8
6
 
9
7
  # DICOM LPS canonical orientation vector mappings
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .types import ScalarComponent
7
5
 
8
6
 
@@ -1,12 +1,9 @@
1
- # System
2
1
  import enum
3
2
  import functools
4
3
 
5
- # Third Party
6
4
  import pydantic as pc
7
5
  import vtk
8
6
 
9
- # Internal
10
7
  from .types import RGBColor, ScalarComponent
11
8
 
12
9
 
@@ -1,15 +1,12 @@
1
- # System
2
1
  import logging
3
2
  import pathlib as pl
4
3
  import typing as ty
5
4
 
6
- # Third Party
7
5
  import numpy as np
8
6
  import pydantic as pc
9
7
  import pydantic_settings as ps
10
8
  import vtk
11
9
 
12
- # Internal
13
10
  from .mesh import Mesh
14
11
  from .segmentation import Segmentation
15
12
  from .types import RGBColor
@@ -107,24 +104,16 @@ class Scene(ps.BaseSettings):
107
104
  volumes: VolumeList = pc.Field(default_factory=list)
108
105
  segmentations: SegmentationList = pc.Field(default_factory=list)
109
106
  mpr_enabled: bool = pc.Field(
110
- default=False,
107
+ default=True,
111
108
  description="Enable multi-planar reconstruction (MPR) mode with quad-view layout",
112
109
  )
113
110
  active_volume_label: str = pc.Field(
114
111
  default="",
115
112
  description="Label of the volume to use for multi-planar reconstruction",
116
113
  )
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)",
114
+ mpr_origin: list = pc.Field(
115
+ default_factory=lambda: [0.0, 0.0, 0.0],
116
+ description="MPR origin position [x, y, z] in LPS coordinates",
128
117
  )
129
118
  mpr_window: float = pc.Field(
130
119
  default=800.0, description="Window width for MPR image display"
@@ -150,6 +139,20 @@ class Scene(ps.BaseSettings):
150
139
  coordinate_system: str = pc.Field(
151
140
  default="LAS", description="Coordinate system orientation (e.g., LAS, RAS, LPS)"
152
141
  )
142
+ mpr_crosshairs_enabled: bool = pc.Field(
143
+ default=False, description="Show crosshair lines indicating slice intersections"
144
+ )
145
+ mpr_crosshair_colors: dict = pc.Field(
146
+ default_factory=lambda: {
147
+ "axial": (0.0, 0.5, 1.0),
148
+ "sagittal": (1.0, 0.3, 0.3),
149
+ "coronal": (0.3, 1.0, 0.3),
150
+ },
151
+ description="RGB colors for crosshair lines (keyed by view name)",
152
+ )
153
+ mpr_crosshair_width: float = pc.Field(
154
+ default=1.5, description="Line width for crosshair lines"
155
+ )
153
156
 
154
157
  # Field validators for JSON string inputs
155
158
  @pc.field_validator("meshes", mode="before")
@@ -1,17 +1,14 @@
1
- # System
2
1
  import logging
3
2
 
4
- # Third Party
5
3
  import itk
6
4
  import numpy as np
7
5
  import pydantic as pc
8
6
  import vtk
9
7
 
10
- # Internal
8
+ from .object import Object
11
9
  from .orientation import (
12
10
  reset_direction,
13
11
  )
14
- from .object import Object
15
12
  from .property_config import vtkPropertyConfig
16
13
 
17
14
 
@@ -19,7 +16,7 @@ class Segmentation(Object):
19
16
  """Segmentation object with multi-label mesh extraction using SurfaceNets."""
20
17
 
21
18
  pattern: str = pc.Field(
22
- default="${frame}.nii.gz",
19
+ default="{frame}.nii.gz",
23
20
  description="Filename pattern with $frame placeholder",
24
21
  )
25
22
  _actors: list[vtk.vtkActor] = pc.PrivateAttr(default_factory=list)
@@ -1,8 +1,6 @@
1
- # Third Party
2
1
  import pydantic as pc
3
2
  import vtk
4
3
 
5
- # Internal
6
4
  from .color_transfer_function import ColorTransferFunctionConfig
7
5
  from .piecewise_function import PiecewiseFunctionConfig
8
6