cardio 2023.1.2__py3-none-any.whl → 2025.8.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/ui.py CHANGED
@@ -2,7 +2,8 @@ from trame.ui.vuetify import SinglePageWithDrawerLayout
2
2
  from trame.widgets import vtk as vtk_widgets
3
3
  from trame.widgets import vuetify
4
4
 
5
- from . import Scene
5
+ from .scene import Scene
6
+ from .transfer_functions import list_available_presets
6
7
 
7
8
 
8
9
  class UI:
@@ -13,7 +14,6 @@ class UI:
13
14
  self.setup()
14
15
 
15
16
  def setup(self):
16
-
17
17
  self.server.state.trame__title = self.scene.project_name
18
18
 
19
19
  with SinglePageWithDrawerLayout(self.server) as layout:
@@ -37,17 +37,26 @@ class UI:
37
37
  # )
38
38
 
39
39
  vuetify.VCheckbox(
40
- v_model="$vuetify.theme.dark",
40
+ v_model=("dark_mode", False),
41
41
  on_icon="mdi-lightbulb-outline",
42
42
  off_icon="mdi-lightbulb-off-outline",
43
43
  classes="mx-1",
44
44
  hide_details=True,
45
45
  dense=True,
46
46
  outlined=True,
47
+ change="$vuetify.theme.dark = $event",
47
48
  )
48
49
 
49
- with vuetify.VBtn(icon=True, click=self.server.controller.reset_all):
50
- vuetify.VIcon("mdi-undo-variant")
50
+ # NOTE: Reset button should be VBtn, but using VCheckbox for consistent sizing/spacing
51
+ vuetify.VCheckbox(
52
+ value=False,
53
+ on_icon="mdi-undo-variant",
54
+ off_icon="mdi-undo-variant",
55
+ hide_details=True,
56
+ title="Reset All",
57
+ click=self.server.controller.reset_all,
58
+ readonly=True,
59
+ )
51
60
 
52
61
  vuetify.VProgressLinear(
53
62
  indeterminate=True,
@@ -61,7 +70,6 @@ class UI:
61
70
  fluid=True,
62
71
  classes="pa-0 fill-height",
63
72
  ):
64
-
65
73
  view = vtk_widgets.VtkRemoteView(
66
74
  self.scene.renderWindow, interactive_ratio=1
67
75
  )
@@ -77,64 +85,68 @@ class UI:
77
85
  self.server.controller.on_server_ready.add(view.update)
78
86
 
79
87
  with layout.drawer:
88
+ vuetify.VSubheader("Playback Controls")
80
89
 
81
- with vuetify.VBtn(
82
- icon=True,
83
- click=self.server.controller.decrement_frame,
84
- title="Previous",
85
- ):
86
- vuetify.VIcon("mdi-skip-previous-circle")
90
+ with vuetify.VToolbar(dense=True, flat=True):
91
+ # NOTE: Previous/Next controls should be VBtn components, but we use
92
+ # VCheckbox for consistent sizing/spacing with the other controls.
93
+ # This may be easier to fix in Vuetify 3.
94
+ vuetify.VCheckbox(
95
+ value=False,
96
+ on_icon="mdi-skip-previous-circle",
97
+ off_icon="mdi-skip-previous-circle",
98
+ hide_details=True,
99
+ title="Previous",
100
+ click=self.server.controller.decrement_frame,
101
+ readonly=True,
102
+ )
87
103
 
88
- with vuetify.VBtn(
89
- icon=True,
90
- click=self.server.controller.increment_frame,
91
- title="Next",
92
- ):
93
- vuetify.VIcon("mdi-skip-next-circle")
104
+ vuetify.VSpacer()
94
105
 
95
- vuetify.VCheckbox(
96
- v_model=("playing", False),
97
- on_icon="mdi-pause-circle",
98
- off_icon="mdi-play-circle",
99
- classes="mx-1",
100
- hide_details=True,
101
- dense=True,
102
- title="Play/Pause",
103
- )
106
+ vuetify.VCheckbox(
107
+ value=False,
108
+ on_icon="mdi-skip-next-circle",
109
+ off_icon="mdi-skip-next-circle",
110
+ hide_details=True,
111
+ title="Next",
112
+ click=self.server.controller.increment_frame,
113
+ readonly=True,
114
+ )
104
115
 
105
- vuetify.VCheckbox(
106
- v_model=("incrementing", True),
107
- on_icon="mdi-movie-open-outline",
108
- off_icon="mdi-movie-open-off-outline",
109
- classes="mx-1",
110
- hide_details=True,
111
- dense=True,
112
- label="Incrementing",
113
- title="Incrementing",
114
- )
116
+ vuetify.VSpacer()
115
117
 
116
- vuetify.VCheckbox(
117
- v_model=("rotating", False),
118
- on_icon="mdi-autorenew",
119
- off_icon="mdi-autorenew-off",
120
- classes="mx-1",
121
- hide_details=True,
122
- dense=True,
123
- label="Rotating",
124
- title="Rotating",
125
- )
118
+ vuetify.VCheckbox(
119
+ v_model=("playing", False),
120
+ on_icon="mdi-pause-circle",
121
+ off_icon="mdi-play-circle",
122
+ title="Play/Pause",
123
+ hide_details=True,
124
+ )
126
125
 
127
- with vuetify.VBtn(
128
- icon=True,
129
- click=self.server.controller.screenshot,
130
- title="Save Screenshot",
131
- label="Take Screenshot",
132
- ):
133
- vuetify.VIcon("mdi-image")
126
+ vuetify.VSpacer()
127
+
128
+ vuetify.VCheckbox(
129
+ v_model=("incrementing", True),
130
+ on_icon="mdi-movie-open-outline",
131
+ off_icon="mdi-movie-open-off-outline",
132
+ hide_details=True,
133
+ title="Incrementing",
134
+ )
135
+
136
+ vuetify.VSpacer()
137
+
138
+ vuetify.VCheckbox(
139
+ v_model=("rotating", False),
140
+ on_icon="mdi-autorenew",
141
+ off_icon="mdi-autorenew-off",
142
+ hide_details=True,
143
+ title="Rotating",
144
+ )
134
145
 
135
146
  vuetify.VSlider(
136
147
  v_model=("frame", self.scene.current_frame),
137
- label="Frame",
148
+ hint="Phase",
149
+ persistent_hint=True,
138
150
  min=0,
139
151
  max=self.scene.nframes - 1,
140
152
  step=1,
@@ -147,11 +159,10 @@ class UI:
147
159
 
148
160
  vuetify.VSlider(
149
161
  v_model=("bpm", 60),
150
- label="BPM",
151
- hint="Beats Per Minute",
162
+ hint="Speed",
152
163
  persistent_hint=True,
153
164
  min=40,
154
- max=160,
165
+ max=1024,
155
166
  step=1,
156
167
  hide_details=False,
157
168
  dense=True,
@@ -162,11 +173,10 @@ class UI:
162
173
 
163
174
  vuetify.VSlider(
164
175
  v_model=("bpr", 3),
165
- label="BPR",
166
- hint="Beats Per Rotation",
176
+ hint="Cycles/Rotation",
167
177
  persistent_hint=True,
168
178
  min=1,
169
- max=5,
179
+ max=360,
170
180
  step=1,
171
181
  hide_details=False,
172
182
  dense=True,
@@ -175,24 +185,293 @@ class UI:
175
185
  thumb_label=True,
176
186
  )
177
187
 
178
- for i, m in enumerate(self.scene.meshes):
179
- vuetify.VCheckbox(
180
- v_model=(f"mesh_visibility_{m.label}", m.visible),
181
- on_icon="mdi-eye",
182
- off_icon="mdi-eye-off",
183
- classes="mx-1",
184
- hide_details=True,
185
- dense=True,
186
- label=m.label,
188
+ with vuetify.VRow(justify="center", classes="my-3"):
189
+ vuetify.VBtn(
190
+ f"Capture Cine",
191
+ small=True,
192
+ click=self.server.controller.screenshot,
193
+ title=f"Capture cine to {self.scene.screenshot_directory}",
187
194
  )
188
195
 
189
- for i, v in enumerate(self.scene.volumes):
190
- vuetify.VCheckbox(
191
- v_model=(f"volume_visibility_{v.label}", v.visible),
192
- on_icon="mdi-eye",
193
- off_icon="mdi-eye-off",
194
- classes="mx-1",
195
- hide_details=True,
196
- dense=True,
197
- label=v.label,
198
- )
196
+ vuetify.VSubheader("Appearance and Visibility")
197
+
198
+ if self.scene.meshes:
199
+ vuetify.VSubheader("Meshes", classes="text-caption pl-4")
200
+ for i, m in enumerate(self.scene.meshes):
201
+ vuetify.VCheckbox(
202
+ v_model=f"mesh_visibility_{m.label}",
203
+ on_icon="mdi-eye",
204
+ off_icon="mdi-eye-off",
205
+ classes="mx-1",
206
+ hide_details=True,
207
+ dense=True,
208
+ label=m.label,
209
+ )
210
+ if m.clipping_enabled:
211
+ vuetify.VCheckbox(
212
+ v_model=(
213
+ f"mesh_clipping_{m.label}",
214
+ m.clipping_enabled,
215
+ ),
216
+ on_icon="mdi-content-cut",
217
+ off_icon="mdi-content-cut",
218
+ classes="mx-1 ml-4",
219
+ hide_details=True,
220
+ dense=True,
221
+ label="Clip",
222
+ )
223
+
224
+ # Get initial mesh bounds for sliders
225
+ if m.actors:
226
+ bounds = m.combined_bounds
227
+ with vuetify.VExpansionPanels(
228
+ v_model=f"clip_panel_{m.label}",
229
+ multiple=True,
230
+ flat=True,
231
+ classes="ml-4",
232
+ dense=True,
233
+ style="max-width: 270px;",
234
+ ):
235
+ with vuetify.VExpansionPanel():
236
+ vuetify.VExpansionPanelHeader("Clip Bounds")
237
+ with vuetify.VExpansionPanelContent():
238
+ # X bounds
239
+ vuetify.VRangeSlider(
240
+ v_model=(
241
+ f"clip_x_{m.label}",
242
+ [bounds[0], bounds[1]],
243
+ ),
244
+ label="X Range",
245
+ min=bounds[0],
246
+ max=bounds[1],
247
+ step=(bounds[1] - bounds[0]) / 100,
248
+ hide_details=True,
249
+ dense=True,
250
+ thumb_label=False,
251
+ )
252
+ # Y bounds
253
+ vuetify.VRangeSlider(
254
+ v_model=(
255
+ f"clip_y_{m.label}",
256
+ [bounds[2], bounds[3]],
257
+ ),
258
+ label="Y Range",
259
+ min=bounds[2],
260
+ max=bounds[3],
261
+ step=(bounds[3] - bounds[2]) / 100,
262
+ hide_details=True,
263
+ dense=True,
264
+ thumb_label=False,
265
+ )
266
+ # Z bounds
267
+ vuetify.VRangeSlider(
268
+ v_model=(
269
+ f"clip_z_{m.label}",
270
+ [bounds[4], bounds[5]],
271
+ ),
272
+ label="Z Range",
273
+ min=bounds[4],
274
+ max=bounds[5],
275
+ step=(bounds[5] - bounds[4]) / 100,
276
+ hide_details=True,
277
+ dense=True,
278
+ thumb_label=False,
279
+ )
280
+
281
+ if self.scene.volumes:
282
+ vuetify.VSubheader("Volumes", classes="text-caption pl-4")
283
+ for i, v in enumerate(self.scene.volumes):
284
+ vuetify.VCheckbox(
285
+ v_model=f"volume_visibility_{v.label}",
286
+ on_icon="mdi-eye",
287
+ off_icon="mdi-eye-off",
288
+ classes="mx-1",
289
+ hide_details=True,
290
+ dense=True,
291
+ label=v.label,
292
+ )
293
+
294
+ # Preset selection in collapsible panel
295
+ available_presets = list_available_presets()
296
+ current_preset = self.server.state[f"volume_preset_{v.label}"]
297
+ current_desc = available_presets.get(
298
+ current_preset, current_preset
299
+ )
300
+
301
+ with vuetify.VExpansionPanels(
302
+ v_model=f"preset_panel_{v.label}",
303
+ flat=True,
304
+ classes="ml-4",
305
+ dense=True,
306
+ style="max-width: 270px;",
307
+ ):
308
+ with vuetify.VExpansionPanel():
309
+ vuetify.VExpansionPanelHeader("Transfer Function")
310
+ with vuetify.VExpansionPanelContent():
311
+ with vuetify.VRadioGroup(
312
+ v_model=f"volume_preset_{v.label}",
313
+ dense=True,
314
+ ):
315
+ for (
316
+ preset_key,
317
+ preset_desc,
318
+ ) in available_presets.items():
319
+ vuetify.VRadio(
320
+ label=preset_desc, value=preset_key
321
+ )
322
+
323
+ # Add clipping controls for volumes
324
+ if v.clipping_enabled:
325
+ vuetify.VCheckbox(
326
+ v_model=(
327
+ f"volume_clipping_{v.label}",
328
+ v.clipping_enabled,
329
+ ),
330
+ on_icon="mdi-cube-outline",
331
+ off_icon="mdi-cube-off-outline",
332
+ classes="mx-1 ml-4",
333
+ hide_details=True,
334
+ dense=True,
335
+ label=f"{v.label} Clipping",
336
+ )
337
+
338
+ # Get initial volume bounds for sliders
339
+ if v.actors:
340
+ bounds = v.combined_bounds
341
+ with vuetify.VExpansionPanels(
342
+ v_model=f"clip_panel_{v.label}",
343
+ multiple=True,
344
+ flat=True,
345
+ classes="ml-4",
346
+ dense=True,
347
+ style="max-width: 270px;",
348
+ ):
349
+ with vuetify.VExpansionPanel():
350
+ vuetify.VExpansionPanelHeader("Clip Bounds")
351
+ with vuetify.VExpansionPanelContent():
352
+ # X bounds
353
+ vuetify.VRangeSlider(
354
+ v_model=(
355
+ f"clip_x_{v.label}",
356
+ [bounds[0], bounds[1]],
357
+ ),
358
+ label="X Range",
359
+ min=bounds[0],
360
+ max=bounds[1],
361
+ step=(bounds[1] - bounds[0]) / 100,
362
+ hide_details=True,
363
+ dense=True,
364
+ thumb_label=False,
365
+ )
366
+ # Y bounds
367
+ vuetify.VRangeSlider(
368
+ v_model=(
369
+ f"clip_y_{v.label}",
370
+ [bounds[2], bounds[3]],
371
+ ),
372
+ label="Y Range",
373
+ min=bounds[2],
374
+ max=bounds[3],
375
+ step=(bounds[3] - bounds[2]) / 100,
376
+ hide_details=True,
377
+ dense=True,
378
+ thumb_label=False,
379
+ )
380
+ # Z bounds
381
+ vuetify.VRangeSlider(
382
+ v_model=(
383
+ f"clip_z_{v.label}",
384
+ [bounds[4], bounds[5]],
385
+ ),
386
+ label="Z Range",
387
+ min=bounds[4],
388
+ max=bounds[5],
389
+ step=(bounds[5] - bounds[4]) / 100,
390
+ hide_details=True,
391
+ dense=True,
392
+ thumb_label=False,
393
+ )
394
+
395
+ if self.scene.segmentations:
396
+ vuetify.VSubheader("Segmentations", classes="text-caption pl-4")
397
+ for i, s in enumerate(self.scene.segmentations):
398
+ vuetify.VCheckbox(
399
+ v_model=f"segmentation_visibility_{s.label}",
400
+ on_icon="mdi-eye",
401
+ off_icon="mdi-eye-off",
402
+ classes="mx-1",
403
+ hide_details=True,
404
+ dense=True,
405
+ label=s.label,
406
+ )
407
+
408
+ if s.clipping_enabled:
409
+ vuetify.VCheckbox(
410
+ v_model=(
411
+ f"segmentation_clipping_{s.label}",
412
+ s.clipping_enabled,
413
+ ),
414
+ on_icon="mdi-content-cut",
415
+ off_icon="mdi-content-cut",
416
+ classes="mx-1 ml-4",
417
+ hide_details=True,
418
+ dense=True,
419
+ label="Clip",
420
+ )
421
+
422
+ # Get initial segmentation bounds for sliders
423
+ if s.actors:
424
+ bounds = s.combined_bounds
425
+ with vuetify.VExpansionPanels(
426
+ v_model=f"clip_panel_{s.label}",
427
+ multiple=True,
428
+ flat=True,
429
+ classes="ml-4",
430
+ dense=True,
431
+ style="max-width: 270px;",
432
+ ):
433
+ with vuetify.VExpansionPanel():
434
+ vuetify.VExpansionPanelHeader("Clip Bounds")
435
+ with vuetify.VExpansionPanelContent():
436
+ # X bounds
437
+ vuetify.VRangeSlider(
438
+ v_model=(
439
+ f"clip_x_{s.label}",
440
+ [bounds[0], bounds[1]],
441
+ ),
442
+ label="X Range",
443
+ min=bounds[0],
444
+ max=bounds[1],
445
+ step=(bounds[1] - bounds[0]) / 100,
446
+ hide_details=True,
447
+ dense=True,
448
+ thumb_label=False,
449
+ )
450
+ # Y bounds
451
+ vuetify.VRangeSlider(
452
+ v_model=(
453
+ f"clip_y_{s.label}",
454
+ [bounds[2], bounds[3]],
455
+ ),
456
+ label="Y Range",
457
+ min=bounds[2],
458
+ max=bounds[3],
459
+ step=(bounds[3] - bounds[2]) / 100,
460
+ hide_details=True,
461
+ dense=True,
462
+ thumb_label=False,
463
+ )
464
+ # Z bounds
465
+ vuetify.VRangeSlider(
466
+ v_model=(
467
+ f"clip_z_{s.label}",
468
+ [bounds[4], bounds[5]],
469
+ ),
470
+ label="Z Range",
471
+ min=bounds[4],
472
+ max=bounds[5],
473
+ step=(bounds[5] - bounds[4]) / 100,
474
+ hide_details=True,
475
+ dense=True,
476
+ thumb_label=False,
477
+ )
cardio/utils.py ADDED
@@ -0,0 +1,101 @@
1
+ """Utility functions shared across cardio classes."""
2
+
3
+ import enum
4
+
5
+ import itk
6
+ import numpy as np
7
+
8
+
9
+ class InterpolatorType(enum.Enum):
10
+ """Interpolation methods for image resampling."""
11
+
12
+ LINEAR = "linear"
13
+ NEAREST = "nearest"
14
+
15
+
16
+ def reset_direction(
17
+ image, interpolator_type: InterpolatorType = InterpolatorType.LINEAR
18
+ ):
19
+ """Reset image direction to identity matrix, preserving origin.
20
+
21
+ This function handles the VTK reader issue where origin is not retained
22
+ by using ITK to properly transform the image coordinates.
23
+
24
+ Args:
25
+ image: ITK image object
26
+ interpolator_type: InterpolatorType enum for interpolation method
27
+ """
28
+ origin = np.asarray(itk.origin(image))
29
+ spacing = np.asarray(itk.spacing(image))
30
+ size = np.asarray(itk.size(image))
31
+ direction = np.asarray(image.GetDirection())
32
+
33
+ direction[direction == 1] = 0
34
+ origin += np.dot(size, np.dot(np.diag(spacing), direction))
35
+ direction = np.identity(3)
36
+
37
+ origin = itk.Point[itk.F, 3](origin)
38
+ spacing = itk.spacing(image)
39
+ size = itk.size(image)
40
+ direction = itk.matrix_from_array(direction)
41
+
42
+ # Select interpolator based on type
43
+ match interpolator_type:
44
+ case InterpolatorType.NEAREST:
45
+ interpolator = itk.NearestNeighborInterpolateImageFunction.New(image)
46
+ case InterpolatorType.LINEAR:
47
+ interpolator = itk.LinearInterpolateImageFunction.New(image)
48
+ case _:
49
+ raise ValueError(f"Unsupported interpolator type: {interpolator_type}")
50
+
51
+ output = itk.resample_image_filter(
52
+ image,
53
+ size=size,
54
+ interpolator=interpolator,
55
+ output_spacing=spacing,
56
+ output_origin=origin,
57
+ output_direction=direction,
58
+ )
59
+
60
+ return output
61
+
62
+
63
+ def calculate_combined_bounds(actors):
64
+ """Calculate combined bounds encompassing all VTK actors.
65
+
66
+ Args:
67
+ actors: List of VTK actors or list of lists/dicts of actors
68
+
69
+ Returns:
70
+ List of 6 floats: [xmin, xmax, ymin, ymax, zmin, zmax]
71
+ """
72
+ if not actors:
73
+ return [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
74
+
75
+ # Flatten actors if nested (for segmentations with frame_actors dict)
76
+ flat_actors = []
77
+ for item in actors:
78
+ if isinstance(item, dict):
79
+ flat_actors.extend(item.values())
80
+ elif isinstance(item, list):
81
+ flat_actors.extend(item)
82
+ else:
83
+ flat_actors.append(item)
84
+
85
+ if not flat_actors:
86
+ return [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
87
+
88
+ # Start with first actor's bounds
89
+ combined = list(flat_actors[0].GetBounds())
90
+
91
+ # Expand to encompass all actors
92
+ for actor in flat_actors[1:]:
93
+ bounds = actor.GetBounds()
94
+ combined[0] = min(combined[0], bounds[0]) # xmin
95
+ combined[1] = max(combined[1], bounds[1]) # xmax
96
+ combined[2] = min(combined[2], bounds[2]) # ymin
97
+ combined[3] = max(combined[3], bounds[3]) # ymax
98
+ combined[4] = min(combined[4], bounds[4]) # zmin
99
+ combined[5] = max(combined[5], bounds[5]) # zmax
100
+
101
+ return combined