cardio 2026.1.0__tar.gz → 2026.1.2__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-2026.1.0 → cardio-2026.1.2}/PKG-INFO +4 -4
- {cardio-2026.1.0 → cardio-2026.1.2}/README.md +3 -3
- {cardio-2026.1.0 → cardio-2026.1.2}/pyproject.toml +2 -2
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/__init__.py +1 -1
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/logic.py +84 -23
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/orientation.py +4 -4
- cardio-2026.1.2/src/cardio/rotation.py +146 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/scene.py +4 -9
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/ui.py +2 -2
- cardio-2026.1.0/src/cardio/rotation.py +0 -211
- {cardio-2026.1.0 → cardio-2026.1.2}/LICENSE +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/app.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/bone.toml +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/vascular_closed.toml +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/vascular_open.toml +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/xray.toml +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/blend_transfer_functions.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/color_transfer_function.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/mesh.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/object.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/piecewise_function.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/property_config.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/screenshot.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/segmentation.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/transfer_function_pair.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/types.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/utils.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume_property.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume_property_presets.py +0 -0
- {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/window_level.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cardio
|
|
3
|
-
Version: 2026.1.
|
|
3
|
+
Version: 2026.1.2
|
|
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
|
|
@@ -60,8 +60,8 @@ Description-Content-Type: text/markdown
|
|
|
60
60
|
built primarily on [trame](https://github.com/kitware/trame),
|
|
61
61
|
[vtk](https://github.com/kitware/vtk), and
|
|
62
62
|
[itk](https://github.com/insightsoftwareconsortium/itk). `cardio` can render sequences
|
|
63
|
-
of mesh files (e.g.,
|
|
64
|
-
discrete labels) and volume renderings of grayscale images (e.g.,
|
|
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
65
|
continuous values). `cardio` is launched from the commandline and may be configured via
|
|
66
66
|
commandline arguments, a static TOML configuration file, or a combination of the two.
|
|
67
67
|
|
|
@@ -75,7 +75,7 @@ $ uv init
|
|
|
75
75
|
$ uv add cardio
|
|
76
76
|
$ . ./.venv/bin/activate
|
|
77
77
|
(project) cardio --version
|
|
78
|
-
cardio 2026.1.
|
|
78
|
+
cardio 2026.1.2
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
### Developing
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
built primarily on [trame](https://github.com/kitware/trame),
|
|
5
5
|
[vtk](https://github.com/kitware/vtk), and
|
|
6
6
|
[itk](https://github.com/insightsoftwareconsortium/itk). `cardio` can render sequences
|
|
7
|
-
of mesh files (e.g.,
|
|
8
|
-
discrete labels) and volume renderings of grayscale images (e.g.,
|
|
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
9
|
continuous values). `cardio` is launched from the commandline and may be configured via
|
|
10
10
|
commandline arguments, a static TOML configuration file, or a combination of the two.
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ $ uv init
|
|
|
19
19
|
$ uv add cardio
|
|
20
20
|
$ . ./.venv/bin/activate
|
|
21
21
|
(project) cardio --version
|
|
22
|
-
cardio 2026.1.
|
|
22
|
+
cardio 2026.1.2
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
### Developing
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cardio"
|
|
7
|
-
version = "2026.1.
|
|
7
|
+
version = "2026.1.2"
|
|
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 = "2026.1.
|
|
65
|
+
current_version = "2026.1.2"
|
|
66
66
|
version_pattern = "YYYY.MM.INC0"
|
|
67
67
|
commit_message = "ENH: Bump version from {old_version} => {new_version}"
|
|
68
68
|
commit = true
|
|
@@ -9,7 +9,13 @@ from .screenshot import Screenshot
|
|
|
9
9
|
|
|
10
10
|
class Logic:
|
|
11
11
|
def _get_visible_rotation_data(self):
|
|
12
|
-
"""Get rotation sequence and angles for visible rotations only.
|
|
12
|
+
"""Get rotation sequence and angles for visible rotations only.
|
|
13
|
+
|
|
14
|
+
Returns data in ITK convention, as required by VTK.
|
|
15
|
+
Converts from current convention if necessary.
|
|
16
|
+
"""
|
|
17
|
+
from .orientation import IndexOrder
|
|
18
|
+
|
|
13
19
|
rotation_data = getattr(
|
|
14
20
|
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
15
21
|
)
|
|
@@ -26,6 +32,17 @@ class Logic:
|
|
|
26
32
|
rotation_angles[visible_index] = rotation["angles"][0]
|
|
27
33
|
visible_index += 1
|
|
28
34
|
|
|
35
|
+
# CRITICAL: VTK always needs rotations in ITK convention
|
|
36
|
+
# Convert from current convention to ITK if necessary
|
|
37
|
+
current_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
38
|
+
if current_convention == IndexOrder.ROMA:
|
|
39
|
+
# Convert ROMA to ITK: X→Z, Y→Y, Z→X, angle→-angle
|
|
40
|
+
rotation_sequence = [
|
|
41
|
+
{"axis": {"X": "Z", "Y": "Y", "Z": "X"}[rot["axis"]]}
|
|
42
|
+
for rot in rotation_sequence
|
|
43
|
+
]
|
|
44
|
+
rotation_angles = {idx: -angle for idx, angle in rotation_angles.items()}
|
|
45
|
+
|
|
29
46
|
return rotation_sequence, rotation_angles
|
|
30
47
|
|
|
31
48
|
def __init__(self, server, scene: Scene):
|
|
@@ -48,7 +65,7 @@ class Logic:
|
|
|
48
65
|
]
|
|
49
66
|
|
|
50
67
|
# Initialize axis convention items for dropdown
|
|
51
|
-
self.server.state.
|
|
68
|
+
self.server.state.index_order_items = [
|
|
52
69
|
{"text": "ITK (X=L, Y=P, Z=S)", "value": "itk"},
|
|
53
70
|
{"text": "Roma (X=S, Y=P, Z=L)", "value": "roma"},
|
|
54
71
|
]
|
|
@@ -71,7 +88,7 @@ class Logic:
|
|
|
71
88
|
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
|
72
89
|
self.server.state.change("mpr_rotation_data")(self.update_mpr_rotation)
|
|
73
90
|
self.server.state.change("angle_units")(self.sync_angle_units)
|
|
74
|
-
self.server.state.change("
|
|
91
|
+
self.server.state.change("index_order")(self.sync_index_order)
|
|
75
92
|
|
|
76
93
|
# Initialize visibility state variables
|
|
77
94
|
for m in self.scene.meshes:
|
|
@@ -189,8 +206,12 @@ class Logic:
|
|
|
189
206
|
self.scene.mpr_rotation_sequence.to_dict_for_ui()
|
|
190
207
|
)
|
|
191
208
|
|
|
192
|
-
self.server.state.angle_units =
|
|
193
|
-
|
|
209
|
+
self.server.state.angle_units = (
|
|
210
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units.value
|
|
211
|
+
)
|
|
212
|
+
self.server.state.index_order = (
|
|
213
|
+
self.scene.mpr_rotation_sequence.metadata.index_order.value
|
|
214
|
+
)
|
|
194
215
|
|
|
195
216
|
# Initialize MPR presets data
|
|
196
217
|
try:
|
|
@@ -326,7 +347,7 @@ class Logic:
|
|
|
326
347
|
origin,
|
|
327
348
|
rotation_sequence,
|
|
328
349
|
rotation_angles,
|
|
329
|
-
self.scene.angle_units,
|
|
350
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
330
351
|
)
|
|
331
352
|
|
|
332
353
|
# Apply window/level
|
|
@@ -517,16 +538,21 @@ class Logic:
|
|
|
517
538
|
)
|
|
518
539
|
rotation_seq = RotationSequence.from_ui_dict(rotation_data, active_volume_label)
|
|
519
540
|
|
|
541
|
+
mpr_origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
|
|
542
|
+
rotation_seq.mpr_origin = list(mpr_origin)
|
|
543
|
+
|
|
520
544
|
rotation_seq.metadata.timestamp = timestamp.isoformat()
|
|
521
545
|
rotation_seq.metadata.volume_label = active_volume_label
|
|
522
546
|
rotation_seq.metadata.coordinate_system = self.scene.coordinate_system
|
|
547
|
+
rotation_seq.metadata.index_order = (
|
|
548
|
+
self.scene.mpr_rotation_sequence.metadata.index_order
|
|
549
|
+
)
|
|
550
|
+
rotation_seq.metadata.angle_units = (
|
|
551
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
552
|
+
)
|
|
523
553
|
|
|
524
554
|
output_path = save_dir / f"{timestamp_str}.toml"
|
|
525
|
-
rotation_seq.to_file(
|
|
526
|
-
output_path,
|
|
527
|
-
target_convention=self.scene.axis_convention,
|
|
528
|
-
target_units=self.scene.angle_units,
|
|
529
|
-
)
|
|
555
|
+
rotation_seq.to_file(output_path)
|
|
530
556
|
|
|
531
557
|
def reset_all(self):
|
|
532
558
|
self.server.state.frame = 0
|
|
@@ -560,9 +586,9 @@ class Logic:
|
|
|
560
586
|
from .orientation import AngleUnits
|
|
561
587
|
|
|
562
588
|
# Get current units before changing
|
|
563
|
-
old_units = self.scene.angle_units
|
|
589
|
+
old_units = self.scene.mpr_rotation_sequence.metadata.angle_units
|
|
564
590
|
|
|
565
|
-
# Update
|
|
591
|
+
# Update based on UI selection
|
|
566
592
|
new_units = None
|
|
567
593
|
if angle_units == "degrees":
|
|
568
594
|
new_units = AngleUnits.DEGREES
|
|
@@ -592,16 +618,48 @@ class Logic:
|
|
|
592
618
|
|
|
593
619
|
self.server.state.mpr_rotation_data = updated_data
|
|
594
620
|
|
|
595
|
-
self.scene.angle_units = new_units
|
|
621
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units = new_units
|
|
622
|
+
|
|
623
|
+
def sync_index_order(self, index_order, **kwargs):
|
|
624
|
+
"""Sync index order selection - converts existing rotations and updates scene."""
|
|
625
|
+
import copy
|
|
626
|
+
|
|
627
|
+
from .orientation import IndexOrder
|
|
596
628
|
|
|
597
|
-
|
|
598
|
-
"""Sync axis convention selection - updates the scene configuration."""
|
|
599
|
-
from .orientation import AxisConvention
|
|
629
|
+
old_convention = self.scene.mpr_rotation_sequence.metadata.index_order
|
|
600
630
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
631
|
+
# Convert string input to enum
|
|
632
|
+
if isinstance(index_order, str):
|
|
633
|
+
match index_order.lower():
|
|
634
|
+
case "itk":
|
|
635
|
+
new_convention = IndexOrder.ITK
|
|
636
|
+
case "roma":
|
|
637
|
+
new_convention = IndexOrder.ROMA
|
|
638
|
+
case _:
|
|
639
|
+
raise ValueError(f"Unrecognized index order: {index_order}")
|
|
640
|
+
else:
|
|
641
|
+
new_convention = index_order
|
|
642
|
+
|
|
643
|
+
if old_convention == new_convention:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
rotation_data = getattr(
|
|
647
|
+
self.server.state, "mpr_rotation_data", {"angles_list": []}
|
|
648
|
+
)
|
|
649
|
+
if rotation_data.get("angles_list"):
|
|
650
|
+
updated_data = copy.deepcopy(rotation_data)
|
|
651
|
+
|
|
652
|
+
for rotation in updated_data["angles_list"]:
|
|
653
|
+
current_axis = rotation.get("axes")
|
|
654
|
+
current_angle = rotation.get("angles", [0])[0]
|
|
655
|
+
|
|
656
|
+
# Conversion is the same for both ITK<->ROMA directions
|
|
657
|
+
rotation["axes"] = {"X": "Z", "Y": "Y", "Z": "X"}[current_axis]
|
|
658
|
+
rotation["angles"][0] = -current_angle
|
|
659
|
+
|
|
660
|
+
self.server.state.mpr_rotation_data = updated_data
|
|
661
|
+
|
|
662
|
+
self.scene.mpr_rotation_sequence.metadata.index_order = new_convention
|
|
605
663
|
|
|
606
664
|
def _initialize_clipping_state(self):
|
|
607
665
|
"""Initialize clipping state variables for all objects."""
|
|
@@ -793,7 +851,7 @@ class Logic:
|
|
|
793
851
|
origin,
|
|
794
852
|
rotation_sequence,
|
|
795
853
|
rotation_angles,
|
|
796
|
-
self.scene.angle_units,
|
|
854
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
797
855
|
)
|
|
798
856
|
|
|
799
857
|
# Update all views
|
|
@@ -892,7 +950,7 @@ class Logic:
|
|
|
892
950
|
origin,
|
|
893
951
|
rotation_sequence,
|
|
894
952
|
rotation_angles,
|
|
895
|
-
self.scene.angle_units,
|
|
953
|
+
self.scene.mpr_rotation_sequence.metadata.angle_units,
|
|
896
954
|
)
|
|
897
955
|
|
|
898
956
|
# Update all views
|
|
@@ -984,6 +1042,9 @@ class Logic:
|
|
|
984
1042
|
self.sync_active_volume(self._pending_active_volume)
|
|
985
1043
|
delattr(self, "_pending_active_volume")
|
|
986
1044
|
|
|
1045
|
+
# Apply loaded rotation data to MPR views
|
|
1046
|
+
self.update_mpr_rotation()
|
|
1047
|
+
|
|
987
1048
|
@asynchronous.task
|
|
988
1049
|
async def close_application(self):
|
|
989
1050
|
"""Close the application by stopping the server."""
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
import enum
|
|
2
2
|
|
|
3
3
|
import itk
|
|
4
4
|
import numpy as np
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
# DICOM LPS canonical orientation vector mappings
|
|
8
|
-
class EulerAxis(
|
|
8
|
+
class EulerAxis(enum.StrEnum):
|
|
9
9
|
X = "X"
|
|
10
10
|
Y = "Y"
|
|
11
11
|
Z = "Z"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class
|
|
14
|
+
class IndexOrder(enum.StrEnum):
|
|
15
15
|
ITK = "itk" # X=Left, Y=Posterior, Z=Superior
|
|
16
16
|
ROMA = "roma" # X=Superior, Y=Posterior, Z=Left
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class AngleUnits(
|
|
19
|
+
class AngleUnits(enum.StrEnum):
|
|
20
20
|
DEGREES = "degrees"
|
|
21
21
|
RADIANS = "radians"
|
|
22
22
|
|
|
@@ -0,0 +1,146 @@
|
|
|
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, IndexOrder
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RotationStep(pc.BaseModel):
|
|
16
|
+
"""Single rotation step.
|
|
17
|
+
|
|
18
|
+
Both angles and axes are stored in the current convention/units.
|
|
19
|
+
- Angles: stored in units specified by parent metadata.angle_units
|
|
20
|
+
- Axes: stored in convention specified by parent metadata.index_order
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
axes: ty.Literal["X", "Y", "Z"]
|
|
24
|
+
angle: float = 0.0
|
|
25
|
+
visible: bool = True
|
|
26
|
+
name: str = ""
|
|
27
|
+
name_editable: bool = True
|
|
28
|
+
deletable: bool = True
|
|
29
|
+
|
|
30
|
+
@pc.model_validator(mode="before")
|
|
31
|
+
@classmethod
|
|
32
|
+
def handle_legacy_format(cls, data):
|
|
33
|
+
"""Handle legacy 'angles' list format and 'axis' field."""
|
|
34
|
+
if isinstance(data, dict):
|
|
35
|
+
if "angles" in data and "angle" not in data:
|
|
36
|
+
angles = data["angles"]
|
|
37
|
+
if isinstance(angles, list) and len(angles) > 0:
|
|
38
|
+
data["angle"] = angles[0]
|
|
39
|
+
else:
|
|
40
|
+
data["angle"] = angles
|
|
41
|
+
if "axis" in data and "axes" not in data:
|
|
42
|
+
data["axes"] = data["axis"]
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RotationMetadata(pc.BaseModel):
|
|
47
|
+
"""Metadata for TOML files."""
|
|
48
|
+
|
|
49
|
+
coordinate_system: ty.Literal["LPS"] = "LPS"
|
|
50
|
+
index_order: IndexOrder = IndexOrder.ITK
|
|
51
|
+
angle_units: AngleUnits = AngleUnits.RADIANS
|
|
52
|
+
timestamp: str = pc.Field(default_factory=lambda: dt.datetime.now().isoformat())
|
|
53
|
+
volume_label: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RotationSequence(pc.BaseModel):
|
|
57
|
+
"""Complete rotation sequence.
|
|
58
|
+
|
|
59
|
+
Both angles and axes are always stored in the current convention/units:
|
|
60
|
+
- Angles: stored in units specified by metadata.angle_units
|
|
61
|
+
- Axes: stored in convention specified by metadata.index_order
|
|
62
|
+
|
|
63
|
+
When convention/units change in the UI, all existing rotations are converted.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
model_config = pc.ConfigDict(frozen=False)
|
|
67
|
+
|
|
68
|
+
metadata: RotationMetadata = pc.Field(default_factory=RotationMetadata)
|
|
69
|
+
angles_list: list[RotationStep] = pc.Field(default_factory=list)
|
|
70
|
+
mpr_origin: list[float] = pc.Field(
|
|
71
|
+
default_factory=lambda: [0.0, 0.0, 0.0],
|
|
72
|
+
description="MPR origin position [x, y, z] in LPS coordinates",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@pc.field_validator("mpr_origin")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_mpr_origin(cls, v):
|
|
78
|
+
"""Ensure mpr_origin is a 3-element list of floats."""
|
|
79
|
+
if not isinstance(v, list) or len(v) != 3:
|
|
80
|
+
raise ValueError("mpr_origin must be a 3-element list [x, y, z]")
|
|
81
|
+
return [float(x) for x in v]
|
|
82
|
+
|
|
83
|
+
@pc.field_validator("angles_list", mode="before")
|
|
84
|
+
@classmethod
|
|
85
|
+
def convert_legacy_list(cls, v):
|
|
86
|
+
"""Convert legacy list of dicts to list of RotationStep."""
|
|
87
|
+
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
|
88
|
+
return [RotationStep(**item) for item in v]
|
|
89
|
+
return v
|
|
90
|
+
|
|
91
|
+
def to_dict_for_ui(self) -> dict:
|
|
92
|
+
"""Convert to UI format.
|
|
93
|
+
|
|
94
|
+
Angles are passed through in their current units (metadata.angle_units).
|
|
95
|
+
UI expects 'angles' as a list for backward compatibility.
|
|
96
|
+
"""
|
|
97
|
+
return {
|
|
98
|
+
"angles_list": [
|
|
99
|
+
{
|
|
100
|
+
"axes": step.axes,
|
|
101
|
+
"angles": [step.angle],
|
|
102
|
+
"visible": step.visible,
|
|
103
|
+
"name": step.name,
|
|
104
|
+
"name_editable": step.name_editable,
|
|
105
|
+
"deletable": step.deletable,
|
|
106
|
+
}
|
|
107
|
+
for step in self.angles_list
|
|
108
|
+
],
|
|
109
|
+
"mpr_origin": self.mpr_origin,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_ui_dict(cls, data: dict, volume_label: str = "") -> "RotationSequence":
|
|
114
|
+
"""Create from UI format.
|
|
115
|
+
|
|
116
|
+
Angles from UI are stored as-is (in current UI units).
|
|
117
|
+
Caller should set metadata.angle_units to match the UI's current units.
|
|
118
|
+
"""
|
|
119
|
+
angles_list = [RotationStep(**step) for step in data.get("angles_list", [])]
|
|
120
|
+
metadata = RotationMetadata(volume_label=volume_label)
|
|
121
|
+
mpr_origin = data.get("mpr_origin", [0.0, 0.0, 0.0])
|
|
122
|
+
return cls(metadata=metadata, angles_list=angles_list, mpr_origin=mpr_origin)
|
|
123
|
+
|
|
124
|
+
def to_toml(self) -> str:
|
|
125
|
+
"""Serialize to TOML using stored serialization preferences."""
|
|
126
|
+
data = self.model_dump(mode="json")
|
|
127
|
+
return tk.dumps(data)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_toml(cls, toml_content: str) -> "RotationSequence":
|
|
131
|
+
"""Deserialize from TOML (no conversions - loads as-is)."""
|
|
132
|
+
doc = tk.loads(toml_content)
|
|
133
|
+
data = dict(doc)
|
|
134
|
+
return cls(**data)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_file(cls, path: pl.Path) -> "RotationSequence":
|
|
138
|
+
"""Load from TOML file."""
|
|
139
|
+
with open(path, "r") as f:
|
|
140
|
+
return cls.from_toml(f.read())
|
|
141
|
+
|
|
142
|
+
def to_file(self, path: pl.Path):
|
|
143
|
+
"""Save to TOML file using stored serialization preferences."""
|
|
144
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with open(path, "w") as f:
|
|
146
|
+
f.write(self.to_toml())
|
|
@@ -8,7 +8,7 @@ import pydantic_settings as ps
|
|
|
8
8
|
import vtk
|
|
9
9
|
|
|
10
10
|
from .mesh import Mesh
|
|
11
|
-
from .orientation import AngleUnits,
|
|
11
|
+
from .orientation import AngleUnits, IndexOrder
|
|
12
12
|
from .rotation import RotationSequence
|
|
13
13
|
from .segmentation import Segmentation
|
|
14
14
|
from .types import RGBColor
|
|
@@ -137,17 +137,9 @@ class Scene(ps.BaseSettings):
|
|
|
137
137
|
default=20,
|
|
138
138
|
description="Maximum number of MPR rotations supported",
|
|
139
139
|
)
|
|
140
|
-
angle_units: AngleUnits = pc.Field(
|
|
141
|
-
default=AngleUnits.RADIANS,
|
|
142
|
-
description="Units for angle measurements in rotation serialization",
|
|
143
|
-
)
|
|
144
140
|
coordinate_system: str = pc.Field(
|
|
145
141
|
default="LPS", description="Coordinate system orientation (e.g., LPS, RAS, LAS)"
|
|
146
142
|
)
|
|
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)",
|
|
150
|
-
)
|
|
151
143
|
mpr_crosshairs_enabled: bool = pc.Field(
|
|
152
144
|
default=True, description="Show crosshair lines indicating slice intersections"
|
|
153
145
|
)
|
|
@@ -200,6 +192,9 @@ class Scene(ps.BaseSettings):
|
|
|
200
192
|
self.mpr_rotation_sequence.metadata.volume_label
|
|
201
193
|
)
|
|
202
194
|
|
|
195
|
+
if self.mpr_rotation_sequence.mpr_origin:
|
|
196
|
+
self.mpr_origin = list(self.mpr_rotation_sequence.mpr_origin)
|
|
197
|
+
|
|
203
198
|
return self
|
|
204
199
|
|
|
205
200
|
# VTK objects as private attributes
|
|
@@ -660,8 +660,8 @@ class UI:
|
|
|
660
660
|
vuetify.VLabel("Convention:")
|
|
661
661
|
with vuetify.VCol(cols="8"):
|
|
662
662
|
vuetify.VSelect(
|
|
663
|
-
v_model=("
|
|
664
|
-
items=("
|
|
663
|
+
v_model=("index_order", "itk"),
|
|
664
|
+
items=("index_order_items", []),
|
|
665
665
|
item_title="text",
|
|
666
666
|
item_value="value",
|
|
667
667
|
dense=True,
|
|
@@ -1,211 +0,0 @@
|
|
|
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))
|
|
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
|
|
File without changes
|
|
File without changes
|