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.
- {cardio-2025.10.0 → cardio-2025.12.0}/PKG-INFO +7 -8
- {cardio-2025.10.0 → cardio-2025.12.0}/README.md +6 -7
- {cardio-2025.10.0 → cardio-2025.12.0}/pyproject.toml +2 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/__init__.py +1 -1
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/app.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/blend_transfer_functions.py +0 -3
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/color_transfer_function.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/logic.py +131 -83
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/mesh.py +1 -4
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/object.py +0 -3
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/orientation.py +1 -3
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/piecewise_function.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/property_config.py +0 -3
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/scene.py +18 -15
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/segmentation.py +2 -5
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/transfer_function_pair.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/types.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/ui.py +236 -90
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume.py +162 -45
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume_property.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/volume_property_presets.py +0 -3
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/window_level.py +0 -2
- {cardio-2025.10.0 → cardio-2025.12.0}/LICENSE +0 -0
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/bone.toml +0 -0
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/vascular_closed.toml +0 -0
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/vascular_open.toml +0 -0
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/assets/xray.toml +0 -0
- {cardio-2025.10.0 → cardio-2025.12.0}/src/cardio/screenshot.py +0 -0
- {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.
|
|
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`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
commandline
|
|
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.
|
|
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`
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
commandline
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
31
|
-
self.server.state.
|
|
32
|
-
self.server.state.
|
|
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("
|
|
41
|
-
|
|
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
|
-
|
|
154
|
-
self.server.state.
|
|
155
|
-
|
|
156
|
-
self.
|
|
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
|
-
#
|
|
249
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
#
|
|
665
|
+
# Initialize origin to volume center (in LPS coordinates)
|
|
637
666
|
try:
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
#
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
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
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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,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=
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
description="
|
|
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
|
-
|
|
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="
|
|
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)
|