cardio 2023.1.2__py3-none-any.whl → 2025.8.0__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/scene.py CHANGED
@@ -1,78 +1,213 @@
1
+ # System
1
2
  import logging
3
+ import pathlib as pl
2
4
 
5
+ # Type adapters for list types to improve CLI integration
6
+ from typing import Annotated
7
+
8
+ # Third Party
3
9
  import numpy as np
4
- import tomlkit as tk
5
-
6
- # noinspection PyUnresolvedReferences
7
- import vtkmodules.vtkInteractionStyle
8
-
9
- # Required for rendering initialization, not necessary for
10
- # local rendering, but doesn't hurt to include it
11
- # noinspection PyUnresolvedReferences
12
- import vtkmodules.vtkRenderingOpenGL2 # noqa
13
-
14
- # Required for interactor initialization
15
- from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa
16
- from vtkmodules.vtkIOGeometry import vtkOBJReader
17
- from vtkmodules.vtkRenderingCore import (
18
- vtkRenderer,
19
- vtkRenderWindow,
20
- vtkRenderWindowInteractor,
21
- )
22
-
23
- from . import Mesh, Volume
24
-
25
-
26
- class Scene:
27
- def __init__(self, cfg_file: str):
28
- self.meshes: list[Mesh] = []
29
- self.volumes: list[Volume] = []
30
- self.nframes: int = None
31
- self.renderer: vtkRenderer = vtkRenderer()
32
- self.renderWindow: vtkRenderWindow = vtkRenderWindow()
33
- self.renderWindowInteractor: vtkRenderWindowInteractor = (
34
- vtkRenderWindowInteractor()
10
+ import pydantic as pc
11
+ import pydantic_settings as ps
12
+ import vtk
13
+ from pydantic import Field, PrivateAttr, TypeAdapter, field_validator, model_validator
14
+
15
+ # Internal
16
+ from .mesh import Mesh
17
+ from .segmentation import Segmentation
18
+ from .types import RGBColor
19
+ from .volume import Volume
20
+
21
+ MeshListAdapter = TypeAdapter(list[Mesh])
22
+ VolumeListAdapter = TypeAdapter(list[Volume])
23
+ SegmentationListAdapter = TypeAdapter(list[Segmentation])
24
+
25
+ # Create annotated types for better CLI integration
26
+ MeshList = Annotated[
27
+ list[Mesh],
28
+ Field(
29
+ description='List of mesh objects. CLI usage: --meshes \'[{"label":"mesh1","directory":"./data/mesh1"}]\''
30
+ ),
31
+ ]
32
+ VolumeList = Annotated[
33
+ list[Volume],
34
+ Field(
35
+ description='Volume objects. CLI: --volumes \'[{"label":"vol1","directory":"./data/vol1"}]\''
36
+ ),
37
+ ]
38
+ SegmentationList = Annotated[
39
+ list[Segmentation],
40
+ Field(
41
+ description='Segmentation objects. CLI: --segmentations \'[{"label":"seg1","directory":"./data/seg1"}]\''
42
+ ),
43
+ ]
44
+
45
+
46
+ class Background(pc.BaseModel):
47
+ light: RGBColor = Field(
48
+ default=(1.0, 1.0, 1.0),
49
+ description="Background color in light mode. CLI usage: --background.light '[0.8,0.9,1.0]'",
50
+ )
51
+ dark: RGBColor = Field(
52
+ default=(0.0, 0.0, 0.0),
53
+ description="Background color in dark mode. CLI usage: --background.dark '[0.1,0.1,0.2]'",
54
+ )
55
+
56
+
57
+ class Scene(ps.BaseSettings):
58
+ model_config = ps.SettingsConfigDict(
59
+ arbitrary_types_allowed=True,
60
+ populate_by_name=True,
61
+ cli_parse_args=False,
62
+ cli_use_class_docstring=True,
63
+ )
64
+
65
+ @classmethod
66
+ def settings_customise_sources(
67
+ cls,
68
+ settings_cls,
69
+ init_settings,
70
+ env_settings,
71
+ dotenv_settings,
72
+ file_secret_settings,
73
+ ):
74
+ # Get the CLI settings source and config file from the class if available
75
+ cli_source = getattr(cls, "_cli_source", None)
76
+ config_file = getattr(cls, "_config_file", None)
77
+
78
+ sources = [init_settings]
79
+
80
+ # Add CLI settings source if available
81
+ if cli_source is not None:
82
+ sources.append(cli_source)
83
+
84
+ # Add TOML config file source if config file is specified
85
+ if config_file is not None:
86
+ sources.append(
87
+ ps.TomlConfigSettingsSource(settings_cls, toml_file=config_file)
88
+ )
89
+
90
+ sources.extend([env_settings, file_secret_settings])
91
+ return tuple(sources)
92
+
93
+ project_name: str = "Cardio"
94
+ current_frame: int = 0
95
+ screenshot_directory: pl.Path = pl.Path("./data/screenshots")
96
+ screenshot_subdirectory_format: pl.Path = pl.Path("%Y-%m-%d-%H-%M-%S")
97
+ rotation_factor: float = 3.0
98
+ background: Background = Field(
99
+ default_factory=Background,
100
+ description='Background colors. CLI usage: \'{"light": [0.8, 0.9, 1.0], "dark": [0.1, 0.1, 0.2]}\'',
101
+ )
102
+ meshes: MeshList = Field(default_factory=list)
103
+ volumes: VolumeList = Field(default_factory=list)
104
+ segmentations: SegmentationList = Field(default_factory=list)
105
+
106
+ # Field validators for JSON string inputs
107
+ @field_validator("meshes", mode="before")
108
+ @classmethod
109
+ def validate_meshes(cls, v):
110
+ if isinstance(v, str):
111
+ return MeshListAdapter.validate_json(v)
112
+ return v
113
+
114
+ @field_validator("volumes", mode="before")
115
+ @classmethod
116
+ def validate_volumes(cls, v):
117
+ if isinstance(v, str):
118
+ return VolumeListAdapter.validate_json(v)
119
+ return v
120
+
121
+ @field_validator("segmentations", mode="before")
122
+ @classmethod
123
+ def validate_segmentations(cls, v):
124
+ if isinstance(v, str):
125
+ return SegmentationListAdapter.validate_json(v)
126
+ return v
127
+
128
+ # VTK objects as private attributes
129
+ _renderer: vtk.vtkRenderer = PrivateAttr(default_factory=vtk.vtkRenderer)
130
+ _renderWindow: vtk.vtkRenderWindow = PrivateAttr(
131
+ default_factory=vtk.vtkRenderWindow
132
+ )
133
+ _renderWindowInteractor: vtk.vtkRenderWindowInteractor = PrivateAttr(
134
+ default_factory=vtk.vtkRenderWindowInteractor
135
+ )
136
+
137
+ @property
138
+ def renderer(self) -> vtk.vtkRenderer:
139
+ return self._renderer
140
+
141
+ @property
142
+ def renderWindow(self) -> vtk.vtkRenderWindow:
143
+ return self._renderWindow
144
+
145
+ @property
146
+ def renderWindowInteractor(self) -> vtk.vtkRenderWindowInteractor:
147
+ return self._renderWindowInteractor
148
+
149
+ @model_validator(mode="after")
150
+ def setup_scene(self):
151
+ # Configure VTK objects
152
+ self._renderer.SetBackground(
153
+ *self.background.light,
35
154
  )
36
- self.renderWindowInteractor.GetInteractorStyle().SetCurrentStyleToTrackballCamera()
37
-
38
- with open(cfg_file, mode="rt", encoding="utf-8") as fp:
39
- self.cfg = tk.load(fp)
40
- for mesh_cfg in self.cfg["meshes"]:
41
- self.meshes += [Mesh(mesh_cfg, self.renderer)]
42
- for volume_cfg in self.cfg["volumes"]:
43
- self.volumes += [Volume(volume_cfg, self.renderer)]
44
- self.set_nframes()
45
-
46
- self.project_name = self.cfg["project_name"]
47
- self.current_frame = self.cfg["current_frame"] % self.nframes
48
- self.screenshot_directory = self.cfg["screenshot_directory"]
49
- self.screenshot_subdirectory_format = self.cfg["screenshot_subdirectory_format"]
50
- self.rotation_factor = self.cfg["rotation_factor"]
51
- self.background_r = self.cfg["background_r"]
52
- self.background_g = self.cfg["background_g"]
53
- self.background_b = self.cfg["background_b"]
54
-
55
- self.setup_rendering()
155
+ self._renderWindow.AddRenderer(self._renderer)
156
+ self._renderWindow.SetOffScreenRendering(True)
157
+ self._renderWindowInteractor.SetRenderWindow(self._renderWindow)
158
+ self._renderWindowInteractor.GetInteractorStyle().SetCurrentStyleToTrackballCamera()
159
+
160
+ # Configure all objects
56
161
  for mesh in self.meshes:
57
- mesh.setup_pipeline(self.current_frame)
162
+ mesh.configure_actors()
58
163
  for volume in self.volumes:
59
- volume.setup_pipeline(self.current_frame)
164
+ volume.configure_actors()
165
+ for segmentation in self.segmentations:
166
+ segmentation.configure_actors()
167
+
168
+ # Set current frame using nframes property
169
+ self.current_frame = self.current_frame % self.nframes
60
170
 
61
- def set_nframes(self):
171
+ # Setup rendering pipeline
172
+ self.setup_pipeline()
173
+
174
+ return self
175
+
176
+ @property
177
+ def nframes(self) -> int:
62
178
  ns = []
63
179
  ns += [len(m.actors) for m in self.meshes]
64
180
  ns += [len(v.actors) for v in self.volumes]
181
+ ns += [len(s.actors) for s in self.segmentations]
182
+ if not len(ns) > 0:
183
+ logging.warning("No objects were found to display.")
184
+ return 1
185
+ result = int(max(ns))
65
186
  ns = np.array(ns)
66
- assert len(ns) > 0
67
- assert np.all(ns == ns[0])
68
- self.nframes = int(ns[0])
187
+ if not np.all(ns == ns[0]):
188
+ logging.warning(f"Unequal number of frames: {ns}.")
189
+ return result
69
190
 
70
- def setup_rendering(self):
71
- self.renderer.SetBackground(
72
- self.background_r, self.background_g, self.background_b
73
- )
74
- self.renderWindow.AddRenderer(self.renderer)
75
- self.renderWindowInteractor.SetRenderWindow(self.renderWindow)
191
+ def setup_pipeline(self):
192
+ """Add all actors to the renderer and configure initial visibility."""
193
+ # Add mesh actors
194
+ for mesh in self.meshes:
195
+ for actor in mesh.actors:
196
+ self.renderer.AddActor(actor)
197
+
198
+ # Add volume actors
199
+ for volume in self.volumes:
200
+ for actor in volume.actors:
201
+ self.renderer.AddVolume(actor)
202
+
203
+ # Add segmentation actors
204
+ for segmentation in self.segmentations:
205
+ for actor in segmentation.actors:
206
+ self.renderer.AddActor(actor)
207
+
208
+ # Show current frame
209
+ self.show_frame(self.current_frame)
210
+ self.renderer.ResetCamera()
76
211
 
77
212
  def hide_all_frames(self):
78
213
  for a in self.renderer.GetActors():
@@ -83,7 +218,11 @@ class Scene:
83
218
  def show_frame(self, frame: int):
84
219
  for mesh in self.meshes:
85
220
  if mesh.visible:
86
- mesh.actors[frame].SetVisibility(True)
221
+ mesh.actors[frame % len(mesh.actors)].SetVisibility(True)
87
222
  for volume in self.volumes:
88
223
  if volume.visible:
89
- volume.actors[frame].SetVisibility(True)
224
+ volume.actors[frame % len(volume.actors)].SetVisibility(True)
225
+ for segmentation in self.segmentations:
226
+ if segmentation.visible:
227
+ actor = segmentation.actors[frame % len(segmentation.actors)]
228
+ actor.SetVisibility(True)
cardio/screenshot.py CHANGED
@@ -1,16 +1,15 @@
1
- from vtkmodules.vtkIOImage import vtkPNGWriter
2
- from vtkmodules.vtkRenderingCore import vtkRenderWindow, vtkWindowToImageFilter
1
+ import vtk
3
2
 
4
3
 
5
4
  class Screenshot:
6
- def __init__(self, renderWindow: vtkRenderWindow):
7
- self.windowToImageFilter = vtkWindowToImageFilter()
5
+ def __init__(self, renderWindow: vtk.vtkRenderWindow):
6
+ self.windowToImageFilter = vtk.vtkWindowToImageFilter()
8
7
  self.windowToImageFilter.SetInput(renderWindow)
9
8
  self.windowToImageFilter.SetScale(1)
10
9
  self.windowToImageFilter.SetInputBufferTypeToRGBA()
11
10
  self.windowToImageFilter.ReadFrontBufferOff()
12
11
 
13
- self.writer = vtkPNGWriter()
12
+ self.writer = vtk.vtkPNGWriter()
14
13
  self.writer.SetInputConnection(self.windowToImageFilter.GetOutputPort())
15
14
 
16
15
  def save(self, fileName: str):
cardio/segmentation.py ADDED
@@ -0,0 +1,178 @@
1
+ # System
2
+ import logging
3
+
4
+ # Third Party
5
+ import itk
6
+ import numpy as np
7
+ import pydantic as pc
8
+ import vtk
9
+
10
+ # Internal
11
+ from .object import Object
12
+ from .property_config import vtkPropertyConfig
13
+ from .utils import InterpolatorType, reset_direction
14
+
15
+
16
+ class Segmentation(Object):
17
+ """Segmentation object with multi-label mesh extraction using SurfaceNets."""
18
+
19
+ pattern: str = pc.Field(
20
+ default="${frame}.nii.gz",
21
+ description="Filename pattern with $frame placeholder",
22
+ )
23
+ _actors: list[vtk.vtkActor] = pc.PrivateAttr(default_factory=list)
24
+ properties: vtkPropertyConfig = pc.Field(
25
+ default_factory=vtkPropertyConfig, description="Property configuration"
26
+ )
27
+ include_labels: list[int] | None = pc.Field(default=None)
28
+ label_properties: dict[int, dict] = pc.Field(default_factory=dict)
29
+
30
+ @pc.model_validator(mode="after")
31
+ def initialize_segmentation(self):
32
+ """Generate VTK actors for all frames using SurfaceNets3D."""
33
+ for frame, path in enumerate(self.path_list):
34
+ logging.info(f"{self.label}: Loading segmentation frame {frame}.")
35
+
36
+ # Read and process segmentation image
37
+ image = itk.imread(path)
38
+ image = reset_direction(image, InterpolatorType.NEAREST)
39
+ vtk_image = itk.vtk_image_from_image(image)
40
+
41
+ # Create SurfaceNets3D filter
42
+ surface_nets = vtk.vtkSurfaceNets3D()
43
+ surface_nets.SetInputData(vtk_image)
44
+
45
+ # Configure label selection if specified
46
+ if self.include_labels is not None:
47
+ surface_nets.SetOutputStyle(surface_nets.OUTPUT_STYLE_SELECTED)
48
+ surface_nets.InitializeSelectedLabelsList()
49
+ for label in self.include_labels:
50
+ surface_nets.AddSelectedLabel(label)
51
+
52
+ # Execute filter
53
+ surface_nets.Update()
54
+ mesh = surface_nets.GetOutput()
55
+
56
+ # Create scalar array from boundary labels (use higher value)
57
+ boundary_labels = mesh.GetCellData().GetArray("BoundaryLabels")
58
+
59
+ if boundary_labels:
60
+ # Create scalar array using the maximum of the two boundary labels
61
+ scalar_array = vtk.vtkIntArray()
62
+ scalar_array.SetName("Labels")
63
+ scalar_array.SetNumberOfTuples(boundary_labels.GetNumberOfTuples())
64
+
65
+ for i in range(boundary_labels.GetNumberOfTuples()):
66
+ label1 = int(boundary_labels.GetComponent(i, 0))
67
+ label2 = int(boundary_labels.GetComponent(i, 1))
68
+ # Use the higher label value (excluding background=0)
69
+ max_label = (
70
+ max(label1, label2)
71
+ if max(label1, label2) > 0
72
+ else min(label1, label2)
73
+ )
74
+ scalar_array.SetValue(i, max_label)
75
+
76
+ mesh.GetCellData().SetScalars(scalar_array)
77
+
78
+ # Create single actor with scalar coloring
79
+ actor = self._create_segmentation_actor(mesh)
80
+ self._actors.append(actor)
81
+
82
+ return self
83
+
84
+ @property
85
+ def actors(self) -> list[vtk.vtkActor]:
86
+ return self._actors
87
+
88
+ def _create_segmentation_actor(self, mesh):
89
+ """Create a VTK actor with scalar-based coloring for the segmentation mesh."""
90
+ # Create a mapper with scalar coloring
91
+ mapper = vtk.vtkPolyDataMapper()
92
+ mapper.SetInputData(mesh)
93
+ mapper.SetScalarModeToUseCellData()
94
+ mapper.ScalarVisibilityOn()
95
+
96
+ # Create color transfer function for label-based coloring
97
+ color_func = vtk.vtkColorTransferFunction()
98
+
99
+ # Get the label range from the scalar array
100
+ scalar_array = mesh.GetCellData().GetArray("Labels")
101
+ if scalar_array:
102
+ scalar_range = scalar_array.GetRange()
103
+ min_label = int(scalar_range[0])
104
+ max_label = int(scalar_range[1])
105
+
106
+ # Set colors for each label
107
+ for label in range(min_label, max_label + 1):
108
+ if label == 0: # Skip background
109
+ continue
110
+
111
+ if label in self.label_properties:
112
+ props = self.label_properties[label]
113
+ color_func.AddRGBPoint(
114
+ label,
115
+ props.get("r", 1.0),
116
+ props.get("g", 0.0),
117
+ props.get("b", 0.0),
118
+ )
119
+ else:
120
+ # Default coloring based on label value
121
+ color = self._get_default_color(label)
122
+ color_func.AddRGBPoint(label, *color)
123
+
124
+ mapper.SetLookupTable(color_func)
125
+ mapper.SetScalarRange(min_label, max_label)
126
+
127
+ # Create actor
128
+ actor = vtk.vtkActor()
129
+ actor.SetMapper(mapper)
130
+
131
+ return actor
132
+
133
+ def _get_default_color(self, label):
134
+ """Generate a default color for a label."""
135
+ # Simple color generation based on label value
136
+ np.random.seed(label)
137
+ return np.random.rand(3)
138
+
139
+ def configure_actors(self):
140
+ """Configure actor properties without adding to renderer."""
141
+ for actor in self._actors:
142
+ actor.SetVisibility(False)
143
+ # Apply base property configuration if available
144
+ base_prop = self.properties.vtk_property
145
+ if base_prop:
146
+ # Note: For scalar-colored actors, we preserve the color transfer function
147
+ # by not overriding the mapper's lookup table
148
+ pass
149
+
150
+ def toggle_clipping(self, enabled: bool):
151
+ """Enable or disable clipping for all segmentation actors."""
152
+ if not self._actors:
153
+ return
154
+
155
+ if enabled and self.clipping_planes:
156
+ # Apply clipping to all actors
157
+ for actor in self._actors:
158
+ mapper = actor.GetMapper()
159
+ mapper.SetClippingPlanes(self.clipping_planes)
160
+ else:
161
+ # Remove clipping from all actors
162
+ for actor in self._actors:
163
+ mapper = actor.GetMapper()
164
+ mapper.RemoveAllClippingPlanes()
165
+
166
+ def update_clipping_bounds(self, bounds):
167
+ """Update clipping bounds from UI controls."""
168
+ if not self.clipping_planes:
169
+ return
170
+
171
+ # Update clipping planes with new bounds
172
+ super()._create_clipping_planes_from_bounds(self.clipping_planes, bounds)
173
+
174
+ # Apply to all actors if clipping is enabled
175
+ if self.clipping_enabled:
176
+ for actor in self._actors:
177
+ mapper = actor.GetMapper()
178
+ mapper.SetClippingPlanes(self.clipping_planes)