cardio 2025.10.1__py3-none-any.whl → 2025.12.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/ui.py CHANGED
@@ -1,9 +1,13 @@
1
1
  import functools as ft
2
+ import time
2
3
 
4
+ import numpy as np
3
5
  from trame.ui.vuetify3 import SinglePageWithDrawerLayout
6
+ from trame.widgets import html
4
7
  from trame.widgets import vtk as vtk_widgets
5
8
  from trame.widgets import vuetify3 as vuetify
6
9
 
10
+ from .orientation import EulerAxis, euler_angle_to_rotation_matrix
7
11
  from .scene import Scene
8
12
  from .volume_property_presets import list_volume_property_presets
9
13
  from .window_level import presets
@@ -37,10 +41,46 @@ class UI:
37
41
 
38
42
  match event["type"]:
39
43
  case "KeyPress":
40
- if event["key"].isdigit():
41
- key_num = int(event["key"])
44
+ key = event["key"]
45
+ current_time = time.time() * 1000
46
+
47
+ if key in self.last_keypress_time:
48
+ time_since_last = current_time - self.last_keypress_time[key]
49
+ if time_since_last < self.keypress_debounce_ms:
50
+ return
51
+
52
+ self.last_keypress_time[key] = current_time
53
+
54
+ if key.isdigit():
55
+ key_num = int(key)
42
56
  if key_num in presets.keys():
43
57
  self.server.state.mpr_window_level_preset = key_num
58
+ elif key == "l":
59
+ current = getattr(self.server.state, "mpr_crosshairs_enabled", True)
60
+ self.server.state.mpr_crosshairs_enabled = not current
61
+ elif key == "h":
62
+ current = getattr(self.server.state, "help_overlay_visible", False)
63
+ self.server.state.help_overlay_visible = not current
64
+ elif key == "v":
65
+ current = getattr(self.server.state, "maximized_view", "")
66
+ self.server.state.maximized_view = (
67
+ "" if current == "volume" else "volume"
68
+ )
69
+ elif key == "a":
70
+ current = getattr(self.server.state, "maximized_view", "")
71
+ self.server.state.maximized_view = (
72
+ "" if current == "axial" else "axial"
73
+ )
74
+ elif key == "c":
75
+ current = getattr(self.server.state, "maximized_view", "")
76
+ self.server.state.maximized_view = (
77
+ "" if current == "coronal" else "coronal"
78
+ )
79
+ elif key == "s":
80
+ current = getattr(self.server.state, "maximized_view", "")
81
+ self.server.state.maximized_view = (
82
+ "" if current == "sagittal" else "sagittal"
83
+ )
44
84
 
45
85
  case "LeftButtonPress":
46
86
  self.left_dragging = True
@@ -99,32 +139,52 @@ class UI:
99
139
  event["position"]["y"],
100
140
  ]
101
141
 
102
- def _handle_slice_scroll(self, view_name, base_slice_delta):
103
- """Handle slice scrolling for a specific view."""
104
- match view_name:
105
- case "axial":
106
- current_slice = getattr(self.server.state, "axial_slice", 0.0)
107
- bounds = getattr(self.server.state, "axial_slice_bounds", [0.0, 100.0])
108
- slice_attr = "axial_slice"
109
- case "sagittal":
110
- current_slice = getattr(self.server.state, "sagittal_slice", 0.0)
111
- bounds = getattr(
112
- self.server.state, "sagittal_slice_bounds", [0.0, 100.0]
142
+ def _get_scroll_vector(self, view_name):
143
+ """Get the current normal vector for a view after rotation."""
144
+ base_normals = {
145
+ "axial": np.array([0.0, 0.0, 1.0]),
146
+ "sagittal": np.array([1.0, 0.0, 0.0]),
147
+ "coronal": np.array([0.0, 1.0, 0.0]),
148
+ }
149
+
150
+ if view_name not in base_normals:
151
+ return np.array([0.0, 0.0, 1.0])
152
+
153
+ # Build cumulative rotation from visible rotations
154
+ rotation_sequence = getattr(self.server.state, "mpr_rotation_sequence", [])
155
+ cumulative_rotation = np.eye(3)
156
+
157
+ for i in range(len(rotation_sequence)):
158
+ is_visible = getattr(self.server.state, f"mpr_rotation_visible_{i}", True)
159
+ if is_visible:
160
+ angle = getattr(self.server.state, f"mpr_rotation_angle_{i}", 0)
161
+ rotation = rotation_sequence[i]
162
+ rotation_matrix = euler_angle_to_rotation_matrix(
163
+ EulerAxis(rotation["axis"]), angle
113
164
  )
114
- slice_attr = "sagittal_slice"
115
- case "coronal":
116
- current_slice = getattr(self.server.state, "coronal_slice", 0.0)
117
- bounds = getattr(
118
- self.server.state, "coronal_slice_bounds", [0.0, 100.0]
119
- )
120
- slice_attr = "coronal_slice"
121
- case _:
122
- return
165
+ cumulative_rotation = cumulative_rotation @ rotation_matrix
166
+
167
+ return cumulative_rotation @ base_normals[view_name]
123
168
 
124
- min_bound, max_bound = min(bounds), max(bounds)
125
- slice_delta = base_slice_delta if bounds[1] > bounds[0] else -base_slice_delta
126
- new_slice = max(min_bound, min(max_bound, current_slice + slice_delta))
127
- setattr(self.server.state, slice_attr, new_slice)
169
+ def _handle_slice_scroll(self, view_name, base_slice_delta):
170
+ """Handle slice scrolling for a specific view using rotated scroll vectors."""
171
+ if view_name not in {"axial", "sagittal", "coronal"}:
172
+ return
173
+
174
+ # Get current origin
175
+ origin = getattr(self.server.state, "mpr_origin", [0.0, 0.0, 0.0])
176
+
177
+ # Get scroll vector for this view (considering rotation)
178
+ scroll_vector = self._get_scroll_vector(view_name)
179
+
180
+ # Update origin along the rotated scroll direction
181
+ new_origin = [
182
+ origin[0] + base_slice_delta * scroll_vector[0],
183
+ origin[1] + base_slice_delta * scroll_vector[1],
184
+ origin[2] + base_slice_delta * scroll_vector[2],
185
+ ]
186
+
187
+ self.server.state.mpr_origin = new_origin
128
188
 
129
189
  def __init__(self, server, scene: Scene):
130
190
  self.server = server
@@ -135,6 +195,10 @@ class UI:
135
195
  self.window_sensitivity = 5.0
136
196
  self.level_sensitivity = 2.0
137
197
  self.slice_sensitivity = 1.0
198
+ self.last_keypress_time = {}
199
+ self.keypress_debounce_ms = 100
200
+ self.server.state.help_overlay_visible = False
201
+ self.server.state.maximized_view = ""
138
202
 
139
203
  self.setup()
140
204
 
@@ -181,9 +245,9 @@ class UI:
181
245
  )
182
246
 
183
247
  with layout.content:
184
- # Single VR view (default mode)
248
+ # Single VR view (volume maximized)
185
249
  with vuetify.VContainer(
186
- v_if="!mpr_enabled",
250
+ v_if="maximized_view === 'volume'",
187
251
  fluid=True,
188
252
  classes="pa-0 fill-height",
189
253
  ):
@@ -197,9 +261,9 @@ class UI:
197
261
  self.server.controller.view_reset_camera = view.reset_camera
198
262
  self.server.controller.on_server_ready.add(view.update)
199
263
 
200
- # Quad-view layout (MPR mode) - directly in content like app.py
264
+ # Quad-view layout (MPR mode - default)
201
265
  with vuetify.VContainer(
202
- v_if="mpr_enabled",
266
+ v_if="!maximized_view",
203
267
  fluid=True,
204
268
  classes="pa-0",
205
269
  style="height: calc(100vh - 85px);",
@@ -263,6 +327,10 @@ class UI:
263
327
  self.server.controller.on_server_ready.add(
264
328
  self._update_all_mpr_views
265
329
  )
330
+ # Finalize MPR initialization after UI is ready to avoid race condition
331
+ self.server.controller.on_server_ready.add(
332
+ self.server.controller.finalize_mpr_initialization
333
+ )
266
334
 
267
335
  # Store individual view update functions
268
336
  self.server.controller.axial_update = axial_view.update
@@ -270,21 +338,131 @@ class UI:
270
338
  self.server.controller.sagittal_update = sagittal_view.update
271
339
  self.server.controller.volume_update = volume_view.update
272
340
 
273
- with layout.drawer:
274
- # MPR Mode Toggle (only show when volumes are present)
275
- if self.scene.volumes:
276
- vuetify.VListSubheader("View Mode")
277
- vuetify.VCheckbox(
278
- v_model=("mpr_enabled", False),
279
- label="Multi-Planar Reconstruction (MPR)",
280
- title="Toggle between single 3D view and quad-view with slice planes",
281
- dense=True,
282
- hide_details=True,
341
+ # Maximized axial view
342
+ with vuetify.VContainer(
343
+ v_if="maximized_view === 'axial'",
344
+ fluid=True,
345
+ classes="pa-0 fill-height",
346
+ ):
347
+ axial_maximized_view = vtk_widgets.VtkRemoteView(
348
+ self.scene.axial_renderWindow,
349
+ interactor_events=("event_types", self.handled_events),
350
+ **self.event_listeners_for_view("axial"),
351
+ interactive_ratio=1,
352
+ )
353
+
354
+ # Maximized coronal view
355
+ with vuetify.VContainer(
356
+ v_if="maximized_view === 'coronal'",
357
+ fluid=True,
358
+ classes="pa-0 fill-height",
359
+ ):
360
+ coronal_maximized_view = vtk_widgets.VtkRemoteView(
361
+ self.scene.coronal_renderWindow,
362
+ interactor_events=("event_types", self.handled_events),
363
+ **self.event_listeners_for_view("coronal"),
364
+ interactive_ratio=1,
365
+ )
366
+
367
+ # Maximized sagittal view
368
+ with vuetify.VContainer(
369
+ v_if="maximized_view === 'sagittal'",
370
+ fluid=True,
371
+ classes="pa-0 fill-height",
372
+ ):
373
+ sagittal_maximized_view = vtk_widgets.VtkRemoteView(
374
+ self.scene.sagittal_renderWindow,
375
+ interactor_events=("event_types", self.handled_events),
376
+ **self.event_listeners_for_view("sagittal"),
377
+ interactive_ratio=1,
283
378
  )
284
379
 
285
- # Volume selection dropdown (only show when MPR is enabled)
380
+ with vuetify.VDialog(
381
+ v_model=("help_overlay_visible", False),
382
+ max_width="700px",
383
+ scrim="rgba(0, 0, 0, 0.7)",
384
+ ):
385
+ with vuetify.VCard(
386
+ classes="pa-6",
387
+ style="background: rgba(33, 33, 33, 0.95);",
388
+ ):
389
+ vuetify.VCardTitle(
390
+ "Keyboard Shortcuts & Controls", classes="text-h5 mb-4"
391
+ )
392
+
393
+ with vuetify.VCardText():
394
+ html.H3("Keyboard Shortcuts", classes="text-h6 mb-3")
395
+ with vuetify.VTable(density="compact", classes="mb-4"):
396
+ with html.Thead():
397
+ with html.Tr():
398
+ html.Th("Key")
399
+ html.Th("Action")
400
+ with html.Tbody():
401
+ with html.Tr():
402
+ html.Td("h")
403
+ html.Td("Toggle this help window")
404
+ with html.Tr():
405
+ html.Td("v")
406
+ html.Td("Toggle 3D volume view")
407
+ with html.Tr():
408
+ html.Td("a")
409
+ html.Td("Toggle axial view")
410
+ with html.Tr():
411
+ html.Td("c")
412
+ html.Td("Toggle coronal view")
413
+ with html.Tr():
414
+ html.Td("s")
415
+ html.Td("Toggle sagittal view")
416
+ with html.Tr():
417
+ html.Td("l")
418
+ html.Td("Toggle crosshairs")
419
+
420
+ html.H3("Window/Level Presets", classes="text-h6 mb-3")
421
+ with vuetify.VTable(density="compact", classes="mb-4"):
422
+ with html.Thead():
423
+ with html.Tr():
424
+ html.Th("Key")
425
+ html.Th("Preset")
426
+ html.Th("Window")
427
+ html.Th("Level")
428
+ with html.Tbody():
429
+ for key, preset in presets.items():
430
+ with html.Tr():
431
+ html.Td(str(key))
432
+ html.Td(preset.name)
433
+ html.Td(str(preset.window))
434
+ html.Td(str(preset.level))
435
+
436
+ html.H3("Mouse Controls (MPR Mode)", classes="text-h6 mb-3")
437
+ with vuetify.VTable(density="compact"):
438
+ with html.Thead():
439
+ with html.Tr():
440
+ html.Th("Action")
441
+ html.Th("Effect")
442
+ with html.Tbody():
443
+ with html.Tr():
444
+ html.Td("Left Drag ←/→")
445
+ html.Td("Narrow/widen window")
446
+ with html.Tr():
447
+ html.Td("Left Drag ↑/↓")
448
+ html.Td("Increase/decrease level")
449
+ with html.Tr():
450
+ html.Td("Right Drag ↑/↓")
451
+ html.Td("Scroll through slices")
452
+
453
+ with vuetify.VCardActions():
454
+ vuetify.VSpacer()
455
+ vuetify.VBtn(
456
+ "Close",
457
+ click="help_overlay_visible = false",
458
+ variant="text",
459
+ )
460
+
461
+ with layout.drawer:
462
+ # Volume selection dropdown
463
+ if self.scene.volumes:
286
464
  vuetify.VSelect(
287
- v_if="mpr_enabled",
465
+ v_if="!maximized_view || maximized_view === 'volume'",
288
466
  v_model=("active_volume_label", ""),
289
467
  items=("volume_items", []),
290
468
  item_title="text",
@@ -296,11 +474,11 @@ class UI:
296
474
 
297
475
  # Window/Level controls for MPR
298
476
  vuetify.VListSubheader(
299
- "Window/Level", v_if="mpr_enabled && active_volume_label"
477
+ "Window/Level", v_if="!maximized_view && active_volume_label"
300
478
  )
301
479
 
302
480
  vuetify.VSelect(
303
- v_if="mpr_enabled && active_volume_label",
481
+ v_if="!maximized_view && active_volume_label",
304
482
  v_model=("mpr_window_level_preset", 7),
305
483
  items=("mpr_presets", []),
306
484
  item_title="text",
@@ -311,7 +489,7 @@ class UI:
311
489
  )
312
490
 
313
491
  vuetify.VSlider(
314
- v_if="mpr_enabled && active_volume_label",
492
+ v_if="!maximized_view && active_volume_label",
315
493
  v_model="mpr_window",
316
494
  min=1.0,
317
495
  max=2000.0,
@@ -324,7 +502,7 @@ class UI:
324
502
  )
325
503
 
326
504
  vuetify.VSlider(
327
- v_if="mpr_enabled && active_volume_label",
505
+ v_if="!maximized_view && active_volume_label",
328
506
  v_model="mpr_level",
329
507
  min=-1000.0,
330
508
  max=1000.0,
@@ -336,55 +514,23 @@ class UI:
336
514
  thumb_label=True,
337
515
  )
338
516
 
339
- # Slice position controls (only show when MPR is enabled and volume is selected)
340
- vuetify.VListSubheader(
341
- "Slice Positions", v_if="mpr_enabled && active_volume_label"
342
- )
343
-
344
- vuetify.VSlider(
345
- v_if="mpr_enabled && active_volume_label",
346
- v_model=("axial_slice", 0.0),
347
- min=("axial_slice_bounds[0]", 0.0),
348
- max=("axial_slice_bounds[1]", 100.0),
349
- hint="A (I ↔ S)",
350
- persistent_hint=True,
351
- dense=True,
352
- hide_details=False,
353
- thumb_label=True,
354
- )
355
-
356
- vuetify.VSlider(
357
- v_if="mpr_enabled && active_volume_label",
358
- v_model=("sagittal_slice", 0.0),
359
- min=("sagittal_slice_bounds[0]", 0.0),
360
- max=("sagittal_slice_bounds[1]", 100.0),
361
- hint="S (R ↔ L)",
362
- persistent_hint=True,
363
- dense=True,
364
- hide_details=False,
365
- thumb_label=True,
366
- )
367
-
368
- vuetify.VSlider(
369
- v_if="mpr_enabled && active_volume_label",
370
- v_model=("coronal_slice", 0.0),
371
- min=("coronal_slice_bounds[0]", 0.0),
372
- max=("coronal_slice_bounds[1]", 100.0),
373
- hint="C (P ↔ A)",
374
- persistent_hint=True,
517
+ vuetify.VCheckbox(
518
+ v_if="!maximized_view && active_volume_label",
519
+ v_model=("mpr_crosshairs_enabled", True),
520
+ label="Show Crosshairs",
375
521
  dense=True,
376
- hide_details=False,
377
- thumb_label=True,
522
+ hide_details=True,
523
+ classes="mt-2",
378
524
  )
379
525
 
380
526
  # MPR Rotation controls
381
527
  vuetify.VListSubheader(
382
- "Rotations", v_if="mpr_enabled && active_volume_label"
528
+ "Rotations", v_if="!maximized_view && active_volume_label"
383
529
  )
384
530
 
385
531
  # Rotation buttons
386
532
  with vuetify.VRow(
387
- v_if="mpr_enabled && active_volume_label",
533
+ v_if="!maximized_view && active_volume_label",
388
534
  no_gutters=True,
389
535
  classes="mb-2",
390
536
  ):
@@ -419,7 +565,7 @@ class UI:
419
565
  # Reset rotations button
420
566
  vuetify.VBtn(
421
567
  "Reset",
422
- v_if="mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
568
+ v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
423
569
  click=self.server.controller.reset_rotations,
424
570
  small=True,
425
571
  dense=True,
@@ -433,7 +579,7 @@ class UI:
433
579
  # Individual rotation sliders
434
580
  for i in range(self.scene.max_mpr_rotations):
435
581
  with vuetify.VRow(
436
- v_if=f"mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
582
+ v_if=f"!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > {i}",
437
583
  no_gutters=True,
438
584
  classes="align-center mb-1",
439
585
  ):
@@ -475,7 +621,7 @@ class UI:
475
621
 
476
622
  # Angle units selector
477
623
  with vuetify.VRow(
478
- v_if="mpr_enabled && active_volume_label",
624
+ v_if="!maximized_view && active_volume_label",
479
625
  no_gutters=True,
480
626
  classes="align-center mb-2 mt-2",
481
627
  ):
@@ -495,7 +641,7 @@ class UI:
495
641
  # Save rotations button
496
642
  vuetify.VBtn(
497
643
  "Save Rotations",
498
- v_if="mpr_enabled && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
644
+ v_if="!maximized_view && active_volume_label && mpr_rotation_sequence && mpr_rotation_sequence.length > 0",
499
645
  click=self.server.controller.save_rotation_angles,
500
646
  small=True,
501
647
  dense=True,