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 CHANGED
@@ -23,6 +23,7 @@ __all__ = [
23
23
  "Logic",
24
24
  "load_volume_property_preset",
25
25
  "list_volume_property_presets",
26
+ "window_level",
26
27
  ]
27
28
 
28
- __version__ = "2025.9.0"
29
+ __version__ = "2025.10.1"
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
- class CardioApp(tm.app.TrameApp):
17
- def __init__(self, name=None):
18
- super().__init__(server=name, client_type="vue3")
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(20):
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": preset.name, "value": key} for key, preset in presets.items()
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 (up to 20 rotations like app.py)
158
- for i in range(20):
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.screenshot_subdirectory_format)
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
- # Update the actual MPR views with new window/level
680
- self.update_mpr_window_level()
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
- rotation_angles[i] = getattr(
712
- self.server.state, f"mpr_rotation_angle_{i}", 0
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, 20):
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(20):
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), 20):
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="${frame}.obj", description="Filename pattern with $frame placeholder"
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 "${frame}" not in v and "$frame" not in v:
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
- template = string.Template(self.pattern)
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 = 0
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 += 1
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