cardio 2025.8.0__py3-none-any.whl → 2025.9.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 +8 -7
- cardio/app.py +1 -1
- cardio/blend_transfer_functions.py +84 -0
- cardio/color_transfer_function.py +29 -0
- cardio/logic.py +415 -5
- cardio/mesh.py +2 -2
- cardio/object.py +1 -6
- cardio/piecewise_function.py +29 -0
- cardio/scene.py +132 -1
- cardio/transfer_function_pair.py +25 -0
- cardio/ui.py +296 -60
- cardio/volume.py +216 -3
- cardio/volume_property.py +46 -0
- cardio/volume_property_presets.py +53 -0
- cardio/window_level.py +35 -0
- {cardio-2025.8.0.dist-info → cardio-2025.9.0.dist-info}/METADATA +16 -6
- cardio-2025.9.0.dist-info/RECORD +28 -0
- cardio/transfer_functions.py +0 -272
- cardio-2025.8.0.dist-info/RECORD +0 -22
- {cardio-2025.8.0.dist-info → cardio-2025.9.0.dist-info}/WHEEL +0 -0
- {cardio-2025.8.0.dist-info → cardio-2025.9.0.dist-info}/entry_points.txt +0 -0
cardio/__init__.py
CHANGED
@@ -1,15 +1,16 @@
|
|
1
|
+
from . import window_level
|
1
2
|
from .logic import Logic
|
2
3
|
from .mesh import Mesh
|
3
4
|
from .object import Object
|
4
5
|
from .scene import Scene
|
5
6
|
from .screenshot import Screenshot
|
6
7
|
from .segmentation import Segmentation
|
7
|
-
from .transfer_functions import (
|
8
|
-
list_available_presets,
|
9
|
-
load_preset,
|
10
|
-
)
|
11
8
|
from .ui import UI
|
12
9
|
from .volume import Volume
|
10
|
+
from .volume_property_presets import (
|
11
|
+
list_volume_property_presets,
|
12
|
+
load_volume_property_preset,
|
13
|
+
)
|
13
14
|
|
14
15
|
__all__ = [
|
15
16
|
"Object",
|
@@ -20,8 +21,8 @@ __all__ = [
|
|
20
21
|
"Screenshot",
|
21
22
|
"UI",
|
22
23
|
"Logic",
|
23
|
-
"
|
24
|
-
"
|
24
|
+
"load_volume_property_preset",
|
25
|
+
"list_volume_property_presets",
|
25
26
|
]
|
26
27
|
|
27
|
-
__version__ = "2025.
|
28
|
+
__version__ = "2025.9.0"
|
cardio/app.py
CHANGED
@@ -15,7 +15,7 @@ from .ui import UI
|
|
15
15
|
|
16
16
|
class CardioApp(tm.app.TrameApp):
|
17
17
|
def __init__(self, name=None):
|
18
|
-
super().__init__(server=name, client_type="
|
18
|
+
super().__init__(server=name, client_type="vue3")
|
19
19
|
|
20
20
|
# Add config file argument to Trame's parser
|
21
21
|
self.server.cli.add_argument(
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# System
|
2
|
+
import numpy as np
|
3
|
+
|
4
|
+
# Third Party
|
5
|
+
import vtk
|
6
|
+
|
7
|
+
|
8
|
+
def blend_transfer_functions(tfs, scalar_range=(-2000, 2000), num_samples=512):
|
9
|
+
"""
|
10
|
+
Blend multiple transfer functions using volume rendering emission-absorption model.
|
11
|
+
|
12
|
+
Based on the volume rendering equation from:
|
13
|
+
- Levoy, M. "Display of Surfaces from Volume Data" IEEE Computer Graphics and Applications, 1988
|
14
|
+
- Kajiya, J.T. & Von Herzen, B.P. "Ray tracing volume densities" ACM SIGGRAPH Computer Graphics, 1984
|
15
|
+
- Engel, K. et al. "Real-time Volume Graphics" A K Peters, 2006, Chapter 2
|
16
|
+
|
17
|
+
The volume rendering integral: I = ∫ C(s) * μ(s) * T(s) ds
|
18
|
+
where C(s) = emission color, μ(s) = opacity, T(s) = transmission
|
19
|
+
|
20
|
+
For discrete transfer functions, this becomes:
|
21
|
+
- Total emission = Σ(color_i * opacity_i)
|
22
|
+
- Total absorption = Σ(opacity_i)
|
23
|
+
- Final color = total_emission / total_absorption
|
24
|
+
"""
|
25
|
+
if len(tfs) == 1:
|
26
|
+
return tfs[0]
|
27
|
+
|
28
|
+
sample_points = np.linspace(
|
29
|
+
start=scalar_range[0],
|
30
|
+
stop=scalar_range[1],
|
31
|
+
num=num_samples,
|
32
|
+
)
|
33
|
+
|
34
|
+
# Initialize arrays to store blended values
|
35
|
+
blended_opacity = []
|
36
|
+
blended_color = []
|
37
|
+
|
38
|
+
for scalar_val in sample_points:
|
39
|
+
# Accumulate emission and absorption for volume rendering
|
40
|
+
total_emission = [0.0, 0.0, 0.0]
|
41
|
+
total_absorption = 0.0
|
42
|
+
|
43
|
+
for otf, ctf in tfs:
|
44
|
+
# Get opacity and color for this scalar value
|
45
|
+
layer_opacity = otf.GetValue(scalar_val)
|
46
|
+
layer_color = [0.0, 0.0, 0.0]
|
47
|
+
ctf.GetColor(scalar_val, layer_color)
|
48
|
+
|
49
|
+
# Volume rendering accumulation:
|
50
|
+
# Emission = color * opacity (additive)
|
51
|
+
# Absorption = opacity (multiplicative through transmission)
|
52
|
+
for i in range(3):
|
53
|
+
total_emission[i] += layer_color[i] * layer_opacity
|
54
|
+
|
55
|
+
total_absorption += layer_opacity
|
56
|
+
|
57
|
+
# Clamp values to reasonable ranges
|
58
|
+
total_absorption = min(total_absorption, 1.0)
|
59
|
+
for i in range(3):
|
60
|
+
total_emission[i] = min(total_emission[i], 1.0)
|
61
|
+
|
62
|
+
# For the final color, normalize emission by absorption if absorption > 0
|
63
|
+
if total_absorption > 0.001: # Avoid division by zero
|
64
|
+
final_color = [total_emission[i] / total_absorption for i in range(3)]
|
65
|
+
else:
|
66
|
+
final_color = [0.0, 0.0, 0.0]
|
67
|
+
|
68
|
+
# Clamp final colors
|
69
|
+
final_color = [min(c, 1.0) for c in final_color]
|
70
|
+
|
71
|
+
blended_opacity.append(total_absorption)
|
72
|
+
blended_color.append(final_color)
|
73
|
+
|
74
|
+
# Create new VTK transfer functions with blended values
|
75
|
+
blended_otf = vtk.vtkPiecewiseFunction()
|
76
|
+
blended_ctf = vtk.vtkColorTransferFunction()
|
77
|
+
|
78
|
+
for i, scalar_val in enumerate(sample_points):
|
79
|
+
blended_otf.AddPoint(scalar_val, blended_opacity[i])
|
80
|
+
blended_ctf.AddRGBPoint(
|
81
|
+
scalar_val, blended_color[i][0], blended_color[i][1], blended_color[i][2]
|
82
|
+
)
|
83
|
+
|
84
|
+
return blended_otf, blended_ctf
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# Third Party
|
2
|
+
import pydantic as pc
|
3
|
+
import vtk
|
4
|
+
|
5
|
+
# Internal
|
6
|
+
from .types import RGBColor
|
7
|
+
|
8
|
+
|
9
|
+
class ColorTransferFunctionPoint(pc.BaseModel):
|
10
|
+
"""A single point in a color transfer function."""
|
11
|
+
|
12
|
+
x: float = pc.Field(description="Scalar value")
|
13
|
+
color: RGBColor
|
14
|
+
|
15
|
+
|
16
|
+
class ColorTransferFunctionConfig(pc.BaseModel):
|
17
|
+
"""Configuration for a VTK color transfer function."""
|
18
|
+
|
19
|
+
points: list[ColorTransferFunctionPoint] = pc.Field(
|
20
|
+
min_length=1, description="Points defining the color transfer function"
|
21
|
+
)
|
22
|
+
|
23
|
+
@property
|
24
|
+
def vtk_function(self) -> vtk.vtkColorTransferFunction:
|
25
|
+
"""Create VTK color transfer function from this configuration."""
|
26
|
+
ctf = vtk.vtkColorTransferFunction()
|
27
|
+
for point in self.points:
|
28
|
+
ctf.AddRGBPoint(point.x, *point.color)
|
29
|
+
return ctf
|
cardio/logic.py
CHANGED
@@ -12,9 +12,34 @@ class Logic:
|
|
12
12
|
self.server = server
|
13
13
|
self.scene = scene
|
14
14
|
|
15
|
+
# Initialize mpr_presets early to avoid undefined errors
|
16
|
+
self.server.state.mpr_presets = []
|
17
|
+
|
18
|
+
# Initialize volume items for MPR dropdown
|
19
|
+
self.server.state.volume_items = [
|
20
|
+
{"text": volume.label, "value": volume.label}
|
21
|
+
for volume in self.scene.volumes
|
22
|
+
]
|
23
|
+
|
15
24
|
self.server.state.change("frame")(self.update_frame)
|
16
25
|
self.server.state.change("playing")(self.play)
|
17
|
-
self.server.state.change("
|
26
|
+
self.server.state.change("theme_mode")(self.sync_background_color)
|
27
|
+
self.server.state.change("mpr_enabled")(self.sync_mpr_mode)
|
28
|
+
self.server.state.change("active_volume_label")(self.sync_active_volume)
|
29
|
+
self.server.state.change("axial_slice", "sagittal_slice", "coronal_slice")(
|
30
|
+
self.update_slice_positions
|
31
|
+
)
|
32
|
+
self.server.state.change("mpr_window", "mpr_level")(
|
33
|
+
self.update_mpr_window_level
|
34
|
+
)
|
35
|
+
self.server.state.change("mpr_window_level_preset")(self.update_mpr_preset)
|
36
|
+
self.server.state.change("mpr_rotation_sequence")(self.update_mpr_rotation)
|
37
|
+
|
38
|
+
# Add handlers for individual rotation angles
|
39
|
+
for i in range(20):
|
40
|
+
self.server.state.change(f"mpr_rotation_angle_{i}")(
|
41
|
+
self.update_mpr_rotation
|
42
|
+
)
|
18
43
|
|
19
44
|
# Initialize visibility state variables
|
20
45
|
for m in self.scene.meshes:
|
@@ -98,6 +123,50 @@ class Logic:
|
|
98
123
|
self.server.controller.decrement_frame = self.decrement_frame
|
99
124
|
self.server.controller.screenshot = self.screenshot
|
100
125
|
self.server.controller.reset_all = self.reset_all
|
126
|
+
self.server.controller.close_application = self.close_application
|
127
|
+
|
128
|
+
# MPR rotation controllers
|
129
|
+
self.server.controller.add_x_rotation = lambda: self.add_mpr_rotation("X")
|
130
|
+
self.server.controller.add_y_rotation = lambda: self.add_mpr_rotation("Y")
|
131
|
+
self.server.controller.add_z_rotation = lambda: self.add_mpr_rotation("Z")
|
132
|
+
self.server.controller.remove_rotation_event = self.remove_mpr_rotation
|
133
|
+
self.server.controller.reset_rotations = self.reset_mpr_rotations
|
134
|
+
|
135
|
+
# Initialize MPR state
|
136
|
+
self.server.state.mpr_enabled = self.scene.mpr_enabled
|
137
|
+
self.server.state.active_volume_label = self.scene.active_volume_label
|
138
|
+
self.server.state.axial_slice = self.scene.axial_slice
|
139
|
+
self.server.state.sagittal_slice = self.scene.sagittal_slice
|
140
|
+
self.server.state.coronal_slice = self.scene.coronal_slice
|
141
|
+
self.server.state.mpr_window = self.scene.mpr_window
|
142
|
+
self.server.state.mpr_level = self.scene.mpr_level
|
143
|
+
self.server.state.mpr_window_level_preset = self.scene.mpr_window_level_preset
|
144
|
+
self.server.state.mpr_rotation_sequence = self.scene.mpr_rotation_sequence
|
145
|
+
|
146
|
+
# Initialize MPR presets data
|
147
|
+
try:
|
148
|
+
from .window_level import presets
|
149
|
+
|
150
|
+
self.server.state.mpr_presets = [
|
151
|
+
{"text": preset.name, "value": key} for key, preset in presets.items()
|
152
|
+
]
|
153
|
+
except Exception as e:
|
154
|
+
print(f"Error initializing MPR presets: {e}")
|
155
|
+
self.server.state.mpr_presets = []
|
156
|
+
|
157
|
+
# Initialize rotation angle states (up to 20 rotations like app.py)
|
158
|
+
for i in range(20):
|
159
|
+
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
160
|
+
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
161
|
+
|
162
|
+
# Apply initial preset to ensure window/level values are set correctly
|
163
|
+
# Only update state values, don't call update methods yet since MPR may not be enabled
|
164
|
+
from .window_level import presets
|
165
|
+
|
166
|
+
if self.scene.mpr_window_level_preset in presets:
|
167
|
+
preset = presets[self.scene.mpr_window_level_preset]
|
168
|
+
self.server.state.mpr_window = preset.window
|
169
|
+
self.server.state.mpr_level = preset.level
|
101
170
|
|
102
171
|
# Initialize clipping state variables
|
103
172
|
self._initialize_clipping_state()
|
@@ -122,8 +191,95 @@ class Logic:
|
|
122
191
|
actor = segmentation.actors[frame % len(segmentation.actors)]
|
123
192
|
actor.SetVisibility(True)
|
124
193
|
|
194
|
+
# Update MPR views if MPR is enabled
|
195
|
+
self.update_mpr_frame(frame)
|
196
|
+
|
125
197
|
self.server.controller.view_update()
|
126
198
|
|
199
|
+
def update_mpr_frame(self, frame):
|
200
|
+
"""Update MPR views to show the specified frame."""
|
201
|
+
if not getattr(self.server.state, "mpr_enabled", False):
|
202
|
+
return
|
203
|
+
|
204
|
+
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
205
|
+
if not active_volume_label:
|
206
|
+
return
|
207
|
+
|
208
|
+
# Find the active volume
|
209
|
+
active_volume = None
|
210
|
+
for volume in self.scene.volumes:
|
211
|
+
if volume.label == active_volume_label:
|
212
|
+
active_volume = volume
|
213
|
+
break
|
214
|
+
|
215
|
+
if not active_volume:
|
216
|
+
return
|
217
|
+
|
218
|
+
# Get or create MPR actors for the new frame
|
219
|
+
mpr_actors = active_volume.get_mpr_actors_for_frame(frame)
|
220
|
+
|
221
|
+
# Update each MPR renderer with the new frame's actors
|
222
|
+
if self.scene.axial_renderWindow:
|
223
|
+
axial_renderer = (
|
224
|
+
self.scene.axial_renderWindow.GetRenderers().GetFirstRenderer()
|
225
|
+
)
|
226
|
+
if axial_renderer:
|
227
|
+
axial_renderer.RemoveAllViewProps()
|
228
|
+
axial_renderer.AddActor(mpr_actors["axial"]["actor"])
|
229
|
+
mpr_actors["axial"]["actor"].SetVisibility(True)
|
230
|
+
# Apply current slice position and window/level
|
231
|
+
self._apply_current_mpr_settings(active_volume, frame)
|
232
|
+
axial_renderer.ResetCamera()
|
233
|
+
|
234
|
+
if self.scene.coronal_renderWindow:
|
235
|
+
coronal_renderer = (
|
236
|
+
self.scene.coronal_renderWindow.GetRenderers().GetFirstRenderer()
|
237
|
+
)
|
238
|
+
if coronal_renderer:
|
239
|
+
coronal_renderer.RemoveAllViewProps()
|
240
|
+
coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
|
241
|
+
mpr_actors["coronal"]["actor"].SetVisibility(True)
|
242
|
+
coronal_renderer.ResetCamera()
|
243
|
+
|
244
|
+
if self.scene.sagittal_renderWindow:
|
245
|
+
sagittal_renderer = (
|
246
|
+
self.scene.sagittal_renderWindow.GetRenderers().GetFirstRenderer()
|
247
|
+
)
|
248
|
+
if sagittal_renderer:
|
249
|
+
sagittal_renderer.RemoveAllViewProps()
|
250
|
+
sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
|
251
|
+
mpr_actors["sagittal"]["actor"].SetVisibility(True)
|
252
|
+
sagittal_renderer.ResetCamera()
|
253
|
+
|
254
|
+
def _apply_current_mpr_settings(self, active_volume, frame):
|
255
|
+
"""Apply current slice positions and window/level to MPR actors."""
|
256
|
+
# Apply slice positions
|
257
|
+
axial_slice = getattr(self.server.state, "axial_slice", 0.5)
|
258
|
+
sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
|
259
|
+
coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
|
260
|
+
|
261
|
+
# Get rotation data
|
262
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
263
|
+
rotation_angles = {}
|
264
|
+
for i in range(len(rotation_sequence)):
|
265
|
+
rotation_angles[i] = getattr(
|
266
|
+
self.server.state, f"mpr_rotation_angle_{i}", 0
|
267
|
+
)
|
268
|
+
|
269
|
+
active_volume.update_slice_positions(
|
270
|
+
frame,
|
271
|
+
axial_slice,
|
272
|
+
sagittal_slice,
|
273
|
+
coronal_slice,
|
274
|
+
rotation_sequence,
|
275
|
+
rotation_angles,
|
276
|
+
)
|
277
|
+
|
278
|
+
# Apply window/level
|
279
|
+
window = getattr(self.server.state, "mpr_window", 400.0)
|
280
|
+
level = getattr(self.server.state, "mpr_level", 40.0)
|
281
|
+
active_volume.update_mpr_window_level(frame, window, level)
|
282
|
+
|
127
283
|
@asynchronous.task
|
128
284
|
async def play(self, playing, **kwargs):
|
129
285
|
if not (self.server.state.incrementing or self.server.state.rotating):
|
@@ -159,11 +315,11 @@ class Logic:
|
|
159
315
|
|
160
316
|
def sync_volume_presets(self, **kwargs):
|
161
317
|
"""Update volume transfer function presets based on UI selection."""
|
162
|
-
from .
|
318
|
+
from .volume_property_presets import load_volume_property_preset
|
163
319
|
|
164
320
|
for v in self.scene.volumes:
|
165
321
|
preset_name = self.server.state[f"volume_preset_{v.label}"]
|
166
|
-
preset =
|
322
|
+
preset = load_volume_property_preset(preset_name)
|
167
323
|
|
168
324
|
# Apply preset to all actors
|
169
325
|
for actor in v.actors:
|
@@ -295,9 +451,9 @@ class Logic:
|
|
295
451
|
self.server.state.bpr = 5
|
296
452
|
self.server.controller.view_update()
|
297
453
|
|
298
|
-
def sync_background_color(self,
|
454
|
+
def sync_background_color(self, theme_mode, **kwargs):
|
299
455
|
"""Sync VTK renderer background with dark mode."""
|
300
|
-
if
|
456
|
+
if theme_mode == "dark":
|
301
457
|
# Dark mode: use dark background from config
|
302
458
|
self.scene.renderer.SetBackground(
|
303
459
|
*self.scene.background.dark,
|
@@ -363,3 +519,257 @@ class Logic:
|
|
363
519
|
setattr(self.server.state, f"clip_x_{s.label}", [bounds[0], bounds[1]])
|
364
520
|
setattr(self.server.state, f"clip_y_{s.label}", [bounds[2], bounds[3]])
|
365
521
|
setattr(self.server.state, f"clip_z_{s.label}", [bounds[4], bounds[5]])
|
522
|
+
|
523
|
+
def sync_mpr_mode(self, mpr_enabled, **kwargs):
|
524
|
+
"""Handle MPR mode toggle."""
|
525
|
+
if (
|
526
|
+
mpr_enabled
|
527
|
+
and self.scene.volumes
|
528
|
+
and not self.server.state.active_volume_label
|
529
|
+
):
|
530
|
+
# Auto-select first volume when MPR is enabled and no volume is selected
|
531
|
+
self.server.state.active_volume_label = self.scene.volumes[0].label
|
532
|
+
|
533
|
+
def sync_active_volume(self, active_volume_label, **kwargs):
|
534
|
+
"""Handle active volume selection for MPR."""
|
535
|
+
|
536
|
+
if not active_volume_label or not self.server.state.mpr_enabled:
|
537
|
+
return
|
538
|
+
|
539
|
+
# Find the selected volume
|
540
|
+
active_volume = None
|
541
|
+
for volume in self.scene.volumes:
|
542
|
+
if volume.label == active_volume_label:
|
543
|
+
active_volume = volume
|
544
|
+
break
|
545
|
+
|
546
|
+
if not active_volume:
|
547
|
+
return
|
548
|
+
|
549
|
+
# Create MPR actors for current frame
|
550
|
+
current_frame = getattr(self.server.state, "frame", 0)
|
551
|
+
mpr_actors = active_volume.get_mpr_actors_for_frame(current_frame)
|
552
|
+
|
553
|
+
# Add MPR actors to their respective renderers
|
554
|
+
if self.scene.axial_renderWindow:
|
555
|
+
axial_renderer = (
|
556
|
+
self.scene.axial_renderWindow.GetRenderers().GetFirstRenderer()
|
557
|
+
)
|
558
|
+
if axial_renderer:
|
559
|
+
axial_renderer.RemoveAllViewProps() # Clear existing actors
|
560
|
+
axial_renderer.AddActor(mpr_actors["axial"]["actor"])
|
561
|
+
mpr_actors["axial"]["actor"].SetVisibility(True)
|
562
|
+
axial_renderer.ResetCamera()
|
563
|
+
|
564
|
+
if self.scene.coronal_renderWindow:
|
565
|
+
coronal_renderer = (
|
566
|
+
self.scene.coronal_renderWindow.GetRenderers().GetFirstRenderer()
|
567
|
+
)
|
568
|
+
if coronal_renderer:
|
569
|
+
coronal_renderer.RemoveAllViewProps()
|
570
|
+
coronal_renderer.AddActor(mpr_actors["coronal"]["actor"])
|
571
|
+
mpr_actors["coronal"]["actor"].SetVisibility(True)
|
572
|
+
coronal_renderer.ResetCamera()
|
573
|
+
|
574
|
+
if self.scene.sagittal_renderWindow:
|
575
|
+
sagittal_renderer = (
|
576
|
+
self.scene.sagittal_renderWindow.GetRenderers().GetFirstRenderer()
|
577
|
+
)
|
578
|
+
if sagittal_renderer:
|
579
|
+
sagittal_renderer.RemoveAllViewProps()
|
580
|
+
sagittal_renderer.AddActor(mpr_actors["sagittal"]["actor"])
|
581
|
+
mpr_actors["sagittal"]["actor"].SetVisibility(True)
|
582
|
+
sagittal_renderer.ResetCamera()
|
583
|
+
|
584
|
+
# Apply current window/level settings to the MPR actors
|
585
|
+
window = getattr(self.server.state, "mpr_window", 800.0)
|
586
|
+
level = getattr(self.server.state, "mpr_level", 200.0)
|
587
|
+
active_volume.update_mpr_window_level(current_frame, window, level)
|
588
|
+
|
589
|
+
# Update all views
|
590
|
+
self.server.controller.view_update()
|
591
|
+
|
592
|
+
def update_slice_positions(self, **kwargs):
|
593
|
+
"""Update MPR slice positions when sliders change."""
|
594
|
+
|
595
|
+
if not getattr(self.server.state, "mpr_enabled", False):
|
596
|
+
return
|
597
|
+
|
598
|
+
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
599
|
+
if not active_volume_label:
|
600
|
+
return
|
601
|
+
|
602
|
+
# Find the active volume
|
603
|
+
active_volume = None
|
604
|
+
for volume in self.scene.volumes:
|
605
|
+
if volume.label == active_volume_label:
|
606
|
+
active_volume = volume
|
607
|
+
break
|
608
|
+
|
609
|
+
if not active_volume:
|
610
|
+
return
|
611
|
+
|
612
|
+
# Get current slice positions
|
613
|
+
axial_slice = getattr(self.server.state, "axial_slice", 0.5)
|
614
|
+
sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
|
615
|
+
coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
|
616
|
+
current_frame = getattr(self.server.state, "frame", 0)
|
617
|
+
|
618
|
+
# Get rotation data
|
619
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
620
|
+
rotation_angles = {}
|
621
|
+
for i in range(len(rotation_sequence)):
|
622
|
+
rotation_angles[i] = getattr(
|
623
|
+
self.server.state, f"mpr_rotation_angle_{i}", 0
|
624
|
+
)
|
625
|
+
|
626
|
+
# Update slice positions with rotation
|
627
|
+
active_volume.update_slice_positions(
|
628
|
+
current_frame,
|
629
|
+
axial_slice,
|
630
|
+
sagittal_slice,
|
631
|
+
coronal_slice,
|
632
|
+
rotation_sequence,
|
633
|
+
rotation_angles,
|
634
|
+
)
|
635
|
+
|
636
|
+
# Update all views
|
637
|
+
self.server.controller.view_update()
|
638
|
+
|
639
|
+
def update_mpr_window_level(self, **kwargs):
|
640
|
+
"""Update MPR window/level when sliders change."""
|
641
|
+
|
642
|
+
if not getattr(self.server.state, "mpr_enabled", False):
|
643
|
+
return
|
644
|
+
|
645
|
+
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
646
|
+
if not active_volume_label:
|
647
|
+
return
|
648
|
+
|
649
|
+
# Find the active volume
|
650
|
+
active_volume = None
|
651
|
+
for volume in self.scene.volumes:
|
652
|
+
if volume.label == active_volume_label:
|
653
|
+
active_volume = volume
|
654
|
+
break
|
655
|
+
|
656
|
+
if not active_volume:
|
657
|
+
return
|
658
|
+
|
659
|
+
# Get current window/level values
|
660
|
+
window = getattr(self.server.state, "mpr_window", 400.0)
|
661
|
+
level = getattr(self.server.state, "mpr_level", 40.0)
|
662
|
+
current_frame = getattr(self.server.state, "frame", 0)
|
663
|
+
|
664
|
+
# Update window/level for MPR actors
|
665
|
+
active_volume.update_mpr_window_level(current_frame, window, level)
|
666
|
+
|
667
|
+
# Update all views
|
668
|
+
self.server.controller.view_update()
|
669
|
+
|
670
|
+
def update_mpr_preset(self, mpr_window_level_preset, **kwargs):
|
671
|
+
"""Update MPR window/level when preset changes."""
|
672
|
+
from .window_level import presets
|
673
|
+
|
674
|
+
if mpr_window_level_preset in presets:
|
675
|
+
preset = presets[mpr_window_level_preset]
|
676
|
+
self.server.state.mpr_window = preset.window
|
677
|
+
self.server.state.mpr_level = preset.level
|
678
|
+
|
679
|
+
# Update the actual MPR views with new window/level
|
680
|
+
self.update_mpr_window_level()
|
681
|
+
|
682
|
+
def update_mpr_rotation(self, **kwargs):
|
683
|
+
"""Update MPR views when rotation changes."""
|
684
|
+
if not getattr(self.server.state, "mpr_enabled", False):
|
685
|
+
return
|
686
|
+
|
687
|
+
active_volume_label = getattr(self.server.state, "active_volume_label", "")
|
688
|
+
if not active_volume_label:
|
689
|
+
return
|
690
|
+
|
691
|
+
# Find the active volume
|
692
|
+
active_volume = None
|
693
|
+
for volume in self.scene.volumes:
|
694
|
+
if volume.label == active_volume_label:
|
695
|
+
active_volume = volume
|
696
|
+
break
|
697
|
+
|
698
|
+
if not active_volume:
|
699
|
+
return
|
700
|
+
|
701
|
+
# Get current slice positions
|
702
|
+
axial_slice = getattr(self.server.state, "axial_slice", 0.5)
|
703
|
+
sagittal_slice = getattr(self.server.state, "sagittal_slice", 0.5)
|
704
|
+
coronal_slice = getattr(self.server.state, "coronal_slice", 0.5)
|
705
|
+
current_frame = getattr(self.server.state, "frame", 0)
|
706
|
+
|
707
|
+
# Get rotation data
|
708
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
709
|
+
rotation_angles = {}
|
710
|
+
for i in range(len(rotation_sequence)):
|
711
|
+
rotation_angles[i] = getattr(
|
712
|
+
self.server.state, f"mpr_rotation_angle_{i}", 0
|
713
|
+
)
|
714
|
+
|
715
|
+
# Update slice positions with rotation
|
716
|
+
active_volume.update_slice_positions(
|
717
|
+
current_frame,
|
718
|
+
axial_slice,
|
719
|
+
sagittal_slice,
|
720
|
+
coronal_slice,
|
721
|
+
rotation_sequence,
|
722
|
+
rotation_angles,
|
723
|
+
)
|
724
|
+
|
725
|
+
# Update all views
|
726
|
+
self.server.controller.view_update()
|
727
|
+
|
728
|
+
def add_mpr_rotation(self, axis):
|
729
|
+
"""Add a new rotation to the MPR rotation sequence."""
|
730
|
+
import copy
|
731
|
+
|
732
|
+
current_sequence = copy.deepcopy(
|
733
|
+
getattr(self.server.state, "mpr_rotation_sequence", [])
|
734
|
+
)
|
735
|
+
current_sequence.append({"axis": axis, "angle": 0})
|
736
|
+
self.server.state.mpr_rotation_sequence = current_sequence
|
737
|
+
self.update_mpr_rotation_labels()
|
738
|
+
|
739
|
+
def remove_mpr_rotation(self, index):
|
740
|
+
"""Remove a rotation at given index and all subsequent rotations."""
|
741
|
+
sequence = list(getattr(self.server.state, "mpr_rotation_sequence", []))
|
742
|
+
if 0 <= index < len(sequence):
|
743
|
+
sequence = sequence[:index]
|
744
|
+
self.server.state.mpr_rotation_sequence = sequence
|
745
|
+
|
746
|
+
# Reset angle states for all removed rotations
|
747
|
+
for i in range(index, 20):
|
748
|
+
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
749
|
+
|
750
|
+
self.update_mpr_rotation_labels()
|
751
|
+
|
752
|
+
def reset_mpr_rotations(self):
|
753
|
+
"""Reset all MPR rotations."""
|
754
|
+
self.server.state.mpr_rotation_sequence = []
|
755
|
+
for i in range(20):
|
756
|
+
setattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
|
757
|
+
self.update_mpr_rotation_labels()
|
758
|
+
|
759
|
+
def update_mpr_rotation_labels(self):
|
760
|
+
"""Update the rotation axis labels for display."""
|
761
|
+
rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
|
762
|
+
for i, rotation in enumerate(rotation_sequence):
|
763
|
+
setattr(
|
764
|
+
self.server.state,
|
765
|
+
f"mpr_rotation_axis_{i}",
|
766
|
+
f"{rotation['axis']} ({i + 1})",
|
767
|
+
)
|
768
|
+
# Clear unused labels
|
769
|
+
for i in range(len(rotation_sequence), 20):
|
770
|
+
setattr(self.server.state, f"mpr_rotation_axis_{i}", f"Rotation {i + 1}")
|
771
|
+
|
772
|
+
@asynchronous.task
|
773
|
+
async def close_application(self):
|
774
|
+
"""Close the application by stopping the server."""
|
775
|
+
await self.server.stop()
|
cardio/mesh.py
CHANGED
@@ -167,9 +167,9 @@ class Mesh(Object):
|
|
167
167
|
|
168
168
|
def color_transfer_function(self):
|
169
169
|
ctf = vtk.vtkColorTransferFunction()
|
170
|
-
ctf.AddRGBPoint(
|
170
|
+
ctf.AddRGBPoint(self.ctf_min, 0.0, 0.0, 1.0)
|
171
171
|
ctf.AddRGBPoint(1.0, 1.0, 0.0, 0.0)
|
172
|
-
ctf.AddRGBPoint(
|
172
|
+
ctf.AddRGBPoint(self.ctf_max, 1.0, 1.0, 0.0)
|
173
173
|
return ctf
|
174
174
|
|
175
175
|
def setup_scalar_coloring(self, mapper):
|
cardio/object.py
CHANGED
@@ -28,7 +28,7 @@ class Object(pc.BaseModel):
|
|
28
28
|
visible: bool = pc.Field(
|
29
29
|
default=True, description="Whether object is initially visible"
|
30
30
|
)
|
31
|
-
clipping_enabled: bool = pc.Field(default=
|
31
|
+
clipping_enabled: bool = pc.Field(default=True)
|
32
32
|
|
33
33
|
@pc.field_validator("label")
|
34
34
|
@classmethod
|
@@ -69,12 +69,7 @@ class Object(pc.BaseModel):
|
|
69
69
|
if self.pattern is not None and self.file_paths is not None:
|
70
70
|
logging.info("Both pattern and file_paths specified; using file_paths.")
|
71
71
|
|
72
|
-
# Validate all paths for traversal attacks and file existence
|
73
72
|
for path in self.path_list:
|
74
|
-
if not path.resolve().is_relative_to(self.directory.resolve()):
|
75
|
-
raise ValueError(
|
76
|
-
f"Path {path} would access files outside base directory"
|
77
|
-
)
|
78
73
|
if not path.is_file():
|
79
74
|
raise ValueError(f"File does not exist: {path}")
|
80
75
|
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# Third Party
|
2
|
+
import pydantic as pc
|
3
|
+
import vtk
|
4
|
+
|
5
|
+
# Internal
|
6
|
+
from .types import ScalarComponent
|
7
|
+
|
8
|
+
|
9
|
+
class PiecewiseFunctionPoint(pc.BaseModel):
|
10
|
+
"""A single point in a piecewise function."""
|
11
|
+
|
12
|
+
x: float = pc.Field(description="Scalar value")
|
13
|
+
y: ScalarComponent
|
14
|
+
|
15
|
+
|
16
|
+
class PiecewiseFunctionConfig(pc.BaseModel):
|
17
|
+
"""Configuration for a VTK piecewise function (opacity)."""
|
18
|
+
|
19
|
+
points: list[PiecewiseFunctionPoint] = pc.Field(
|
20
|
+
min_length=1, description="Points defining the piecewise function"
|
21
|
+
)
|
22
|
+
|
23
|
+
@property
|
24
|
+
def vtk_function(self) -> vtk.vtkPiecewiseFunction:
|
25
|
+
"""Create VTK piecewise function from this configuration."""
|
26
|
+
otf = vtk.vtkPiecewiseFunction()
|
27
|
+
for point in self.points:
|
28
|
+
otf.AddPoint(point.x, point.y)
|
29
|
+
return otf
|