cardio 2025.9.0__py3-none-any.whl → 2025.10.1__py3-none-any.whl
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/__init__.py +2 -1
- cardio/app.py +6 -13
- cardio/logic.py +151 -18
- cardio/mesh.py +1 -2
- cardio/object.py +7 -7
- cardio/orientation.py +215 -0
- cardio/scene.py +74 -43
- cardio/segmentation.py +5 -3
- cardio/ui.py +231 -32
- cardio/utils.py +4 -47
- cardio/volume.py +124 -115
- {cardio-2025.9.0.dist-info → cardio-2025.10.1.dist-info}/METADATA +2 -2
- {cardio-2025.9.0.dist-info → cardio-2025.10.1.dist-info}/RECORD +15 -14
- {cardio-2025.9.0.dist-info → cardio-2025.10.1.dist-info}/WHEEL +0 -0
- {cardio-2025.9.0.dist-info → cardio-2025.10.1.dist-info}/entry_points.txt +0 -0
cardio/__init__.py
CHANGED
cardio/app.py
CHANGED
|
@@ -2,49 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
# Third Party
|
|
4
4
|
import pydantic_settings as ps
|
|
5
|
-
import tomlkit as tk
|
|
6
5
|
import trame as tm
|
|
6
|
+
import trame.decorators
|
|
7
7
|
|
|
8
|
+
# Internal
|
|
8
9
|
from . import __version__
|
|
9
10
|
from .logic import Logic
|
|
10
|
-
|
|
11
|
-
# Internal
|
|
12
11
|
from .scene import Scene
|
|
13
12
|
from .ui import UI
|
|
14
13
|
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
@tm.decorators.TrameApp()
|
|
16
|
+
class CardioApp:
|
|
17
|
+
def __init__(self, server=None):
|
|
18
|
+
self.server = tm.app.get_server(server, client_type="vue3")
|
|
19
19
|
|
|
20
|
-
# Add config file argument to Trame's parser
|
|
21
20
|
self.server.cli.add_argument(
|
|
22
21
|
"--config", help="TOML configuration file.", dest="cfg_file", required=False
|
|
23
22
|
)
|
|
24
23
|
|
|
25
|
-
# Add version argument
|
|
26
24
|
self.server.cli.add_argument(
|
|
27
25
|
"--version", action="version", version=f"cardio {__version__}"
|
|
28
26
|
)
|
|
29
27
|
|
|
30
|
-
# Create CLI settings source with Trame's parser - enable argument parsing
|
|
31
28
|
cli_settings = ps.CliSettingsSource(
|
|
32
29
|
Scene, root_parser=self.server.cli, cli_parse_args=True
|
|
33
30
|
)
|
|
34
31
|
|
|
35
|
-
# Parse arguments to get config file path (use parse_known_args to avoid conflicts)
|
|
36
32
|
args, unknown = self.server.cli.parse_known_args()
|
|
37
33
|
config_file = getattr(args, "cfg_file", None)
|
|
38
34
|
|
|
39
|
-
# Set the CLI source and config file on the Scene class temporarily
|
|
40
35
|
Scene._cli_source = cli_settings
|
|
41
36
|
Scene._config_file = config_file
|
|
42
37
|
|
|
43
38
|
try:
|
|
44
|
-
# Create Scene with CLI and config file support
|
|
45
39
|
scene = Scene()
|
|
46
40
|
finally:
|
|
47
|
-
# Clean up class attributes
|
|
48
41
|
if hasattr(Scene, "_cli_source"):
|
|
49
42
|
delattr(Scene, "_cli_source")
|
|
50
43
|
if hasattr(Scene, "_config_file"):
|
cardio/logic.py
CHANGED
|
@@ -21,6 +21,17 @@ class Logic:
|
|
|
21
21
|
for volume in self.scene.volumes
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
# Initialize angle units items for dropdown
|
|
25
|
+
self.server.state.angle_units_items = [
|
|
26
|
+
{"text": "Degrees", "value": "degrees"},
|
|
27
|
+
{"text": "Radians", "value": "radians"},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Initialize slice bounds (will be updated when active volume changes)
|
|
31
|
+
self.server.state.axial_slice_bounds = [0.0, 100.0]
|
|
32
|
+
self.server.state.sagittal_slice_bounds = [0.0, 100.0]
|
|
33
|
+
self.server.state.coronal_slice_bounds = [0.0, 100.0]
|
|
34
|
+
|
|
24
35
|
self.server.state.change("frame")(self.update_frame)
|
|
25
36
|
self.server.state.change("playing")(self.play)
|
|
26
37
|
self.server.state.change("theme_mode")(self.sync_background_color)
|
|
@@ -34,12 +45,16 @@ class Logic:
|
|
|
34
45
|
)
|
|
35
46
|
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
|
36
47
|
self.server.state.change("mpr_rotation_sequence")(self.update_mpr_rotation)
|
|
48
|
+
self.server.state.change("angle_units")(self.sync_angle_units)
|
|
37
49
|
|
|
38
|
-
# Add handlers for individual rotation angles
|
|
39
|
-
for i in range(
|
|
50
|
+
# Add handlers for individual rotation angles and visibility
|
|
51
|
+
for i in range(self.scene.max_mpr_rotations):
|
|
40
52
|
self.server.state.change(f"mpr_rotation_angle_{i}")(
|
|
41
53
|
self.update_mpr_rotation
|
|
42
54
|
)
|
|
55
|
+
self.server.state.change(f"mpr_rotation_visible_{i}")(
|
|
56
|
+
self.update_mpr_rotation
|
|
57
|
+
)
|
|
43
58
|
|
|
44
59
|
# Initialize visibility state variables
|
|
45
60
|
for m in self.scene.meshes:
|
|
@@ -122,6 +137,7 @@ class Logic:
|
|
|
122
137
|
self.server.controller.increment_frame = self.increment_frame
|
|
123
138
|
self.server.controller.decrement_frame = self.decrement_frame
|
|
124
139
|
self.server.controller.screenshot = self.screenshot
|
|
140
|
+
self.server.controller.save_rotation_angles = self.save_rotation_angles
|
|
125
141
|
self.server.controller.reset_all = self.reset_all
|
|
126
142
|
self.server.controller.close_application = self.close_application
|
|
127
143
|
|
|
@@ -142,22 +158,24 @@ class Logic:
|
|
|
142
158
|
self.server.state.mpr_level = self.scene.mpr_level
|
|
143
159
|
self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
|
|
144
160
|
self.server.state.mpr_rotation_sequence = self.scene.mpr_rotation_sequence
|
|
161
|
+
self.server.state.angle_units = self.scene.angle_units.value
|
|
145
162
|
|
|
146
163
|
# Initialize MPR presets data
|
|
147
164
|
try:
|
|
148
165
|
from .window_level import presets
|
|
149
166
|
|
|
150
167
|
self.server.state.mpr_presets = [
|
|
151
|
-
{"text":
|
|
152
|
-
]
|
|
168
|
+
{"text": "Select W/L...", "value": None}
|
|
169
|
+
] + [{"text": preset.name, "value": key} for key, preset in presets.items()]
|
|
153
170
|
except Exception as e:
|
|
154
171
|
print(f"Error initializing MPR presets: {e}")
|
|
155
172
|
self.server.state.mpr_presets = []
|
|
156
173
|
|
|
157
|
-
# Initialize rotation angle states
|
|
158
|
-
for i in range(
|
|
174
|
+
# Initialize rotation angle states
|
|
175
|
+
for i in range(self.scene.max_mpr_rotations):
|
|
159
176
|
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
160
177
|
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
178
|
+
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
161
179
|
|
|
162
180
|
# Apply initial preset to ensure window/level values are set correctly
|
|
163
181
|
# Only update state values, don't call update methods yet since MPR may not be enabled
|
|
@@ -417,7 +435,7 @@ class Logic:
|
|
|
417
435
|
|
|
418
436
|
@asynchronous.task
|
|
419
437
|
async def screenshot(self):
|
|
420
|
-
dr = dt.datetime.now().strftime(self.scene.
|
|
438
|
+
dr = dt.datetime.now().strftime(self.scene.timestamp_format)
|
|
421
439
|
dr = self.scene.screenshot_directory / dr
|
|
422
440
|
dr.mkdir(parents=True, exist_ok=True)
|
|
423
441
|
|
|
@@ -442,6 +460,65 @@ class Logic:
|
|
|
442
460
|
1 / self.server.state.bpm * 60 / self.scene.nframes
|
|
443
461
|
)
|
|
444
462
|
|
|
463
|
+
@asynchronous.task
|
|
464
|
+
async def save_rotation_angles(self):
|
|
465
|
+
"""Save current rotation angles to a TOML file."""
|
|
466
|
+
import tomlkit as tk
|
|
467
|
+
|
|
468
|
+
# Get current timestamp
|
|
469
|
+
timestamp = dt.datetime.now()
|
|
470
|
+
timestamp_str = timestamp.strftime(self.scene.timestamp_format)
|
|
471
|
+
iso_timestamp = timestamp.isoformat()
|
|
472
|
+
|
|
473
|
+
# Get active volume label
|
|
474
|
+
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
|
475
|
+
if not active_volume_label:
|
|
476
|
+
print("Warning: No active volume selected for rotation saving")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
# Create directory structure
|
|
480
|
+
save_dir = self.scene.rotations_directory / active_volume_label
|
|
481
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
|
|
483
|
+
# Create TOML structure
|
|
484
|
+
doc = tk.document()
|
|
485
|
+
|
|
486
|
+
# Metadata section
|
|
487
|
+
metadata = tk.table()
|
|
488
|
+
metadata["coordinate_system"] = self.scene.coordinate_system
|
|
489
|
+
metadata["units"] = self.scene.angle_units.value
|
|
490
|
+
metadata["timestamp"] = iso_timestamp
|
|
491
|
+
metadata["volume_label"] = active_volume_label
|
|
492
|
+
doc["metadata"] = metadata
|
|
493
|
+
|
|
494
|
+
# Slice positions section
|
|
495
|
+
slice_positions = tk.table()
|
|
496
|
+
slice_positions["axial"] = getattr(self.server.state, "axial_slice", 0.5)
|
|
497
|
+
slice_positions["sagittal"] = getattr(self.server.state, "sagittal_slice", 0.5)
|
|
498
|
+
slice_positions["coronal"] = getattr(self.server.state, "coronal_slice", 0.5)
|
|
499
|
+
doc["slice_positions"] = slice_positions
|
|
500
|
+
|
|
501
|
+
# Rotations section (array of tables)
|
|
502
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
503
|
+
rotations_array = tk.aot()
|
|
504
|
+
|
|
505
|
+
for i, rotation_def in enumerate(rotation_sequence):
|
|
506
|
+
rotation = tk.table()
|
|
507
|
+
rotation["axis"] = rotation_def["axis"]
|
|
508
|
+
angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
509
|
+
rotation["angle"] = float(angle)
|
|
510
|
+
rotation["visible"] = getattr(
|
|
511
|
+
self.server.state, f"mpr_rotation_visible_{i}", True
|
|
512
|
+
)
|
|
513
|
+
rotations_array.append(rotation)
|
|
514
|
+
|
|
515
|
+
doc["rotations"] = rotations_array
|
|
516
|
+
|
|
517
|
+
# Save to file
|
|
518
|
+
output_path = save_dir / f"{timestamp_str}.toml"
|
|
519
|
+
with open(output_path, "w") as f:
|
|
520
|
+
f.write(tk.dumps(doc))
|
|
521
|
+
|
|
445
522
|
def reset_all(self):
|
|
446
523
|
self.server.state.frame = 0
|
|
447
524
|
self.server.state.playing = False
|
|
@@ -465,6 +542,16 @@ class Logic:
|
|
|
465
542
|
)
|
|
466
543
|
self.server.controller.view_update()
|
|
467
544
|
|
|
545
|
+
def sync_angle_units(self, angle_units, **kwargs):
|
|
546
|
+
"""Sync angle units selection - updates the scene configuration."""
|
|
547
|
+
from .utils import AngleUnit
|
|
548
|
+
|
|
549
|
+
# Update the scene's angle_units field based on UI selection
|
|
550
|
+
if angle_units == "degrees":
|
|
551
|
+
self.scene.angle_units = AngleUnit.DEGREES
|
|
552
|
+
elif angle_units == "radians":
|
|
553
|
+
self.scene.angle_units = AngleUnit.RADIANS
|
|
554
|
+
|
|
468
555
|
def _initialize_clipping_state(self):
|
|
469
556
|
"""Initialize clipping state variables for all objects."""
|
|
470
557
|
# Initialize mesh clipping state
|
|
@@ -546,6 +633,28 @@ class Logic:
|
|
|
546
633
|
if not active_volume:
|
|
547
634
|
return
|
|
548
635
|
|
|
636
|
+
# Update slice bounds based on active volume
|
|
637
|
+
try:
|
|
638
|
+
bounds = active_volume.get_physical_bounds()
|
|
639
|
+
self.server.state.axial_slice_bounds = [bounds[4], bounds[5]] # Z bounds
|
|
640
|
+
self.server.state.sagittal_slice_bounds = [bounds[0], bounds[1]] # X bounds
|
|
641
|
+
self.server.state.coronal_slice_bounds = [bounds[2], bounds[3]] # Y bounds
|
|
642
|
+
|
|
643
|
+
# Initialize slice positions to volume center if they are currently 0.0 (scene defaults)
|
|
644
|
+
if self.server.state.axial_slice == 0.0:
|
|
645
|
+
self.server.state.axial_slice = (bounds[4] + bounds[5]) / 2 # Z center
|
|
646
|
+
if self.server.state.sagittal_slice == 0.0:
|
|
647
|
+
self.server.state.sagittal_slice = (
|
|
648
|
+
bounds[0] + bounds[1]
|
|
649
|
+
) / 2 # X center
|
|
650
|
+
if self.server.state.coronal_slice == 0.0:
|
|
651
|
+
self.server.state.coronal_slice = (
|
|
652
|
+
bounds[2] + bounds[3]
|
|
653
|
+
) / 2 # Y center
|
|
654
|
+
except (RuntimeError, IndexError) as e:
|
|
655
|
+
print(f"Error: Cannot get bounds for volume '{active_volume_label}': {e}")
|
|
656
|
+
return
|
|
657
|
+
|
|
549
658
|
# Create MPR actors for current frame
|
|
550
659
|
current_frame = getattr(self.server.state, "frame", 0)
|
|
551
660
|
mpr_actors = active_volume.get_mpr_actors_for_frame(current_frame)
|
|
@@ -661,6 +770,14 @@ class Logic:
|
|
|
661
770
|
level = getattr(self.server.state, "mpr_level", 40.0)
|
|
662
771
|
current_frame = getattr(self.server.state, "frame", 0)
|
|
663
772
|
|
|
773
|
+
# Check if this change is from manual adjustment (not from preset)
|
|
774
|
+
# by checking if we're not in the middle of a preset update
|
|
775
|
+
if not getattr(self, "_updating_from_preset", False):
|
|
776
|
+
# Reset preset selection when manually adjusting window/level
|
|
777
|
+
current_preset = getattr(self.server.state, "mpr_window_level_preset", None)
|
|
778
|
+
if current_preset is not None:
|
|
779
|
+
self.server.state.mpr_window_level_preset = None
|
|
780
|
+
|
|
664
781
|
# Update window/level for MPR actors
|
|
665
782
|
active_volume.update_mpr_window_level(current_frame, window, level)
|
|
666
783
|
|
|
@@ -671,13 +788,24 @@ class Logic:
|
|
|
671
788
|
"""Update MPR window/level when preset changes."""
|
|
672
789
|
from .window_level import presets
|
|
673
790
|
|
|
791
|
+
# Handle None value (Select W/L... option) - do nothing
|
|
792
|
+
if mpr_window_level_preset is None:
|
|
793
|
+
return
|
|
794
|
+
|
|
674
795
|
if mpr_window_level_preset in presets:
|
|
675
796
|
preset = presets[mpr_window_level_preset]
|
|
676
|
-
self.server.state.mpr_window = preset.window
|
|
677
|
-
self.server.state.mpr_level = preset.level
|
|
678
797
|
|
|
679
|
-
#
|
|
680
|
-
self.
|
|
798
|
+
# Set flag to indicate we're updating from preset
|
|
799
|
+
self._updating_from_preset = True
|
|
800
|
+
try:
|
|
801
|
+
self.server.state.mpr_window = preset.window
|
|
802
|
+
self.server.state.mpr_level = preset.level
|
|
803
|
+
|
|
804
|
+
# Update the actual MPR views with new window/level
|
|
805
|
+
self.update_mpr_window_level()
|
|
806
|
+
finally:
|
|
807
|
+
# Always clear the flag
|
|
808
|
+
self._updating_from_preset = False
|
|
681
809
|
|
|
682
810
|
def update_mpr_rotation(self, **kwargs):
|
|
683
811
|
"""Update MPR views when rotation changes."""
|
|
@@ -704,13 +832,17 @@ class Logic:
|
|
|
704
832
|
coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
|
|
705
833
|
current_frame = getattr(self.server.state, "frame", 0)
|
|
706
834
|
|
|
707
|
-
# Get rotation data
|
|
835
|
+
# Get rotation data - include all visible rotations
|
|
708
836
|
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
|
709
837
|
rotation_angles = {}
|
|
838
|
+
|
|
839
|
+
# Include all visible rotations regardless of position
|
|
710
840
|
for i in range(len(rotation_sequence)):
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
841
|
+
is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
842
|
+
if is_visible:
|
|
843
|
+
rotation_angles[i] = getattr(
|
|
844
|
+
self.server.state, f"mpr_rotation_angle_{i}", 0
|
|
845
|
+
)
|
|
714
846
|
|
|
715
847
|
# Update slice positions with rotation
|
|
716
848
|
active_volume.update_slice_positions(
|
|
@@ -744,7 +876,7 @@ class Logic:
|
|
|
744
876
|
self.server.state.mpr_rotation_sequence = sequence
|
|
745
877
|
|
|
746
878
|
# Reset angle states for all removed rotations
|
|
747
|
-
for i in range(index,
|
|
879
|
+
for i in range(index, self.scene.max_mpr_rotations):
|
|
748
880
|
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
749
881
|
|
|
750
882
|
self.update_mpr_rotation_labels()
|
|
@@ -752,8 +884,9 @@ class Logic:
|
|
|
752
884
|
def reset_mpr_rotations(self):
|
|
753
885
|
"""Reset all MPR rotations."""
|
|
754
886
|
self.server.state.mpr_rotation_sequence = []
|
|
755
|
-
for i in range(
|
|
887
|
+
for i in range(self.scene.max_mpr_rotations):
|
|
756
888
|
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
|
889
|
+
setattr(self.server.state, f"mpr_rotation_visible_{i}", True)
|
|
757
890
|
self.update_mpr_rotation_labels()
|
|
758
891
|
|
|
759
892
|
def update_mpr_rotation_labels(self):
|
|
@@ -766,7 +899,7 @@ class Logic:
|
|
|
766
899
|
f"{rotation['axis']} ({i + 1})",
|
|
767
900
|
)
|
|
768
901
|
# Clear unused labels
|
|
769
|
-
for i in range(len(rotation_sequence),
|
|
902
|
+
for i in range(len(rotation_sequence), self.scene.max_mpr_rotations):
|
|
770
903
|
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
|
771
904
|
|
|
772
905
|
@asynchronous.task
|
cardio/mesh.py
CHANGED
|
@@ -3,7 +3,6 @@ import enum
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
# Third Party
|
|
6
|
-
import numpy as np
|
|
7
6
|
import pydantic as pc
|
|
8
7
|
import vtk
|
|
9
8
|
|
|
@@ -50,7 +49,7 @@ class Mesh(Object):
|
|
|
50
49
|
"""Mesh object with subdivision support."""
|
|
51
50
|
|
|
52
51
|
pattern: str = pc.Field(
|
|
53
|
-
default="
|
|
52
|
+
default="{frame}.obj", description="Filename pattern with $frame placeholder"
|
|
54
53
|
)
|
|
55
54
|
_actors: list[vtk.vtkActor] = pc.PrivateAttr(default_factory=list)
|
|
56
55
|
properties: vtkPropertyConfig = pc.Field(
|
cardio/object.py
CHANGED
|
@@ -3,7 +3,6 @@ import functools
|
|
|
3
3
|
import logging
|
|
4
4
|
import pathlib as pl
|
|
5
5
|
import re
|
|
6
|
-
import string
|
|
7
6
|
|
|
8
7
|
# Third Party
|
|
9
8
|
import pydantic as pc
|
|
@@ -22,6 +21,8 @@ class Object(pc.BaseModel):
|
|
|
22
21
|
pattern: str | None = pc.Field(
|
|
23
22
|
default=None, description="Filename pattern with ${frame} placeholder"
|
|
24
23
|
)
|
|
24
|
+
frame_start: pc.NonNegativeInt = 0
|
|
25
|
+
frame_interval: pc.PositiveInt = 1
|
|
25
26
|
file_paths: list[str] | None = pc.Field(
|
|
26
27
|
default=None, description="Static list of file paths relative to directory"
|
|
27
28
|
)
|
|
@@ -54,10 +55,10 @@ class Object(pc.BaseModel):
|
|
|
54
55
|
if not isinstance(v, str):
|
|
55
56
|
raise ValueError("pattern must be a string")
|
|
56
57
|
|
|
57
|
-
if not re.match(r"^[a-zA-Z0-9_\-.${}]+$", v):
|
|
58
|
+
if not re.match(r"^[a-zA-Z0-9_\-.${}:]+$", v):
|
|
58
59
|
raise ValueError("Pattern contains unsafe characters")
|
|
59
60
|
|
|
60
|
-
if "
|
|
61
|
+
if "frame" not in v:
|
|
61
62
|
raise ValueError("Pattern must contain $frame placeholder")
|
|
62
63
|
|
|
63
64
|
return v
|
|
@@ -78,8 +79,7 @@ class Object(pc.BaseModel):
|
|
|
78
79
|
def path_for_frame(self, frame: int) -> pl.Path:
|
|
79
80
|
if self.pattern is None:
|
|
80
81
|
raise ValueError("Cannot use path_for_frame with static file_paths")
|
|
81
|
-
|
|
82
|
-
filename = template.safe_substitute(frame=frame)
|
|
82
|
+
filename = self.pattern.format(frame=frame)
|
|
83
83
|
return self.directory / filename
|
|
84
84
|
|
|
85
85
|
@functools.cached_property
|
|
@@ -89,13 +89,13 @@ class Object(pc.BaseModel):
|
|
|
89
89
|
return [self.directory / path for path in self.file_paths]
|
|
90
90
|
|
|
91
91
|
paths = []
|
|
92
|
-
frame =
|
|
92
|
+
frame = self.frame_start
|
|
93
93
|
while True:
|
|
94
94
|
path = self.path_for_frame(frame)
|
|
95
95
|
if not path.is_file():
|
|
96
96
|
break
|
|
97
97
|
paths.append(path)
|
|
98
|
-
frame +=
|
|
98
|
+
frame += self.frame_interval
|
|
99
99
|
return paths
|
|
100
100
|
|
|
101
101
|
@property
|
cardio/orientation.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# System
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
# Third Party
|
|
5
|
+
import numpy as np
|
|
6
|
+
import itk
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# DICOM LPS canonical orientation vector mappings
|
|
10
|
+
class EulerAxis(Enum):
|
|
11
|
+
X = "X"
|
|
12
|
+
Y = "Y"
|
|
13
|
+
Z = "Z"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AngleUnits(Enum):
|
|
17
|
+
DEGREES = "degrees"
|
|
18
|
+
RADIANS = "radians"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
AXCODE_VECTORS = {
|
|
22
|
+
"L": (1, 0, 0),
|
|
23
|
+
"R": (-1, 0, 0),
|
|
24
|
+
"P": (0, 1, 0),
|
|
25
|
+
"A": (0, -1, 0),
|
|
26
|
+
"S": (0, 0, 1),
|
|
27
|
+
"I": (0, 0, -1),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_valid_axcode(axcode: str) -> bool:
|
|
32
|
+
"""Validate medical imaging axcode string.
|
|
33
|
+
|
|
34
|
+
Valid axcode must have exactly 3 uppercase characters with:
|
|
35
|
+
- One of L or R (Left/Right)
|
|
36
|
+
- One of A or P (Anterior/Posterior)
|
|
37
|
+
- One of S or I (Superior/Inferior)
|
|
38
|
+
- No repeated characters
|
|
39
|
+
"""
|
|
40
|
+
if len(axcode) != 3:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
if len(set(axcode)) != 3:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
has_lr = any(c in axcode for c in "LR")
|
|
47
|
+
has_ap = any(c in axcode for c in "AP")
|
|
48
|
+
has_si = any(c in axcode for c in "SI")
|
|
49
|
+
|
|
50
|
+
valid_chars = set("LRAPSI")
|
|
51
|
+
has_only_valid = all(c in valid_chars for c in axcode)
|
|
52
|
+
|
|
53
|
+
return has_lr and has_ap and has_si and has_only_valid
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_righthanded_axcode(axcode: str) -> bool:
|
|
57
|
+
"""Check if axcode represents a right-handed coordinate system.
|
|
58
|
+
|
|
59
|
+
Right-handed when cross product of first two axes equals third axis.
|
|
60
|
+
Uses DICOM LPS canonical orientation.
|
|
61
|
+
"""
|
|
62
|
+
if not is_valid_axcode(axcode):
|
|
63
|
+
raise ValueError(f"Invalid axcode: {axcode}")
|
|
64
|
+
|
|
65
|
+
v1 = np.array(AXCODE_VECTORS[axcode[0]])
|
|
66
|
+
v2 = np.array(AXCODE_VECTORS[axcode[1]])
|
|
67
|
+
v3 = np.array(AXCODE_VECTORS[axcode[2]])
|
|
68
|
+
|
|
69
|
+
cross = np.cross(v1, v2)
|
|
70
|
+
|
|
71
|
+
return np.array_equal(cross, v3)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def axcode_transform_matrix(from_axcode: str, to_axcode: str) -> np.ndarray:
|
|
75
|
+
"""Calculate transformation matrix between two coordinate spaces.
|
|
76
|
+
|
|
77
|
+
Returns matrix T such that: new_coords = T @ old_coords
|
|
78
|
+
Uses DICOM LPS canonical orientation for vector mappings.
|
|
79
|
+
"""
|
|
80
|
+
if not is_valid_axcode(from_axcode):
|
|
81
|
+
raise ValueError(f"Invalid source axcode: {from_axcode}")
|
|
82
|
+
if not is_valid_axcode(to_axcode):
|
|
83
|
+
raise ValueError(f"Invalid target axcode: {to_axcode}")
|
|
84
|
+
|
|
85
|
+
# Create basis matrices (each column is a basis vector)
|
|
86
|
+
from_basis = np.array([AXCODE_VECTORS[c] for c in from_axcode]).T
|
|
87
|
+
to_basis = np.array([AXCODE_VECTORS[c] for c in to_axcode]).T
|
|
88
|
+
|
|
89
|
+
# Transformation matrix: T = to_basis @ from_basis^(-1)
|
|
90
|
+
return to_basis @ np.linalg.inv(from_basis)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def euler_angle_to_rotation_matrix(
|
|
94
|
+
axis: EulerAxis, angle: float, units: AngleUnits = AngleUnits.DEGREES
|
|
95
|
+
) -> np.ndarray:
|
|
96
|
+
"""Create rotation matrix for given axis and angle.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
axis: Rotation axis (X, Y, or Z)
|
|
100
|
+
angle: Rotation angle
|
|
101
|
+
units: Angle units (degrees or radians)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
3x3 rotation matrix
|
|
105
|
+
"""
|
|
106
|
+
match units:
|
|
107
|
+
case AngleUnits.DEGREES:
|
|
108
|
+
angle_rad = np.radians(angle)
|
|
109
|
+
case AngleUnits.RADIANS:
|
|
110
|
+
angle_rad = angle
|
|
111
|
+
|
|
112
|
+
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
|
|
113
|
+
|
|
114
|
+
match axis:
|
|
115
|
+
case EulerAxis.X:
|
|
116
|
+
return np.array([[1, 0, 0], [0, cos_a, -sin_a], [0, sin_a, cos_a]])
|
|
117
|
+
case EulerAxis.Y:
|
|
118
|
+
return np.array([[cos_a, 0, sin_a], [0, 1, 0], [-sin_a, 0, cos_a]])
|
|
119
|
+
case EulerAxis.Z:
|
|
120
|
+
return np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_axis_aligned(image) -> bool:
|
|
124
|
+
"""Check if ITK image orientation is axis-aligned.
|
|
125
|
+
|
|
126
|
+
An axis-aligned image has a direction matrix where:
|
|
127
|
+
- Each column has exactly one non-zero entry
|
|
128
|
+
- Non-zero entries are ±1
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
image: ITK image object
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if image is axis-aligned, False otherwise
|
|
135
|
+
"""
|
|
136
|
+
direction = itk.array_from_matrix(image.GetDirection())
|
|
137
|
+
|
|
138
|
+
# Check each column has exactly one non-zero entry
|
|
139
|
+
for col in range(direction.shape[1]):
|
|
140
|
+
non_zero_count = np.count_nonzero(direction[:, col])
|
|
141
|
+
if non_zero_count != 1:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# Check non-zero entries are ±1
|
|
145
|
+
non_zero_values = direction[direction != 0]
|
|
146
|
+
if not np.allclose(np.abs(non_zero_values), 1.0):
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def reset_direction(image):
|
|
153
|
+
"""Reset image direction to identity matrix, preserving physical extent."""
|
|
154
|
+
assert is_axis_aligned(image), "Input image must be axis-aligned"
|
|
155
|
+
|
|
156
|
+
origin = np.array(image.GetOrigin())
|
|
157
|
+
spacing = np.array(image.GetSpacing())
|
|
158
|
+
direction = itk.array_from_matrix(image.GetDirection())
|
|
159
|
+
size = np.array(image.GetLargestPossibleRegion().GetSize())
|
|
160
|
+
pixel_array = itk.array_from_image(image)
|
|
161
|
+
|
|
162
|
+
permutation = []
|
|
163
|
+
flips = []
|
|
164
|
+
|
|
165
|
+
for col in range(3):
|
|
166
|
+
row = np.nonzero(direction[:, col])[0][0]
|
|
167
|
+
permutation.append(row)
|
|
168
|
+
flips.append(direction[row, col] < 0)
|
|
169
|
+
|
|
170
|
+
array_permutation = [2 - p for p in reversed(permutation)]
|
|
171
|
+
pixel_array = np.transpose(pixel_array, array_permutation)
|
|
172
|
+
|
|
173
|
+
for i, should_flip in enumerate(reversed(flips)):
|
|
174
|
+
if should_flip:
|
|
175
|
+
pixel_array = np.flip(pixel_array, axis=i)
|
|
176
|
+
|
|
177
|
+
new_spacing = spacing[permutation]
|
|
178
|
+
|
|
179
|
+
adjusted_origin = origin.copy()
|
|
180
|
+
for i, should_flip in enumerate(flips):
|
|
181
|
+
if should_flip:
|
|
182
|
+
image_axis = permutation[i]
|
|
183
|
+
extent_vector = (
|
|
184
|
+
direction[:, image_axis] * (size[image_axis] - 1) * spacing[image_axis]
|
|
185
|
+
)
|
|
186
|
+
adjusted_origin += extent_vector
|
|
187
|
+
|
|
188
|
+
new_origin = adjusted_origin[permutation]
|
|
189
|
+
|
|
190
|
+
output = itk.image_from_array(pixel_array)
|
|
191
|
+
output.SetOrigin(new_origin)
|
|
192
|
+
output.SetSpacing(new_spacing)
|
|
193
|
+
|
|
194
|
+
return output
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def create_vtk_reslice_matrix(transform_3x3, origin):
|
|
198
|
+
"""Create 4x4 VTK reslice matrix from 3x3 transform and origin.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
transform_3x3: 3x3 coordinate transformation matrix
|
|
202
|
+
origin: 3-element origin position
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
vtk.vtkMatrix4x4 for use with VTK reslice operations
|
|
206
|
+
"""
|
|
207
|
+
import vtk
|
|
208
|
+
|
|
209
|
+
matrix = vtk.vtkMatrix4x4()
|
|
210
|
+
for i in range(3):
|
|
211
|
+
for j in range(3):
|
|
212
|
+
matrix.SetElement(i, j, transform_3x3[i, j])
|
|
213
|
+
matrix.SetElement(i, 3, origin[i])
|
|
214
|
+
matrix.SetElement(3, 3, 1.0)
|
|
215
|
+
return matrix
|