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.
Files changed (31) hide show
  1. {cardio-2026.1.0 → cardio-2026.1.2}/PKG-INFO +4 -4
  2. {cardio-2026.1.0 → cardio-2026.1.2}/README.md +3 -3
  3. {cardio-2026.1.0 → cardio-2026.1.2}/pyproject.toml +2 -2
  4. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/__init__.py +1 -1
  5. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/logic.py +84 -23
  6. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/orientation.py +4 -4
  7. cardio-2026.1.2/src/cardio/rotation.py +146 -0
  8. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/scene.py +4 -9
  9. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/ui.py +2 -2
  10. cardio-2026.1.0/src/cardio/rotation.py +0 -211
  11. {cardio-2026.1.0 → cardio-2026.1.2}/LICENSE +0 -0
  12. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/app.py +0 -0
  13. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/bone.toml +0 -0
  14. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/vascular_closed.toml +0 -0
  15. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/vascular_open.toml +0 -0
  16. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/assets/xray.toml +0 -0
  17. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/blend_transfer_functions.py +0 -0
  18. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/color_transfer_function.py +0 -0
  19. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/mesh.py +0 -0
  20. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/object.py +0 -0
  21. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/piecewise_function.py +0 -0
  22. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/property_config.py +0 -0
  23. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/screenshot.py +0 -0
  24. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/segmentation.py +0 -0
  25. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/transfer_function_pair.py +0 -0
  26. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/types.py +0 -0
  27. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/utils.py +0 -0
  28. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume.py +0 -0
  29. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume_property.py +0 -0
  30. {cardio-2026.1.0 → cardio-2026.1.2}/src/cardio/volume_property_presets.py +0 -0
  31. {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.0
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., `\*.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
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.0
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., `\*.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
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.0
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.0"
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.0"
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
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "window_level",
27
27
  ]
28
28
 
29
- __version__ = "2026.1.0"
29
+ __version__ = "2026.1.2"
@@ -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.axis_convention_items = [
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("axis_convention")(self.sync_axis_convention)
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 = self.scene.angle_units.value
193
- self.server.state.axis_convention = self.scene.axis_convention.value
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 the scene's angle_units field based on UI selection
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
- def sync_axis_convention(self, axis_convention, **kwargs):
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
- if axis_convention == "itk":
602
- self.scene.axis_convention = AxisConvention.ITK
603
- elif axis_convention == "roma":
604
- self.scene.axis_convention = AxisConvention.ROMA
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
- from enum import Enum
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(Enum):
8
+ class EulerAxis(enum.StrEnum):
9
9
  X = "X"
10
10
  Y = "Y"
11
11
  Z = "Z"
12
12
 
13
13
 
14
- class AxisConvention(Enum):
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(Enum):
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, AxisConvention
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=("axis_convention", "itk"),
664
- items=("axis_convention_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