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/__init__.py +13 -5
- cardio/app.py +45 -14
- cardio/assets/bone.toml +42 -0
- cardio/assets/vascular_closed.toml +78 -0
- cardio/assets/vascular_open.toml +54 -0
- cardio/assets/xray.toml +42 -0
- cardio/logic.py +273 -10
- cardio/mesh.py +249 -29
- cardio/object.py +183 -10
- cardio/property_config.py +56 -0
- cardio/scene.py +204 -65
- cardio/screenshot.py +4 -5
- cardio/segmentation.py +178 -0
- cardio/transfer_functions.py +272 -0
- cardio/types.py +18 -0
- cardio/ui.py +359 -80
- cardio/utils.py +101 -0
- cardio/volume.py +41 -96
- cardio-2025.8.0.dist-info/METADATA +94 -0
- cardio-2025.8.0.dist-info/RECORD +22 -0
- cardio-2025.8.0.dist-info/WHEEL +4 -0
- {cardio-2023.1.2.dist-info → cardio-2025.8.0.dist-info}/entry_points.txt +1 -0
- __init__.py +0 -0
- cardio-2023.1.2.dist-info/LICENSE +0 -15
- cardio-2023.1.2.dist-info/METADATA +0 -69
- cardio-2023.1.2.dist-info/RECORD +0 -16
- cardio-2023.1.2.dist-info/WHEEL +0 -5
- cardio-2023.1.2.dist-info/top_level.txt +0 -2
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
|
5
|
-
|
6
|
-
|
7
|
-
import
|
8
|
-
|
9
|
-
#
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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.
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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.
|
162
|
+
mesh.configure_actors()
|
58
163
|
for volume in self.volumes:
|
59
|
-
volume.
|
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
|
-
|
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
|
-
|
67
|
-
|
68
|
-
|
187
|
+
if not np.all(ns == ns[0]):
|
188
|
+
logging.warning(f"Unequal number of frames: {ns}.")
|
189
|
+
return result
|
69
190
|
|
70
|
-
def
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
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)
|