cardio 2025.12.0__tar.gz → 2026.1.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.12.0 → cardio-2026.1.0}/PKG-INFO +2 -2
- {cardio-2025.12.0 → cardio-2026.1.0}/README.md +1 -1
- {cardio-2025.12.0 → cardio-2026.1.0}/pyproject.toml +2 -2
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/__init__.py +1 -1
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/logic.py +137 -103
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/orientation.py +5 -0
- cardio-2026.1.0/src/cardio/rotation.py +211 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/scene.py +34 -8
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/ui.py +137 -106
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/utils.py +0 -7
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume.py +21 -8
- {cardio-2025.12.0 → cardio-2026.1.0}/LICENSE +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/app.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/bone.toml +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/vascular_closed.toml +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/vascular_open.toml +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/assets/xray.toml +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/blend_transfer_functions.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/color_transfer_function.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/mesh.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/object.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/piecewise_function.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/property_config.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/screenshot.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/segmentation.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/transfer_function_pair.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/types.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume_property.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/volume_property_presets.py +0 -0
- {cardio-2025.12.0 → cardio-2026.1.0}/src/cardio/window_level.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cardio
|
|
3
|
-
Version:
|
|
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
|
|
@@ -75,7 +75,7 @@ $ uv init
|
|
|
75
75
|
$ uv add cardio
|
|
76
76
|
$ . ./.venv/bin/activate
|
|
77
77
|
(project) cardio --version
|
|
78
|
-
cardio
|
|
78
|
+
cardio 2026.1.0
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
### Developing
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cardio"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2026.1.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 = "
|
|
65
|
+
current_version = "2026.1.0"
|
|
66
66
|
version_pattern = "YYYY.MM.INC0"
|
|
67
67
|
commit_message = "ENH: Bump version from {old_version} => {new_version}"
|
|
68
68
|
commit = true
|
|
@@ -10,14 +10,22 @@ from .screenshot import Screenshot
|
|
|
10
10
|
class Logic:
|
|
11
11
|
def _get_visible_rotation_data(self):
|
|
12
12
|
"""Get rotation sequence and angles for visible rotations only."""
|
|
13
|
-
|
|
13
|
+
rotation_data = getattr(
|
|
14
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
15
|
+
)
|
|
16
|
+
angles_list = rotation_data.get("angles_list", [])
|
|
17
|
+
|
|
18
|
+
# Build rotation_sequence (list of {"axis": ...}) for visible rotations
|
|
19
|
+
rotation_sequence = []
|
|
14
20
|
rotation_angles = {}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
visible_index = 0
|
|
23
|
+
for rotation in angles_list:
|
|
24
|
+
if rotation.get("visible", True):
|
|
25
|
+
rotation_sequence.append({"axis": rotation["axes"]})
|
|
26
|
+
rotation_angles[visible_index] = rotation["angles"][0]
|
|
27
|
+
visible_index += 1
|
|
28
|
+
|
|
21
29
|
return rotation_sequence, rotation_angles
|
|
22
30
|
|
|
23
31
|
def __init__(self, server, scene: Scene):
|
|
@@ -39,6 +47,12 @@ class Logic:
|
|
|
39
47
|
{"text": "Radians", "value": "radians"},
|
|
40
48
|
]
|
|
41
49
|
|
|
50
|
+
# Initialize axis convention items for dropdown
|
|
51
|
+
self.server.state.axis_convention_items = [
|
|
52
|
+
{"text": "ITK (X=L, Y=P, Z=S)", "value": "itk"},
|
|
53
|
+
{"text": "Roma (X=S, Y=P, Z=L)", "value": "roma"},
|
|
54
|
+
]
|
|
55
|
+
|
|
42
56
|
# Initialize MPR origin (will be updated when active volume changes)
|
|
43
57
|
self.server.state.mpr_origin = [0.0, 0.0, 0.0]
|
|
44
58
|
self.server.state.mpr_crosshairs_enabled = self.scene.mpr_crosshairs_enabled
|
|
@@ -55,17 +69,9 @@ class Logic:
|
|
|
55
69
|
self.update_mpr_window_level
|
|
56
70
|
)
|
|
57
71
|
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
|
58
|
-
self.server.state.change("
|
|
72
|
+
self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
|
|
59
73
|
self.server.state.change("angle_units")(self.sync_angle_units)
|
|
60
|
-
|
|
61
|
-
# Add handlers for individual rotation angles and visibility
|
|
62
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
63
|
-
self.server.state.change(f"mpr_rotation_angle_{i}")(
|
|
64
|
-
self.update_mpr_rotation
|
|
65
|
-
)
|
|
66
|
-
self.server.state.change(f"mpr_rotation_visible_{i}")(
|
|
67
|
-
self.update_mpr_rotation
|
|
68
|
-
)
|
|
74
|
+
self.server.state.change("axis_convention")(self.sync_axis_convention)
|
|
69
75
|
|
|
70
76
|
# Initialize visibility state variables
|
|
71
77
|
for m in self.scene.meshes:
|
|
@@ -160,6 +166,7 @@ class Logic:
|
|
|
160
166
|
self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
|
|
161
167
|
self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
|
|
162
168
|
self.server.controller.remove_rotation_event = self.remove_mpr_rotation
|
|
169
|
+
self.server.controller.reset_rotation_angle = self.reset_rotation_angle
|
|
163
170
|
self.server.controller.reset_rotations = self.reset_mpr_rotations
|
|
164
171
|
|
|
165
172
|
# Initialize MPR state
|
|
@@ -176,8 +183,14 @@ class Logic:
|
|
|
176
183
|
self.server.state.mpr_window = self.scene.mpr_window
|
|
177
184
|
self.server.state.mpr_level = self.scene.mpr_level
|
|
178
185
|
self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
|
|
179
|
-
|
|
186
|
+
|
|
187
|
+
# Initialize rotation data from RotationSequence
|
|
188
|
+
self.server.state.mpr_rotation_data = (
|
|
189
|
+
self.scene.mpr_rotation_sequence.to_dict_for_ui()
|
|
190
|
+
)
|
|
191
|
+
|
|
180
192
|
self.server.state.angle_units = self.scene.angle_units.value
|
|
193
|
+
self.server.state.axis_convention = self.scene.axis_convention.value
|
|
181
194
|
|
|
182
195
|
# Initialize MPR presets data
|
|
183
196
|
try:
|
|
@@ -190,12 +203,6 @@ class Logic:
|
|
|
190
203
|
print(f"Error initializing MPR presets: {e}")
|
|
191
204
|
self.server.state.mpr_presets = []
|
|
192
205
|
|
|
193
|
-
# Initialize rotation angle states
|
|
194
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
195
|
-
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
196
|
-
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
197
|
-
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
198
|
-
|
|
199
206
|
# Apply initial preset to ensure window/level values are set correctly
|
|
200
207
|
# Only update state values, don't call update methods yet since MPR may not be enabled
|
|
201
208
|
from .window_level import presets
|
|
@@ -319,6 +326,7 @@ class Logic:
|
|
|
319
326
|
origin,
|
|
320
327
|
rotation_sequence,
|
|
321
328
|
rotation_angles,
|
|
329
|
+
self.scene.angle_units,
|
|
322
330
|
)
|
|
323
331
|
|
|
324
332
|
# Apply window/level
|
|
@@ -490,63 +498,35 @@ class Logic:
|
|
|
490
498
|
|
|
491
499
|
@asynchronous.task
|
|
492
500
|
async def save_rotation_angles(self):
|
|
493
|
-
"""Save current rotation angles to
|
|
494
|
-
|
|
501
|
+
"""Save current rotation angles to TOML file."""
|
|
502
|
+
from .rotation import RotationSequence
|
|
495
503
|
|
|
496
|
-
# Get current timestamp
|
|
497
504
|
timestamp = dt.datetime.now()
|
|
498
505
|
timestamp_str = timestamp.strftime(self.scene.timestamp_format)
|
|
499
|
-
iso_timestamp = timestamp.isoformat()
|
|
500
|
-
|
|
501
|
-
# Get active volume label
|
|
502
506
|
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
|
507
|
+
|
|
503
508
|
if not active_volume_label:
|
|
504
|
-
print("Warning: No active volume selected
|
|
509
|
+
print("Warning: No active volume selected")
|
|
505
510
|
return
|
|
506
511
|
|
|
507
|
-
# Create directory structure
|
|
508
512
|
save_dir = self.scene.rotations_directory / active_volume_label
|
|
509
513
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
510
514
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
metadata = tk.table()
|
|
516
|
-
metadata["coordinate_system"] = self.scene.coordinate_system
|
|
517
|
-
metadata["units"] = self.scene.angle_units.value
|
|
518
|
-
metadata["timestamp"] = iso_timestamp
|
|
519
|
-
metadata["volume_label"] = active_volume_label
|
|
520
|
-
doc["metadata"] = metadata
|
|
521
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
# Rotations section (array of tables)
|
|
531
|
-
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
532
|
-
rotations_array = tk.aot()
|
|
533
|
-
|
|
534
|
-
for i, rotation_def in enumerate(rotation_sequence):
|
|
535
|
-
rotation = tk.table()
|
|
536
|
-
rotation["axis"] = rotation_def["axis"]
|
|
537
|
-
angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
538
|
-
rotation["angle"] = float(angle)
|
|
539
|
-
rotation["visible"] = getattr(
|
|
540
|
-
self.server.state, f"mpr_rotation_visible_{i}", True
|
|
541
|
-
)
|
|
542
|
-
rotations_array.append(rotation)
|
|
515
|
+
rotation_data = getattr(
|
|
516
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
517
|
+
)
|
|
518
|
+
rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
|
|
543
519
|
|
|
544
|
-
|
|
520
|
+
rotation_seq.metadata.timestamp = timestamp.isoformat()
|
|
521
|
+
rotation_seq.metadata.volume_label = active_volume_label
|
|
522
|
+
rotation_seq.metadata.coordinate_system = self.scene.coordinate_system
|
|
545
523
|
|
|
546
|
-
# Save to file
|
|
547
524
|
output_path = save_dir / f"{timestamp_str}.toml"
|
|
548
|
-
|
|
549
|
-
|
|
525
|
+
rotation_seq.to_file(
|
|
526
|
+
output_path,
|
|
527
|
+
target_convention=self.scene.axis_convention,
|
|
528
|
+
target_units=self.scene.angle_units,
|
|
529
|
+
)
|
|
550
530
|
|
|
551
531
|
def reset_all(self):
|
|
552
532
|
self.server.state.frame = 0
|
|
@@ -573,13 +553,55 @@ class Logic:
|
|
|
573
553
|
|
|
574
554
|
def sync_angle_units(self, angle_units, **kwargs):
|
|
575
555
|
"""Sync angle units selection - updates the scene configuration."""
|
|
576
|
-
|
|
556
|
+
import copy
|
|
557
|
+
|
|
558
|
+
import numpy as np
|
|
559
|
+
|
|
560
|
+
from .orientation import AngleUnits
|
|
561
|
+
|
|
562
|
+
# Get current units before changing
|
|
563
|
+
old_units = self.scene.angle_units
|
|
577
564
|
|
|
578
565
|
# Update the scene's angle_units field based on UI selection
|
|
566
|
+
new_units = None
|
|
579
567
|
if angle_units == "degrees":
|
|
580
|
-
|
|
568
|
+
new_units = AngleUnits.DEGREES
|
|
581
569
|
elif angle_units == "radians":
|
|
582
|
-
|
|
570
|
+
new_units = AngleUnits.RADIANS
|
|
571
|
+
|
|
572
|
+
if new_units is None or old_units == new_units:
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
# Convert all existing rotation angles
|
|
576
|
+
rotation_data = getattr(
|
|
577
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
578
|
+
)
|
|
579
|
+
if rotation_data.get("angles_list"):
|
|
580
|
+
updated_data = copy.deepcopy(rotation_data)
|
|
581
|
+
|
|
582
|
+
for rotation in updated_data["angles_list"]:
|
|
583
|
+
current_angle = rotation.get("angles", [0])[0]
|
|
584
|
+
|
|
585
|
+
# Convert based on old -> new units
|
|
586
|
+
if old_units == AngleUnits.DEGREES and new_units == AngleUnits.RADIANS:
|
|
587
|
+
rotation["angles"][0] = np.radians(current_angle)
|
|
588
|
+
elif (
|
|
589
|
+
old_units == AngleUnits.RADIANS and new_units == AngleUnits.DEGREES
|
|
590
|
+
):
|
|
591
|
+
rotation["angles"][0] = np.degrees(current_angle)
|
|
592
|
+
|
|
593
|
+
self.server.state.mpr_rotation_data = updated_data
|
|
594
|
+
|
|
595
|
+
self.scene.angle_units = new_units
|
|
596
|
+
|
|
597
|
+
def sync_axis_convention(self, axis_convention, **kwargs):
|
|
598
|
+
"""Sync axis convention selection - updates the scene configuration."""
|
|
599
|
+
from .orientation import AxisConvention
|
|
600
|
+
|
|
601
|
+
if axis_convention == "itk":
|
|
602
|
+
self.scene.axis_convention = AxisConvention.ITK
|
|
603
|
+
elif axis_convention == "roma":
|
|
604
|
+
self.scene.axis_convention = AxisConvention.ROMA
|
|
583
605
|
|
|
584
606
|
def _initialize_clipping_state(self):
|
|
585
607
|
"""Initialize clipping state variables for all objects."""
|
|
@@ -771,6 +793,7 @@ class Logic:
|
|
|
771
793
|
origin,
|
|
772
794
|
rotation_sequence,
|
|
773
795
|
rotation_angles,
|
|
796
|
+
self.scene.angle_units,
|
|
774
797
|
)
|
|
775
798
|
|
|
776
799
|
# Update all views
|
|
@@ -869,6 +892,7 @@ class Logic:
|
|
|
869
892
|
origin,
|
|
870
893
|
rotation_sequence,
|
|
871
894
|
rotation_angles,
|
|
895
|
+
self.scene.angle_units,
|
|
872
896
|
)
|
|
873
897
|
|
|
874
898
|
# Update all views
|
|
@@ -901,46 +925,56 @@ class Logic:
|
|
|
901
925
|
"""Add a new rotation to the MPR rotation sequence."""
|
|
902
926
|
import copy
|
|
903
927
|
|
|
904
|
-
|
|
905
|
-
getattr(self.server.state, "
|
|
928
|
+
current_data = copy.deepcopy(
|
|
929
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
930
|
+
)
|
|
931
|
+
angles_list = current_data["angles_list"]
|
|
932
|
+
new_index = len(angles_list)
|
|
933
|
+
|
|
934
|
+
angles_list.append(
|
|
935
|
+
{
|
|
936
|
+
"axes": axis,
|
|
937
|
+
"angles": [0],
|
|
938
|
+
"visible": True,
|
|
939
|
+
"name": "",
|
|
940
|
+
"name_editable": True,
|
|
941
|
+
"deletable": True,
|
|
942
|
+
}
|
|
906
943
|
)
|
|
907
|
-
|
|
908
|
-
self.server.state.
|
|
909
|
-
self.update_mpr_rotation_labels()
|
|
944
|
+
|
|
945
|
+
self.server.state.mpr_rotation_data = current_data
|
|
910
946
|
|
|
911
947
|
def remove_mpr_rotation(self, index):
|
|
912
|
-
"""Remove a rotation at given index
|
|
913
|
-
|
|
914
|
-
if 0 <= index < len(sequence):
|
|
915
|
-
sequence = sequence[:index]
|
|
916
|
-
self.server.state.mpr_rotation_sequence = sequence
|
|
948
|
+
"""Remove a rotation at given index."""
|
|
949
|
+
import copy
|
|
917
950
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
951
|
+
current_data = copy.deepcopy(
|
|
952
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
953
|
+
)
|
|
954
|
+
angles_list = current_data["angles_list"]
|
|
921
955
|
|
|
922
|
-
|
|
956
|
+
if 0 <= index < len(angles_list):
|
|
957
|
+
angles_list.pop(index)
|
|
958
|
+
current_data["angles_list"] = angles_list
|
|
959
|
+
self.server.state.mpr_rotation_data = current_data
|
|
960
|
+
|
|
961
|
+
def reset_rotation_angle(self, index):
|
|
962
|
+
"""Reset the angle of a rotation at given index to zero."""
|
|
963
|
+
import copy
|
|
964
|
+
|
|
965
|
+
current_data = copy.deepcopy(
|
|
966
|
+
getattr(self.server.state, "mpr_rotation_data", {"angles_list": []})
|
|
967
|
+
)
|
|
968
|
+
angles_list = current_data["angles_list"]
|
|
969
|
+
|
|
970
|
+
if 0 <= index < len(angles_list):
|
|
971
|
+
angles_list[index]["angles"][0] = 0
|
|
972
|
+
current_data["angles_list"] = angles_list
|
|
973
|
+
self.server.state.mpr_rotation_data = current_data
|
|
923
974
|
|
|
924
975
|
def reset_mpr_rotations(self):
|
|
925
976
|
"""Reset all MPR rotations."""
|
|
926
|
-
self.server.state.
|
|
927
|
-
for i in range(self.scene.max_mpr_rotations):
|
|
928
|
-
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
929
|
-
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
930
|
-
self.update_mpr_rotation_labels()
|
|
931
|
-
|
|
932
|
-
def update_mpr_rotation_labels(self):
|
|
933
|
-
"""Update the rotation axis labels for display."""
|
|
934
|
-
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
935
|
-
for i, rotation in enumerate(rotation_sequence):
|
|
936
|
-
setattr(
|
|
937
|
-
self.server.state,
|
|
938
|
-
f"mpr_rotation_axis_{i}",
|
|
939
|
-
f"{rotation['axis']} ({i + 1})",
|
|
940
|
-
)
|
|
941
|
-
# Clear unused labels
|
|
942
|
-
for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
|
|
943
|
-
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
977
|
+
self.server.state.mpr_rotation_data = {"angles_list": []}
|
|
944
978
|
|
|
945
979
|
def finalize_mpr_initialization(self, **kwargs):
|
|
946
980
|
"""Set the active volume label after UI is ready to avoid race condition."""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# System
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import pathlib as pl
|
|
4
|
+
import typing as ty
|
|
5
|
+
|
|
6
|
+
# Third Party
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pydantic as pc
|
|
9
|
+
import tomlkit as tk
|
|
10
|
+
|
|
11
|
+
# Internal
|
|
12
|
+
from .orientation import AngleUnits, AxisConvention
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RotationStep(pc.BaseModel):
|
|
16
|
+
"""Single rotation step (stored in ITK convention, radians)."""
|
|
17
|
+
|
|
18
|
+
axes: ty.Literal["X", "Y", "Z"]
|
|
19
|
+
angle: float = 0.0
|
|
20
|
+
visible: bool = True
|
|
21
|
+
name: str = ""
|
|
22
|
+
name_editable: bool = True
|
|
23
|
+
deletable: bool = True
|
|
24
|
+
|
|
25
|
+
@pc.model_validator(mode="before")
|
|
26
|
+
@classmethod
|
|
27
|
+
def handle_legacy_format(cls, data):
|
|
28
|
+
"""Handle legacy 'angles' list format and 'axis' field."""
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
if "angles" in data and "angle" not in data:
|
|
31
|
+
angles = data["angles"]
|
|
32
|
+
if isinstance(angles, list) and len(angles) > 0:
|
|
33
|
+
data["angle"] = angles[0]
|
|
34
|
+
else:
|
|
35
|
+
data["angle"] = angles
|
|
36
|
+
if "axis" in data and "axes" not in data:
|
|
37
|
+
data["axes"] = data["axis"]
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
def to_convention(
|
|
41
|
+
self, convention: AxisConvention, units: AngleUnits
|
|
42
|
+
) -> tuple[str, float]:
|
|
43
|
+
"""Convert to target convention/units for serialization."""
|
|
44
|
+
axis = self.axes
|
|
45
|
+
angle = self.angle
|
|
46
|
+
|
|
47
|
+
if convention == AxisConvention.ROMA:
|
|
48
|
+
axis = {"X": "Z", "Y": "Y", "Z": "X"}[axis]
|
|
49
|
+
angle = -angle
|
|
50
|
+
|
|
51
|
+
if units == AngleUnits.DEGREES:
|
|
52
|
+
angle = np.degrees(angle)
|
|
53
|
+
|
|
54
|
+
return axis, angle
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_convention(
|
|
58
|
+
cls,
|
|
59
|
+
axes: str,
|
|
60
|
+
angle: float,
|
|
61
|
+
convention: AxisConvention,
|
|
62
|
+
units: AngleUnits,
|
|
63
|
+
**kwargs,
|
|
64
|
+
) -> "RotationStep":
|
|
65
|
+
"""Create from target convention/units (for deserialization)."""
|
|
66
|
+
if units == AngleUnits.DEGREES:
|
|
67
|
+
angle = np.radians(angle)
|
|
68
|
+
|
|
69
|
+
if convention == AxisConvention.ROMA:
|
|
70
|
+
axes = {"X": "Z", "Y": "Y", "Z": "X"}[axes]
|
|
71
|
+
angle = -angle
|
|
72
|
+
|
|
73
|
+
return cls(axes=axes, angle=angle, **kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RotationMetadata(pc.BaseModel):
|
|
77
|
+
"""Metadata for TOML files."""
|
|
78
|
+
|
|
79
|
+
coordinate_system: ty.Literal["LPS"] = "LPS"
|
|
80
|
+
axis_convention: AxisConvention = AxisConvention.ITK
|
|
81
|
+
units: AngleUnits = AngleUnits.RADIANS
|
|
82
|
+
timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
|
|
83
|
+
volume_label: str = ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RotationSequence(pc.BaseModel):
|
|
87
|
+
"""Complete rotation sequence (stored in ITK convention, radians)."""
|
|
88
|
+
|
|
89
|
+
model_config = pc.ConfigDict(frozen=False)
|
|
90
|
+
|
|
91
|
+
metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
|
|
92
|
+
angles_list: list[RotationStep] = pc.Field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
@pc.field_validator("angles_list", mode="before")
|
|
95
|
+
@classmethod
|
|
96
|
+
def convert_legacy_list(cls, v):
|
|
97
|
+
"""Convert legacy list of dicts to list of RotationStep."""
|
|
98
|
+
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
|
99
|
+
return [RotationStep(**item) for item in v]
|
|
100
|
+
return v
|
|
101
|
+
|
|
102
|
+
def to_dict_for_ui(self) -> dict:
|
|
103
|
+
"""Convert to UI format (always ITK/radians internally).
|
|
104
|
+
|
|
105
|
+
Note: UI expects 'angles' as a list for backward compatibility.
|
|
106
|
+
"""
|
|
107
|
+
return {
|
|
108
|
+
"angles_list": [
|
|
109
|
+
{
|
|
110
|
+
"axes": step.axes,
|
|
111
|
+
"angles": [step.angle],
|
|
112
|
+
"visible": step.visible,
|
|
113
|
+
"name": step.name,
|
|
114
|
+
"name_editable": step.name_editable,
|
|
115
|
+
"deletable": step.deletable,
|
|
116
|
+
}
|
|
117
|
+
for step in self.angles_list
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
|
|
123
|
+
"""Create from UI format (assumes ITK/radians)."""
|
|
124
|
+
angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
|
|
125
|
+
metadata = RotationMetadata(volume_label=volume_label)
|
|
126
|
+
return cls(metadata=metadata, angles_list=angles_list)
|
|
127
|
+
|
|
128
|
+
def to_toml(
|
|
129
|
+
self, target_convention: AxisConvention, target_units: AngleUnits
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Serialize to TOML with conversions."""
|
|
132
|
+
doc = tk.document()
|
|
133
|
+
|
|
134
|
+
metadata_table = tk.table()
|
|
135
|
+
metadata_table["coordinate_system"] = self.metadata.coordinate_system
|
|
136
|
+
metadata_table["axis_convention"] = target_convention.value
|
|
137
|
+
metadata_table["units"] = target_units.value
|
|
138
|
+
metadata_table["timestamp"] = self.metadata.timestamp
|
|
139
|
+
metadata_table["volume_label"] = self.metadata.volume_label
|
|
140
|
+
doc["metadata"] = metadata_table
|
|
141
|
+
|
|
142
|
+
rotations_array = tk.aot()
|
|
143
|
+
for step in self.angles_list:
|
|
144
|
+
rotation = tk.table()
|
|
145
|
+
converted_axis, converted_angle = step.to_convention(
|
|
146
|
+
target_convention, target_units
|
|
147
|
+
)
|
|
148
|
+
rotation["axes"] = converted_axis
|
|
149
|
+
rotation["angle"] = converted_angle
|
|
150
|
+
rotation["visible"] = step.visible
|
|
151
|
+
rotation["name"] = step.name
|
|
152
|
+
rotation["name_editable"] = step.name_editable
|
|
153
|
+
rotation["deletable"] = step.deletable
|
|
154
|
+
rotations_array.append(rotation)
|
|
155
|
+
|
|
156
|
+
doc["angles_list"] = rotations_array
|
|
157
|
+
|
|
158
|
+
return tk.dumps(doc)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_toml(cls, toml_content: str) -> "RotationSequence":
|
|
162
|
+
"""Deserialize from TOML with conversions."""
|
|
163
|
+
doc = tk.loads(toml_content)
|
|
164
|
+
|
|
165
|
+
metadata_dict = doc.get("metadata", {})
|
|
166
|
+
convention = AxisConvention(metadata_dict.get("axis_convention", "itk"))
|
|
167
|
+
units = AngleUnits(metadata_dict.get("units", "radians"))
|
|
168
|
+
|
|
169
|
+
metadata = RotationMetadata(
|
|
170
|
+
coordinate_system=metadata_dict.get("coordinate_system", "LPS"),
|
|
171
|
+
axis_convention=convention,
|
|
172
|
+
units=units,
|
|
173
|
+
timestamp=metadata_dict.get("timestamp", dt.datetime.now().isoformat()),
|
|
174
|
+
volume_label=metadata_dict.get("volume_label", ""),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
angles_list_data = doc.get("angles_list", [])
|
|
178
|
+
angles_list = []
|
|
179
|
+
for item in angles_list_data:
|
|
180
|
+
axes = item.get("axes", "X")
|
|
181
|
+
angle = item.get("angle", 0.0)
|
|
182
|
+
step = RotationStep.from_convention(
|
|
183
|
+
axes=axes,
|
|
184
|
+
angle=angle,
|
|
185
|
+
convention=convention,
|
|
186
|
+
units=units,
|
|
187
|
+
visible=item.get("visible", True),
|
|
188
|
+
name=item.get("name", ""),
|
|
189
|
+
name_editable=item.get("name_editable", True),
|
|
190
|
+
deletable=item.get("deletable", True),
|
|
191
|
+
)
|
|
192
|
+
angles_list.append(step)
|
|
193
|
+
|
|
194
|
+
return cls(metadata=metadata, angles_list=angles_list)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_file(cls, path: pl.Path) -> "RotationSequence":
|
|
198
|
+
"""Load from TOML file."""
|
|
199
|
+
with open(path, "r") as f:
|
|
200
|
+
return cls.from_toml(f.read())
|
|
201
|
+
|
|
202
|
+
def to_file(
|
|
203
|
+
self,
|
|
204
|
+
path: pl.Path,
|
|
205
|
+
target_convention: AxisConvention,
|
|
206
|
+
target_units: AngleUnits,
|
|
207
|
+
):
|
|
208
|
+
"""Save to TOML file."""
|
|
209
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
with open(path, "w") as f:
|
|
211
|
+
f.write(self.to_toml(target_convention, target_units))
|
|
@@ -8,9 +8,10 @@ import pydantic_settings as ps
|
|
|
8
8
|
import vtk
|
|
9
9
|
|
|
10
10
|
from .mesh import Mesh
|
|
11
|
+
from .orientation import AngleUnits, AxisConvention
|
|
12
|
+
from .rotation import RotationSequence
|
|
11
13
|
from .segmentation import Segmentation
|
|
12
14
|
from .types import RGBColor
|
|
13
|
-
from .utils import AngleUnit
|
|
14
15
|
from .volume import Volume
|
|
15
16
|
|
|
16
17
|
MeshListAdapter = pc.TypeAdapter(list[Mesh])
|
|
@@ -124,23 +125,31 @@ class Scene(ps.BaseSettings):
|
|
|
124
125
|
mpr_window_level_preset: int = pc.Field(
|
|
125
126
|
default=7, description="Window/level preset key for MPR views"
|
|
126
127
|
)
|
|
127
|
-
mpr_rotation_sequence:
|
|
128
|
-
default_factory=
|
|
129
|
-
description="Dynamic rotation sequence for MPR views
|
|
128
|
+
mpr_rotation_sequence: RotationSequence = pc.Field(
|
|
129
|
+
default_factory=RotationSequence,
|
|
130
|
+
description="Dynamic rotation sequence for MPR views",
|
|
131
|
+
)
|
|
132
|
+
mpr_rotation_file: pl.Path | None = pc.Field(
|
|
133
|
+
default=None,
|
|
134
|
+
description="Path to TOML file containing rotation configuration to load",
|
|
130
135
|
)
|
|
131
136
|
max_mpr_rotations: int = pc.Field(
|
|
132
137
|
default=20,
|
|
133
138
|
description="Maximum number of MPR rotations supported",
|
|
134
139
|
)
|
|
135
|
-
angle_units:
|
|
136
|
-
default=
|
|
140
|
+
angle_units: AngleUnits = pc.Field(
|
|
141
|
+
default=AngleUnits.RADIANS,
|
|
137
142
|
description="Units for angle measurements in rotation serialization",
|
|
138
143
|
)
|
|
139
144
|
coordinate_system: str = pc.Field(
|
|
140
|
-
default="
|
|
145
|
+
default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
|
|
146
|
+
)
|
|
147
|
+
axis_convention: AxisConvention = pc.Field(
|
|
148
|
+
default=AxisConvention.ITK,
|
|
149
|
+
description="Axis convention for saved rotations (ITK: X=L,Y=P,Z=S; Roma: X=S,Y=P,Z=L)",
|
|
141
150
|
)
|
|
142
151
|
mpr_crosshairs_enabled: bool = pc.Field(
|
|
143
|
-
default=
|
|
152
|
+
default=True, description="Show crosshair lines indicating slice intersections"
|
|
144
153
|
)
|
|
145
154
|
mpr_crosshair_colors: dict = pc.Field(
|
|
146
155
|
default_factory=lambda: {
|
|
@@ -176,6 +185,23 @@ class Scene(ps.BaseSettings):
|
|
|
176
185
|
return SegmentationListAdapter.validate_json(v)
|
|
177
186
|
return v
|
|
178
187
|
|
|
188
|
+
@pc.model_validator(mode="after")
|
|
189
|
+
def load_rotation_file(self):
|
|
190
|
+
"""Load rotation sequence from TOML file if specified."""
|
|
191
|
+
if self.mpr_rotation_file is not None and self.mpr_rotation_file.exists():
|
|
192
|
+
self.mpr_rotation_sequence = RotationSequence.from_file(
|
|
193
|
+
self.mpr_rotation_file
|
|
194
|
+
)
|
|
195
|
+
if (
|
|
196
|
+
not self.active_volume_label
|
|
197
|
+
and self.mpr_rotation_sequence.metadata.volume_label
|
|
198
|
+
):
|
|
199
|
+
self.active_volume_label = (
|
|
200
|
+
self.mpr_rotation_sequence.metadata.volume_label
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return self
|
|
204
|
+
|
|
179
205
|
# VTK objects as private attributes
|
|
180
206
|
_renderer: vtk.vtkRenderer = pc.PrivateAttr(default_factory=vtk.vtkRenderer)
|
|
181
207
|
_renderWindow: vtk.vtkRenderWindow = pc.PrivateAttr(
|
|
@@ -3,11 +3,12 @@ import time
|
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
from trame.ui.vuetify3 import SinglePageWithDrawerLayout
|
|
6
|
+
from trame.widgets import client
|
|
6
7
|
from trame.widgets import html
|
|
7
8
|
from trame.widgets import vtk as vtk_widgets
|
|
8
9
|
from trame.widgets import vuetify3 as vuetify
|
|
9
10
|
|
|
10
|
-
from .orientation import EulerAxis, euler_angle_to_rotation_matrix
|
|
11
|
+
from .orientation import AngleUnits, EulerAxis, euler_angle_to_rotation_matrix
|
|
11
12
|
from .scene import Scene
|
|
12
13
|
from .volume_property_presets import list_volume_property_presets
|
|
13
14
|
from .window_level import presets
|
|
@@ -151,16 +152,21 @@ class UI:
|
|
|
151
152
|
return np.array([0.0, 0.0, 1.0])
|
|
152
153
|
|
|
153
154
|
# Build cumulative rotation from visible rotations
|
|
154
|
-
|
|
155
|
+
rotation_data = getattr(
|
|
156
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
157
|
+
)
|
|
158
|
+
angles_list = rotation_data.get("angles_list", [])
|
|
155
159
|
cumulative_rotation = np.eye(3)
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
# Get current angle units
|
|
162
|
+
angle_units_str = getattr(self.server.state, "angle_units", "degrees")
|
|
163
|
+
angle_units = AngleUnits(angle_units_str)
|
|
164
|
+
|
|
165
|
+
for rotation in angles_list:
|
|
166
|
+
if rotation.get("visible", True):
|
|
167
|
+
angle = rotation.get("angles", [0])[0]
|
|
162
168
|
rotation_matrix = euler_angle_to_rotation_matrix(
|
|
163
|
-
EulerAxis(rotation["
|
|
169
|
+
EulerAxis(rotation["axes"]), angle, angle_units
|
|
164
170
|
)
|
|
165
171
|
cumulative_rotation = cumulative_rotation @ rotation_matrix
|
|
166
172
|
|
|
@@ -462,7 +468,7 @@ class UI:
|
|
|
462
468
|
# Volume selection dropdown
|
|
463
469
|
if self.scene.volumes:
|
|
464
470
|
vuetify.VSelect(
|
|
465
|
-
v_if="!maximized_view || maximized_view === 'volume'",
|
|
471
|
+
v_if="(!maximized_view || maximized_view === 'volume') && volume_items.length >= 2",
|
|
466
472
|
v_model=("active_volume_label", ""),
|
|
467
473
|
items=("volume_items", []),
|
|
468
474
|
item_title="text",
|
|
@@ -472,57 +478,6 @@ class UI:
|
|
|
472
478
|
hide_details=True,
|
|
473
479
|
)
|
|
474
480
|
|
|
475
|
-
# Window/Level controls for MPR
|
|
476
|
-
vuetify.VListSubheader(
|
|
477
|
-
"Window/Level", v_if="!maximized_view && active_volume_label"
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
vuetify.VSelect(
|
|
481
|
-
v_if="!maximized_view && active_volume_label",
|
|
482
|
-
v_model=("mpr_window_level_preset", 7),
|
|
483
|
-
items=("mpr_presets", []),
|
|
484
|
-
item_title="text",
|
|
485
|
-
item_value="value",
|
|
486
|
-
label="Preset",
|
|
487
|
-
dense=True,
|
|
488
|
-
hide_details=True,
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
vuetify.VSlider(
|
|
492
|
-
v_if="!maximized_view && active_volume_label",
|
|
493
|
-
v_model="mpr_window",
|
|
494
|
-
min=1.0,
|
|
495
|
-
max=2000.0,
|
|
496
|
-
step=1.0,
|
|
497
|
-
hint="Window",
|
|
498
|
-
persistent_hint=True,
|
|
499
|
-
dense=True,
|
|
500
|
-
hide_details=False,
|
|
501
|
-
thumb_label=True,
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
vuetify.VSlider(
|
|
505
|
-
v_if="!maximized_view && active_volume_label",
|
|
506
|
-
v_model="mpr_level",
|
|
507
|
-
min=-1000.0,
|
|
508
|
-
max=1000.0,
|
|
509
|
-
step=1.0,
|
|
510
|
-
hint="Level",
|
|
511
|
-
persistent_hint=True,
|
|
512
|
-
dense=True,
|
|
513
|
-
hide_details=False,
|
|
514
|
-
thumb_label=True,
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
vuetify.VCheckbox(
|
|
518
|
-
v_if="!maximized_view && active_volume_label",
|
|
519
|
-
v_model=("mpr_crosshairs_enabled", True),
|
|
520
|
-
label="Show Crosshairs",
|
|
521
|
-
dense=True,
|
|
522
|
-
hide_details=True,
|
|
523
|
-
classes="mt-2",
|
|
524
|
-
)
|
|
525
|
-
|
|
526
481
|
# MPR Rotation controls
|
|
527
482
|
vuetify.VListSubheader(
|
|
528
483
|
"Rotations", v_if="!maximized_view && active_volume_label"
|
|
@@ -564,8 +519,8 @@ class UI:
|
|
|
564
519
|
|
|
565
520
|
# Reset rotations button
|
|
566
521
|
vuetify.VBtn(
|
|
567
|
-
"
|
|
568
|
-
v_if="!maximized_view && active_volume_label &&
|
|
522
|
+
"Remove All Rotations",
|
|
523
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
|
|
569
524
|
click=self.server.controller.reset_rotations,
|
|
570
525
|
small=True,
|
|
571
526
|
dense=True,
|
|
@@ -576,48 +531,105 @@ class UI:
|
|
|
576
531
|
prepend_icon="mdi-refresh",
|
|
577
532
|
)
|
|
578
533
|
|
|
579
|
-
# Individual rotation sliders
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
vuetify.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
534
|
+
# Individual rotation sliders with DeepReactive
|
|
535
|
+
with client.DeepReactive("mpr_rotation_data"):
|
|
536
|
+
for i in range(self.scene.max_mpr_rotations):
|
|
537
|
+
with vuetify.VContainer(
|
|
538
|
+
v_if=f"!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > {i}",
|
|
539
|
+
fluid=True,
|
|
540
|
+
classes="pa-0 mb-2",
|
|
541
|
+
):
|
|
542
|
+
with vuetify.VRow(no_gutters=True):
|
|
543
|
+
with vuetify.VCol(cols="12"):
|
|
544
|
+
vuetify.VTextField(
|
|
545
|
+
v_model=(
|
|
546
|
+
f"mpr_rotation_data.angles_list[{i}].name",
|
|
547
|
+
),
|
|
548
|
+
placeholder="Name",
|
|
549
|
+
dense=True,
|
|
550
|
+
hide_details=True,
|
|
551
|
+
readonly=(
|
|
552
|
+
f"!mpr_rotation_data.angles_list[{i}].name_editable",
|
|
553
|
+
),
|
|
554
|
+
__events=["keydown", "keyup", "keypress"],
|
|
555
|
+
keydown="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
556
|
+
keyup="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
557
|
+
keypress="$event.stopPropagation(); $event.stopImmediatePropagation();",
|
|
558
|
+
)
|
|
559
|
+
with vuetify.VRow(no_gutters=True):
|
|
560
|
+
with vuetify.VCol(cols="12"):
|
|
561
|
+
vuetify.VSlider(
|
|
562
|
+
v_model=(
|
|
563
|
+
f"mpr_rotation_data.angles_list[{i}].angles[0]",
|
|
564
|
+
),
|
|
565
|
+
min=(
|
|
566
|
+
"angle_units === 'radians' ? -Math.PI : -180",
|
|
567
|
+
),
|
|
568
|
+
max=(
|
|
569
|
+
"angle_units === 'radians' ? Math.PI : 180",
|
|
570
|
+
),
|
|
571
|
+
step=(
|
|
572
|
+
"angle_units === 'radians' ? 0.01 : 1",
|
|
573
|
+
),
|
|
574
|
+
dense=True,
|
|
575
|
+
hide_details=True,
|
|
576
|
+
thumb_label=True,
|
|
577
|
+
)
|
|
578
|
+
with vuetify.VRow(
|
|
579
|
+
no_gutters=True, classes="align-center"
|
|
580
|
+
):
|
|
581
|
+
vuetify.VSpacer()
|
|
582
|
+
with vuetify.VCol(cols="4"):
|
|
583
|
+
vuetify.VSelect(
|
|
584
|
+
v_model=(
|
|
585
|
+
f"mpr_rotation_data.angles_list[{i}].axes",
|
|
586
|
+
),
|
|
587
|
+
items=(["X", "Y", "Z"],),
|
|
588
|
+
dense=True,
|
|
589
|
+
hide_details=True,
|
|
590
|
+
label="Axis",
|
|
591
|
+
)
|
|
592
|
+
vuetify.VSpacer()
|
|
593
|
+
with vuetify.VCol(cols="auto"):
|
|
594
|
+
vuetify.VCheckbox(
|
|
595
|
+
v_model=(
|
|
596
|
+
f"mpr_rotation_data.angles_list[{i}].visible",
|
|
597
|
+
),
|
|
598
|
+
true_icon="mdi-eye",
|
|
599
|
+
false_icon="mdi-eye-off",
|
|
600
|
+
hide_details=True,
|
|
601
|
+
dense=True,
|
|
602
|
+
title="Toggle this rotation",
|
|
603
|
+
)
|
|
604
|
+
vuetify.VSpacer()
|
|
605
|
+
with vuetify.VCol(cols="auto"):
|
|
606
|
+
vuetify.VBtn(
|
|
607
|
+
icon="mdi-restore",
|
|
608
|
+
click=ft.partial(
|
|
609
|
+
self.server.controller.reset_rotation_angle,
|
|
610
|
+
i,
|
|
611
|
+
),
|
|
612
|
+
small=True,
|
|
613
|
+
dense=True,
|
|
614
|
+
title="Reset angle to zero",
|
|
615
|
+
)
|
|
616
|
+
vuetify.VSpacer()
|
|
617
|
+
with vuetify.VCol(cols="auto"):
|
|
618
|
+
vuetify.VBtn(
|
|
619
|
+
icon="mdi-delete",
|
|
620
|
+
click=ft.partial(
|
|
621
|
+
self.server.controller.remove_rotation_event,
|
|
622
|
+
i,
|
|
623
|
+
),
|
|
624
|
+
small=True,
|
|
625
|
+
dense=True,
|
|
626
|
+
color="error",
|
|
627
|
+
title="Remove this rotation",
|
|
628
|
+
disabled=(
|
|
629
|
+
f"!mpr_rotation_data.angles_list[{i}].deletable",
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
vuetify.VSpacer()
|
|
621
633
|
|
|
622
634
|
# Angle units selector
|
|
623
635
|
with vuetify.VRow(
|
|
@@ -629,7 +641,7 @@ class UI:
|
|
|
629
641
|
vuetify.VLabel("Units:")
|
|
630
642
|
with vuetify.VCol(cols="8"):
|
|
631
643
|
vuetify.VSelect(
|
|
632
|
-
v_model=("angle_units", "
|
|
644
|
+
v_model=("angle_units", "radians"),
|
|
633
645
|
items=("angle_units_items", []),
|
|
634
646
|
item_title="text",
|
|
635
647
|
item_value="value",
|
|
@@ -638,10 +650,29 @@ class UI:
|
|
|
638
650
|
outlined=True,
|
|
639
651
|
)
|
|
640
652
|
|
|
653
|
+
# Axis convention selector
|
|
654
|
+
with vuetify.VRow(
|
|
655
|
+
v_if="!maximized_view && active_volume_label",
|
|
656
|
+
no_gutters=True,
|
|
657
|
+
classes="align-center mb-2",
|
|
658
|
+
):
|
|
659
|
+
with vuetify.VCol(cols="4"):
|
|
660
|
+
vuetify.VLabel("Convention:")
|
|
661
|
+
with vuetify.VCol(cols="8"):
|
|
662
|
+
vuetify.VSelect(
|
|
663
|
+
v_model=("axis_convention", "itk"),
|
|
664
|
+
items=("axis_convention_items", []),
|
|
665
|
+
item_title="text",
|
|
666
|
+
item_value="value",
|
|
667
|
+
dense=True,
|
|
668
|
+
hide_details=True,
|
|
669
|
+
outlined=True,
|
|
670
|
+
)
|
|
671
|
+
|
|
641
672
|
# Save rotations button
|
|
642
673
|
vuetify.VBtn(
|
|
643
674
|
"Save Rotations",
|
|
644
|
-
v_if="!maximized_view && active_volume_label &&
|
|
675
|
+
v_if="!maximized_view && active_volume_label && mpr_rotation_data.angles_list && mpr_rotation_data.angles_list.length > 0",
|
|
645
676
|
click=self.server.controller.save_rotation_angles,
|
|
646
677
|
small=True,
|
|
647
678
|
dense=True,
|
|
@@ -10,13 +10,6 @@ class InterpolatorType(enum.Enum):
|
|
|
10
10
|
NEAREST = "nearest"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
class AngleUnit(enum.Enum):
|
|
14
|
-
"""Units for angle measurements."""
|
|
15
|
-
|
|
16
|
-
DEGREES = "degrees"
|
|
17
|
-
RADIANS = "radians"
|
|
18
|
-
|
|
19
|
-
|
|
20
13
|
def calculate_combined_bounds(actors):
|
|
21
14
|
"""Calculate combined bounds encompassing all VTK actors.
|
|
22
15
|
|
|
@@ -206,17 +206,17 @@ class Volume(Object):
|
|
|
206
206
|
)
|
|
207
207
|
elif view_name == "sagittal":
|
|
208
208
|
view_crosshairs["line1"]["actor"].GetProperty().SetColor(
|
|
209
|
-
*colors.get("
|
|
209
|
+
*colors.get("coronal", (0, 1, 0))
|
|
210
210
|
)
|
|
211
211
|
view_crosshairs["line2"]["actor"].GetProperty().SetColor(
|
|
212
|
-
*colors.get("
|
|
212
|
+
*colors.get("axial", (0, 0, 1))
|
|
213
213
|
)
|
|
214
214
|
else: # coronal
|
|
215
215
|
view_crosshairs["line1"]["actor"].GetProperty().SetColor(
|
|
216
|
-
*colors.get("
|
|
216
|
+
*colors.get("sagittal", (1, 0, 0))
|
|
217
217
|
)
|
|
218
218
|
view_crosshairs["line2"]["actor"].GetProperty().SetColor(
|
|
219
|
-
*colors.get("
|
|
219
|
+
*colors.get("axial", (0, 0, 1))
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
crosshairs[view_name] = view_crosshairs
|
|
@@ -299,15 +299,20 @@ class Volume(Object):
|
|
|
299
299
|
return bounds
|
|
300
300
|
|
|
301
301
|
def _build_cumulative_rotation(
|
|
302
|
-
self, rotation_sequence: list, rotation_angles: dict
|
|
302
|
+
self, rotation_sequence: list, rotation_angles: dict, angle_units=None
|
|
303
303
|
) -> np.ndarray:
|
|
304
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
|
+
|
|
305
310
|
cumulative_rotation = np.eye(3)
|
|
306
311
|
if rotation_sequence and rotation_angles:
|
|
307
312
|
for i, rotation in enumerate(rotation_sequence):
|
|
308
313
|
angle = rotation_angles.get(i, 0)
|
|
309
314
|
rotation_matrix = euler_angle_to_rotation_matrix(
|
|
310
|
-
EulerAxis(rotation["axis"]), angle
|
|
315
|
+
EulerAxis(rotation["axis"]), angle, angle_units
|
|
311
316
|
)
|
|
312
317
|
cumulative_rotation = cumulative_rotation @ rotation_matrix
|
|
313
318
|
return cumulative_rotation
|
|
@@ -317,6 +322,7 @@ class Volume(Object):
|
|
|
317
322
|
view_name: str,
|
|
318
323
|
rotation_sequence: list = None,
|
|
319
324
|
rotation_angles: dict = None,
|
|
325
|
+
angle_units=None,
|
|
320
326
|
) -> np.ndarray:
|
|
321
327
|
"""Get the current normal vector for a view after rotation.
|
|
322
328
|
|
|
@@ -324,6 +330,7 @@ class Volume(Object):
|
|
|
324
330
|
view_name: One of "axial", "sagittal", "coronal"
|
|
325
331
|
rotation_sequence: List of rotation definitions
|
|
326
332
|
rotation_angles: Dict mapping rotation index to angle
|
|
333
|
+
angle_units: AngleUnits enum (degrees or radians)
|
|
327
334
|
|
|
328
335
|
Returns:
|
|
329
336
|
3D unit vector representing the scroll direction for this view
|
|
@@ -338,7 +345,7 @@ class Volume(Object):
|
|
|
338
345
|
return np.array([0.0, 0.0, 1.0])
|
|
339
346
|
|
|
340
347
|
cumulative_rotation = self._build_cumulative_rotation(
|
|
341
|
-
rotation_sequence, rotation_angles
|
|
348
|
+
rotation_sequence, rotation_angles, angle_units
|
|
342
349
|
)
|
|
343
350
|
return cumulative_rotation @ base_normals[view_name]
|
|
344
351
|
|
|
@@ -348,6 +355,7 @@ class Volume(Object):
|
|
|
348
355
|
origin: list,
|
|
349
356
|
rotation_sequence: list = None,
|
|
350
357
|
rotation_angles: dict = None,
|
|
358
|
+
angle_units=None,
|
|
351
359
|
):
|
|
352
360
|
"""Update slice positions for MPR views with optional rotation.
|
|
353
361
|
|
|
@@ -356,7 +364,12 @@ class Volume(Object):
|
|
|
356
364
|
origin: [x, y, z] position in LPS coordinates (shared by all views)
|
|
357
365
|
rotation_sequence: List of rotation definitions
|
|
358
366
|
rotation_angles: Dict mapping rotation index to angle
|
|
367
|
+
angle_units: AngleUnits enum (degrees or radians), defaults to DEGREES
|
|
359
368
|
"""
|
|
369
|
+
from .orientation import AngleUnits
|
|
370
|
+
|
|
371
|
+
if angle_units is None:
|
|
372
|
+
angle_units = AngleUnits.DEGREES
|
|
360
373
|
if frame not in self._mpr_actors:
|
|
361
374
|
return
|
|
362
375
|
|
|
@@ -367,7 +380,7 @@ class Volume(Object):
|
|
|
367
380
|
|
|
368
381
|
# Build cumulative rotation matrix
|
|
369
382
|
cumulative_rotation = self._build_cumulative_rotation(
|
|
370
|
-
rotation_sequence, rotation_angles
|
|
383
|
+
rotation_sequence, rotation_angles, angle_units
|
|
371
384
|
)
|
|
372
385
|
|
|
373
386
|
# Apply rotation to base transforms
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|