medscope 0.1.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.
medscope/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .main import MedScopeWindow
2
+ from .main import QApplication as MedScopeSystem
3
+
4
+ __all__ = [
5
+ "MedScopeWindow",
6
+ "MedScopeSystem"
7
+ ]
medscope/main.py ADDED
@@ -0,0 +1,800 @@
1
+ import numpy as np
2
+ from typing import Optional, List, Tuple, Dict, Any, Union, Callable
3
+ from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QFrame
4
+ from PyQt5.QtCore import Qt, QTimer
5
+ from PyQt5.QtGui import QImage, QPixmap, QPainter, QPaintEvent
6
+ import os
7
+
8
+ # VTK imports
9
+ import vtk
10
+ from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
11
+
12
+
13
+ class VTKModelManager:
14
+ """Manages 3D models in VTK scene."""
15
+
16
+ def __init__(self, renderer: vtk.vtkRenderer):
17
+ """
18
+ Initialize the model manager.
19
+
20
+ Args:
21
+ renderer: VTK renderer to add models to
22
+ """
23
+ self.renderer: vtk.vtkRenderer = renderer
24
+ self.models: Dict[str, vtk.vtkActor] = {} # name -> actor
25
+ self.model_sources: Dict[str, Any] = {} # name -> source (for potential regeneration)
26
+
27
+ def add_model_from_file(self, name: str, file_path: str,
28
+ color: Optional[Tuple[float, float, float]] = None,
29
+ scale: float = 1.0,
30
+ position: Tuple[float, float, float] = (0, 0, 0)) -> bool:
31
+ """
32
+ Add a model from a file (supports .vtk, .stl, .obj, etc.)
33
+
34
+ Args:
35
+ name: Unique identifier for the model
36
+ file_path: Path to the model file
37
+ color: RGB tuple (0-1) or None for default
38
+ scale: Scale factor
39
+ position: (x, y, z) position
40
+
41
+ Returns:
42
+ bool: True if successful, False otherwise
43
+ """
44
+ if name in self.models:
45
+ print(f"Model '{name}' already exists. Remove it first or use a different name.")
46
+ return False
47
+
48
+ # Determine reader based on file extension
49
+ if file_path.lower().endswith('.stl'):
50
+ reader = vtk.vtkSTLReader()
51
+ elif file_path.lower().endswith('.obj'):
52
+ reader = vtk.vtkOBJReader()
53
+ elif file_path.lower().endswith('.vtk'):
54
+ reader = vtk.vtkDataSetReader()
55
+ elif file_path.lower().endswith('.ply'):
56
+ reader = vtk.vtkPLYReader()
57
+ else:
58
+ print(f"Unsupported file format: {file_path}")
59
+ return False
60
+
61
+ if not os.path.isfile(file_path):
62
+ print(f"Model file {file_path} not found.")
63
+ return False
64
+
65
+ reader.SetFileName(file_path)
66
+ reader.Update()
67
+
68
+ return self._add_model_from_algorithm(name, reader, color, scale, position)
69
+
70
+ def _add_model_from_algorithm(self, name: str, algorithm: Any,
71
+ color: Optional[Tuple[float, float, float]],
72
+ scale: float,
73
+ position: Tuple[float, float, float]) -> bool:
74
+ """Internal method to add model from a VTK algorithm."""
75
+ mapper = vtk.vtkPolyDataMapper()
76
+ mapper.SetInputConnection(algorithm.GetOutputPort())
77
+ return self._add_model_from_mapper(name, mapper, color, scale, position)
78
+
79
+ def _add_model_from_mapper(self, name: str, mapper: vtk.vtkPolyDataMapper,
80
+ color: Optional[Tuple[float, float, float]],
81
+ scale: float,
82
+ position: Tuple[float, float, float]) -> bool:
83
+ """Internal method to add model from a mapper."""
84
+ actor = vtk.vtkActor()
85
+ actor.SetMapper(mapper)
86
+
87
+ # Apply color if provided
88
+ if color is not None:
89
+ actor.GetProperty().SetColor(color[0], color[1], color[2])
90
+ else:
91
+ # Random color
92
+ actor.GetProperty().SetColor(np.random.rand(), np.random.rand(), np.random.rand())
93
+
94
+ # Apply transformation
95
+ transform = vtk.vtkTransform()
96
+ transform.Scale(scale, scale, scale)
97
+ transform.Translate(position[0], position[1], position[2])
98
+ actor.SetUserTransform(transform)
99
+
100
+ # Add to renderer and store
101
+ self.renderer.AddActor(actor)
102
+ self.models[name] = actor
103
+
104
+ return True
105
+
106
+ def remove_model(self, name: str) -> bool:
107
+ """
108
+ Remove a model from the scene.
109
+
110
+ Args:
111
+ name: Name of the model to remove
112
+
113
+ Returns:
114
+ bool: True if removed, False if not found
115
+ """
116
+ if name in self.models:
117
+ self.renderer.RemoveActor(self.models[name])
118
+ del self.models[name]
119
+ if name in self.model_sources:
120
+ del self.model_sources[name]
121
+ return True
122
+ return False
123
+
124
+ def set_model_scale(self, name: str, scale: float) -> bool:
125
+ """
126
+ Set model scale (uniform scaling).
127
+
128
+ Args:
129
+ name: Model name
130
+ scale: Scale factor
131
+
132
+ Returns:
133
+ bool: True if successful, False if model not found
134
+ """
135
+ if name not in self.models:
136
+ print(f"Model '{name}' not found.")
137
+ return False
138
+
139
+ actor = self.models[name]
140
+ current_transform = actor.GetUserTransform()
141
+ if current_transform:
142
+ # Get current position
143
+ position = current_transform.GetPosition()
144
+ # Create new transform with new scale
145
+ new_transform = vtk.vtkTransform()
146
+ new_transform.Scale(scale, scale, scale)
147
+ new_transform.Translate(position[0], position[1], position[2])
148
+ actor.SetUserTransform(new_transform)
149
+ else:
150
+ transform = vtk.vtkTransform()
151
+ transform.Scale(scale, scale, scale)
152
+ actor.SetUserTransform(transform)
153
+ return True
154
+
155
+ def set_model_pose(self, name: str,
156
+ translation_matrix: Optional[Union[np.ndarray, Tuple[float, float, float]]] = None,
157
+ rotation_matrix: Optional[np.ndarray] = None) -> bool:
158
+ """
159
+ Set model pose using translation and rotation matrices.
160
+
161
+ Args:
162
+ name: Model name
163
+ translation_matrix: 3-element array for translation or 3x3/4x4 matrix
164
+ rotation_matrix: 3x3 or 4x4 numpy array for rotation
165
+
166
+ Returns:
167
+ bool: True if successful, False if model not found
168
+ """
169
+ if name not in self.models:
170
+ print(f"Model '{name}' not found.")
171
+ return False
172
+
173
+ actor = self.models[name]
174
+ transform = vtk.vtkTransform()
175
+ transform.Identity()
176
+
177
+ # Apply rotation
178
+ if rotation_matrix is not None:
179
+ # Convert numpy array to vtk matrix
180
+ if rotation_matrix.shape == (3, 3):
181
+ mat = vtk.vtkMatrix4x4()
182
+ mat.Identity()
183
+ for i in range(3):
184
+ for j in range(3):
185
+ mat.SetElement(i, j, rotation_matrix[i, j])
186
+ transform.Concatenate(mat)
187
+ elif rotation_matrix.shape == (4, 4):
188
+ mat = vtk.vtkMatrix4x4()
189
+ for i in range(4):
190
+ for j in range(4):
191
+ mat.SetElement(i, j, rotation_matrix[i, j])
192
+ transform.Concatenate(mat)
193
+
194
+ # Apply translation
195
+ if translation_matrix is not None:
196
+ if isinstance(translation_matrix, tuple) and len(translation_matrix) == 3:
197
+ transform.Translate(translation_matrix[0], translation_matrix[1], translation_matrix[2])
198
+ elif isinstance(translation_matrix, np.ndarray):
199
+ if translation_matrix.shape == (3,):
200
+ transform.Translate(translation_matrix[0], translation_matrix[1], translation_matrix[2])
201
+ elif translation_matrix.shape == (3, 1):
202
+ transform.Translate(translation_matrix[0, 0], translation_matrix[1, 0], translation_matrix[2, 0])
203
+
204
+ actor.SetUserTransform(transform)
205
+ return True
206
+
207
+ def set_model_position(self, name: str, x: float, y: float, z: float) -> bool:
208
+ """
209
+ Set model position.
210
+
211
+ Args:
212
+ name: Model name
213
+ x: X coordinate
214
+ y: Y coordinate
215
+ z: Z coordinate
216
+
217
+ Returns:
218
+ bool: True if successful, False if model not found
219
+ """
220
+ if name not in self.models:
221
+ print(f"Model '{name}' not found.")
222
+ return False
223
+
224
+ actor = self.models[name]
225
+ current_transform = actor.GetUserTransform()
226
+ if current_transform:
227
+ # Get current scale
228
+ scale = current_transform.GetScale()
229
+ new_transform = vtk.vtkTransform()
230
+ new_transform.Scale(scale[0], scale[1], scale[2])
231
+ new_transform.Translate(x, y, z)
232
+ actor.SetUserTransform(new_transform)
233
+ else:
234
+ transform = vtk.vtkTransform()
235
+ transform.Translate(x, y, z)
236
+ actor.SetUserTransform(transform)
237
+ return True
238
+
239
+ def get_model_list(self) -> List[str]:
240
+ """Return list of all model names."""
241
+ return list(self.models.keys())
242
+
243
+ def clear_all_models(self) -> None:
244
+ """Remove all models from the scene."""
245
+ for name in list(self.models.keys()):
246
+ self.remove_model(name)
247
+
248
+ class VTKWidget(QFrame):
249
+ """VTK widget for 3D visualization with model management."""
250
+
251
+ def __init__(self, parent: Optional[QWidget] = None):
252
+ super().__init__(parent)
253
+ self.setFrameStyle(QFrame.Box)
254
+ self.setStyleSheet("background-color: #2a2a2a;")
255
+
256
+ # Create VTK renderer and widget
257
+ self.vtk_widget: QVTKRenderWindowInteractor = QVTKRenderWindowInteractor(self)
258
+ self.renderer: vtk.vtkRenderer = vtk.vtkRenderer()
259
+
260
+ # IMPORTANT: Get the render window and configure it properly
261
+ render_window = self.vtk_widget.GetRenderWindow()
262
+ render_window.AddRenderer(self.renderer)
263
+
264
+ # CRITICAL: Set window size and position to match parent widget
265
+ # This prevents VTK from creating its own separate window
266
+ render_window.SetSize(self.width(), self.height())
267
+ render_window.SetPosition(0, 0)
268
+
269
+ # Optional: Disable off-screen rendering if not needed
270
+ # render_window.SetOffScreenRendering(False)
271
+
272
+ # Set background color (dark gray)
273
+ self.renderer.SetBackground(0.2, 0.2, 0.2)
274
+
275
+ # Create model manager
276
+ self.model_manager: VTKModelManager = VTKModelManager(self.renderer)
277
+
278
+ # Initialize camera with default settings
279
+ self.set_camera_default()
280
+
281
+ # Initialize interactor
282
+ render_window.Render()
283
+ self.vtk_widget.Initialize()
284
+
285
+ # Layout
286
+ layout = QVBoxLayout(self)
287
+ layout.setContentsMargins(0, 0, 0, 0)
288
+ layout.addWidget(self.vtk_widget)
289
+
290
+ # Mouse interaction enabled by default
291
+ self.mouse_interaction_enabled: bool = True
292
+
293
+ # Override resize event to update VTK window size
294
+ def resizeEvent(self, a0):
295
+ """Update VTK render window size when widget is resized."""
296
+ super().resizeEvent(a0)
297
+ if hasattr(self, 'vtk_widget'):
298
+ render_window = self.vtk_widget.GetRenderWindow()
299
+ render_window.SetSize(self.width(), self.height())
300
+ self.vtk_render()
301
+
302
+ def set_camera_default(self) -> None:
303
+ """Set default camera position."""
304
+ self.renderer.ResetCamera()
305
+ self.renderer.GetActiveCamera().SetClippingRange(0.01, 10 ** 7)
306
+
307
+ def set_camera_y_direction(self, up_vector: Tuple[float, float, float] = (0, 1, 0)) -> None:
308
+ """
309
+ Set camera's Y-axis direction (up vector).
310
+
311
+ Args:
312
+ up_vector: Tuple (x, y, z) specifying the up direction
313
+ """
314
+ self.renderer.GetActiveCamera().SetViewUp(up_vector[0], up_vector[1], up_vector[2])
315
+ self.vtk_render()
316
+
317
+ def set_camera_pose(self, position: Tuple[float, float, float],
318
+ focal_point: Tuple[float, float, float],
319
+ view_up: Tuple[float, float, float] = (0, 1, 0)) -> None:
320
+ """
321
+ Set camera position and orientation.
322
+
323
+ Args:
324
+ position: Tuple (x, y, z) camera position
325
+ focal_point: Tuple (x, y, z) point the camera looks at
326
+ view_up: Tuple (x, y, z) up vector
327
+ """
328
+ camera = self.renderer.GetActiveCamera()
329
+ camera.SetPosition(position[0], position[1], position[2])
330
+ camera.SetFocalPoint(focal_point[0], focal_point[1], focal_point[2])
331
+ camera.SetViewUp(view_up[0], view_up[1], view_up[2])
332
+ self.vtk_render()
333
+
334
+ def set_camera_clipping_range(self, near_plane: float = 0.01, far_plane: float = 1000) -> None:
335
+ """
336
+ Set camera clipping planes to maximum range.
337
+
338
+ Args:
339
+ near_plane: Near clipping plane distance
340
+ far_plane: Far clipping plane distance
341
+ """
342
+ self.renderer.GetActiveCamera().SetClippingRange(near_plane, far_plane)
343
+ self.vtk_render()
344
+
345
+ def set_mouse_interaction(self, enabled: bool) -> None:
346
+ """
347
+ Enable or disable mouse interaction with the scene.
348
+
349
+ Args:
350
+ enabled: Boolean, True to enable mouse interaction, False to disable
351
+ """
352
+ self.mouse_interaction_enabled = enabled
353
+ interactor = self.vtk_widget.GetRenderWindow().GetInteractor()
354
+
355
+ if enabled:
356
+ if hasattr(self, '_original_style'):
357
+ interactor.SetInteractorStyle(self._original_style)
358
+ else:
359
+ style = vtk.vtkInteractorStyleTrackballCamera()
360
+ interactor.SetInteractorStyle(style)
361
+ else:
362
+ # 保存当前样式
363
+ self._original_style = interactor.GetInteractorStyle()
364
+ null_style = vtk.vtkInteractorStyle()
365
+ null_style.SetAutoAdjustCameraClippingRange(False)
366
+ interactor.SetInteractorStyle(null_style)
367
+
368
+ self.vtk_render()
369
+
370
+ def add_model_from_file(self, name: str, file_path: str,
371
+ color: Optional[Tuple[float, float, float]] = None,
372
+ scale: float = 1.0,
373
+ position: Tuple[float, float, float] = (0, 0, 0)) -> bool:
374
+ """Add model from file."""
375
+ return self.model_manager.add_model_from_file(name, file_path, color, scale, position)
376
+
377
+ def remove_model(self, name: str) -> bool:
378
+ """Remove a model by name."""
379
+ return self.model_manager.remove_model(name)
380
+
381
+ def set_model_scale(self, name: str, scale: float) -> bool:
382
+ """Set model scale."""
383
+ return self.model_manager.set_model_scale(name, scale)
384
+
385
+ def set_model_pose(self, name: str,
386
+ translation_matrix: Optional[Union[np.ndarray, Tuple[float, float, float]]] = None,
387
+ rotation_matrix: Optional[np.ndarray] = None) -> bool:
388
+ """Set model pose with matrices."""
389
+ return self.model_manager.set_model_pose(name, translation_matrix, rotation_matrix)
390
+
391
+ def set_model_position(self, name: str, x: float, y: float, z: float) -> bool:
392
+ """Set model position."""
393
+ return self.model_manager.set_model_position(name, x, y, z)
394
+
395
+ def get_model_list(self) -> List[str]:
396
+ """Get list of all model names."""
397
+ return self.model_manager.get_model_list()
398
+
399
+ def clear_all_models(self) -> None:
400
+ """Remove all models."""
401
+ self.model_manager.clear_all_models()
402
+
403
+ def vtk_render(self) -> None:
404
+ """Force a render."""
405
+ if self.vtk_widget:
406
+ self.vtk_widget.GetRenderWindow().Render()
407
+
408
+ def get_renderer(self) -> vtk.vtkRenderer:
409
+ """Return the VTK renderer for external manipulation."""
410
+ return self.renderer
411
+
412
+
413
+ class ImageDisplayWidget(QFrame):
414
+ """Widget for displaying a 2D slice from a 3D volume."""
415
+
416
+ def __init__(self, parent: Optional[QWidget] = None):
417
+ super().__init__(parent)
418
+ self.setFrameStyle(QFrame.Box)
419
+ self.setStyleSheet("background-color: black;")
420
+ self.image_data: Optional[np.ndarray] = None
421
+ self.setMinimumSize(100, 100)
422
+
423
+ def update_slice(self, slice_2d: np.ndarray) -> None:
424
+ """
425
+ Update displayed 2D slice.
426
+
427
+ Args:
428
+ slice_2d: 2D numpy array (grayscale, uint8)
429
+ """
430
+ if slice_2d is None:
431
+ return
432
+
433
+ # Store a copy to avoid memory issues
434
+ self.image_data = slice_2d.copy()
435
+ self.update() # trigger repaint
436
+
437
+ def paintEvent(self, a0: QPaintEvent|None) -> None:
438
+ """Paint the image scaled to fit the widget while preserving aspect ratio."""
439
+ if self.image_data is None or self.image_data.size == 0:
440
+ return
441
+
442
+ h, w = self.image_data.shape
443
+ # Create a copy to ensure data persists
444
+ img_copy = self.image_data.copy()
445
+ qimage = QImage(img_copy.data, w, h, w, QImage.Format_Grayscale8)
446
+
447
+ # Scale to widget size while preserving aspect ratio
448
+ pixmap = QPixmap.fromImage(qimage)
449
+ scaled_pixmap = pixmap.scaled(
450
+ self.size(),
451
+ Qt.AspectRatioMode.KeepAspectRatio,
452
+ Qt.TransformationMode.SmoothTransformation)
453
+
454
+ # Center the image
455
+ painter = QPainter(self)
456
+ painter.fillRect(self.rect(), Qt.GlobalColor.black)
457
+ x = (self.width() - scaled_pixmap.width()) // 2
458
+ y = (self.height() - scaled_pixmap.height()) // 2
459
+ painter.drawPixmap(x, y, scaled_pixmap)
460
+ painter.end()
461
+
462
+
463
+ class VolumeSliceViewer:
464
+ """Manages 3D volume and extracts slices for display."""
465
+
466
+ def __init__(self, image_widgets: List[ImageDisplayWidget]):
467
+ """
468
+ Initialize the volume slice viewer.
469
+
470
+ Args:
471
+ image_widgets: List of 3 ImageDisplayWidgets for XY, XZ, YZ slices
472
+ """
473
+ self.volume_data: Optional[np.ndarray] = None # 3D uint8 array
474
+ self.image_widgets = image_widgets
475
+
476
+ # Current slice indices
477
+ self.current_x: int = 0
478
+ self.current_y: int = 0
479
+ self.current_z: int = 0
480
+
481
+ def set_volume(self, volume: np.ndarray) -> None:
482
+ """
483
+ Set the 3D volume data.
484
+
485
+ Args:
486
+ volume: 3D numpy array (uint8) with shape (depth, height, width) or (z, y, x)
487
+ """
488
+ if volume is None:
489
+ return
490
+
491
+ assert volume.ndim == 3, "Volume must be 3D"
492
+ assert volume.dtype == np.uint8, "Volume must be uint8"
493
+
494
+ self.volume_data = volume
495
+
496
+ # Update all slices with current indices
497
+ self.update_all_slices()
498
+
499
+ def set_slice_position(self, axis: str, position: int) -> None:
500
+ """
501
+ Set the slice position for a specific axis.
502
+
503
+ Args:
504
+ axis: 'x', 'y', or 'z'
505
+ position: Slice index
506
+ """
507
+ if self.volume_data is None:
508
+ return
509
+
510
+ old_position = None
511
+ if axis == 'x':
512
+ old_position = self.current_x
513
+ self.current_x = max(0, min(position, self.volume_data.shape[0] - 1))
514
+ if old_position == self.current_x:
515
+ return
516
+ elif axis == 'y':
517
+ old_position = self.current_y
518
+ self.current_y = max(0, min(position, self.volume_data.shape[1] - 1))
519
+ if old_position == self.current_y:
520
+ return
521
+ elif axis == 'z':
522
+ old_position = self.current_z
523
+ self.current_z = max(0, min(position, self.volume_data.shape[2] - 1))
524
+ if old_position == self.current_z:
525
+ return
526
+ else:
527
+ raise ValueError("Axis must be 'x', 'y', or 'z'")
528
+
529
+ # Update the corresponding slice
530
+ self._update_slice(axis)
531
+
532
+ def set_slice_positions(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None) -> None:
533
+ """
534
+ Set multiple slice positions at once.
535
+
536
+ Args:
537
+ x: X slice index (optional)
538
+ y: Y slice index (optional)
539
+ z: Z slice index (optional)
540
+ """
541
+ if self.volume_data is None:
542
+ return
543
+
544
+ need_update = False
545
+
546
+ if x is not None:
547
+ new_x = max(0, min(x, self.volume_data.shape[0] - 1))
548
+ if new_x != self.current_x:
549
+ self.current_x = new_x
550
+ need_update = True
551
+
552
+ if y is not None:
553
+ new_y = max(0, min(y, self.volume_data.shape[1] - 1))
554
+ if new_y != self.current_y:
555
+ self.current_y = new_y
556
+ need_update = True
557
+
558
+ if z is not None:
559
+ new_z = max(0, min(z, self.volume_data.shape[2] - 1))
560
+ if new_z != self.current_z:
561
+ self.current_z = new_z
562
+ need_update = True
563
+
564
+ if need_update:
565
+ self.update_all_slices()
566
+
567
+ def update_all_slices(self) -> None:
568
+ """Update all three slice views."""
569
+ if self.volume_data is None:
570
+ return
571
+
572
+ # Extract and display XY slice (constant Z)
573
+ xy_slice = self.volume_data[self.current_z, :, :]
574
+ self.image_widgets[0].update_slice(xy_slice)
575
+
576
+ # Extract and display XZ slice (constant Y)
577
+ xz_slice = self.volume_data[:, self.current_y, :]
578
+ self.image_widgets[1].update_slice(xz_slice)
579
+
580
+ # Extract and display YZ slice (constant X)
581
+ yz_slice = self.volume_data[:, :, self.current_x]
582
+ self.image_widgets[2].update_slice(yz_slice)
583
+
584
+ def _update_slice(self, axis: str) -> None:
585
+ """Update a single slice based on axis."""
586
+ if self.volume_data is None:
587
+ return
588
+
589
+ if axis == 'z':
590
+ # XY slice
591
+ xy_slice = self.volume_data[self.current_z, :, :]
592
+ self.image_widgets[0].update_slice(xy_slice)
593
+ elif axis == 'y':
594
+ # XZ slice
595
+ xz_slice = self.volume_data[:, self.current_y, :]
596
+ self.image_widgets[1].update_slice(xz_slice)
597
+ elif axis == 'x':
598
+ # YZ slice
599
+ yz_slice = self.volume_data[:, :, self.current_x]
600
+ self.image_widgets[2].update_slice(yz_slice)
601
+
602
+ def get_current_positions(self) -> Tuple[int, int, int]:
603
+ """Get current slice positions (x, y, z)."""
604
+ return (self.current_x, self.current_y, self.current_z)
605
+
606
+
607
+ class MedScopeWindow(QMainWindow):
608
+ """Main window with four equal sub-windows."""
609
+
610
+ def set_window_title(self, new_title:str, force:bool=False):
611
+ if force or (new_title != self.window_title):
612
+ self.window_title = new_title
613
+ self.setWindowTitle(self.window_title)
614
+
615
+ def __init__(self):
616
+ super().__init__()
617
+
618
+ # Set window title
619
+ self.window_title = "MedScope"
620
+ self.set_window_title(self.window_title, True)
621
+
622
+ # Get screen resolution and set window to full screen
623
+ screen = QApplication.primaryScreen()
624
+ if screen is not None:
625
+ screen_geometry = screen.availableGeometry()
626
+ self.setGeometry(screen_geometry)
627
+ else:
628
+ self.setGeometry(0, 0, 1920, 1080) # default size
629
+ self.showMaximized()
630
+
631
+ # Create central widget and main layout
632
+ central_widget = QWidget()
633
+ self.setCentralWidget(central_widget)
634
+ main_layout = QVBoxLayout(central_widget)
635
+ main_layout.setContentsMargins(0, 0, 0, 0)
636
+ main_layout.setSpacing(2)
637
+
638
+ # Create a 2x2 grid layout
639
+ grid_layout = QHBoxLayout()
640
+ left_column = QVBoxLayout()
641
+ right_column = QVBoxLayout()
642
+
643
+ # Top-left: VTK scene
644
+ self.vtk_widget: VTKWidget = VTKWidget()
645
+ left_column.addWidget(self.vtk_widget)
646
+
647
+ # Bottom-left: XY slice (Z = constant)
648
+ self.image_widget_xy: ImageDisplayWidget = ImageDisplayWidget()
649
+ left_column.addWidget(self.image_widget_xy)
650
+
651
+ # Top-right: XZ slice (Y = constant)
652
+ self.image_widget_xz: ImageDisplayWidget = ImageDisplayWidget()
653
+ right_column.addWidget(self.image_widget_xz)
654
+
655
+ # Bottom-right: YZ slice (X = constant)
656
+ self.image_widget_yz: ImageDisplayWidget = ImageDisplayWidget()
657
+ right_column.addWidget(self.image_widget_yz)
658
+
659
+ # Set equal stretch factors
660
+ left_column.setStretch(0, 1)
661
+ left_column.setStretch(1, 1)
662
+ right_column.setStretch(0, 1)
663
+ right_column.setStretch(1, 1)
664
+
665
+ grid_layout.addLayout(left_column, 1)
666
+ grid_layout.addLayout(right_column, 1)
667
+ main_layout.addLayout(grid_layout)
668
+
669
+ # Create volume slice viewer
670
+ self.slice_viewer = VolumeSliceViewer([
671
+ self.image_widget_xy, # XY slice (Z constant)
672
+ self.image_widget_xz, # XZ slice (Y constant)
673
+ self.image_widget_yz # YZ slice (X constant)
674
+ ])
675
+
676
+ # Store all timers
677
+ self.timer_pool: Dict[str, QTimer] = dict()
678
+
679
+ # Default: ban mouse interaction
680
+ self.set_mouse_interaction(False)
681
+
682
+ # Create a default test volume
683
+ self._create_default_volume()
684
+
685
+ # Show window
686
+ self.show()
687
+
688
+ def _create_default_volume(self) -> None:
689
+ """Create a fast test 3D volume with simple patterns."""
690
+ self.set_volume(np.zeros((1, 1, 1), np.uint8))
691
+
692
+ def add_timer(self, timer_name: str, timer_ms:int, call_func:Optional[Callable]=None) -> None:
693
+ """Add or erase a timer to the pool."""
694
+ assert timer_ms >= 0
695
+
696
+ if self.timer_pool.get(timer_name) is not None:
697
+ self.timer_pool[timer_name].stop()
698
+ del self.timer_pool[timer_name]
699
+
700
+ if call_func is not None:
701
+ qtimer_now = QTimer()
702
+ qtimer_now.timeout.connect(call_func)
703
+ qtimer_now.start()
704
+ self.timer_pool[timer_name] = qtimer_now
705
+
706
+ def __del__(self):
707
+ for timer_name in self.timer_pool:
708
+ self.timer_pool[timer_name].stop()
709
+
710
+ # Volume slice interface methods
711
+ def set_volume(self, volume: np.ndarray) -> None:
712
+ """
713
+ Set the 3D volume data.
714
+
715
+ Args:
716
+ volume: 3D numpy array (uint8) with shape (z, y, x)
717
+ """
718
+ self.slice_viewer.set_volume(volume)
719
+
720
+ def set_slice_x(self, x: int) -> None:
721
+ """Set the X slice position (for YZ plane)."""
722
+ self.slice_viewer.set_slice_position('x', x)
723
+
724
+ def set_slice_y(self, y: int) -> None:
725
+ """Set the Y slice position (for XZ plane)."""
726
+ self.slice_viewer.set_slice_position('y', y)
727
+
728
+ def set_slice_z(self, z: int) -> None:
729
+ """Set the Z slice position (for XY plane)."""
730
+ self.slice_viewer.set_slice_position('z', z)
731
+
732
+ def set_slice_positions(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None) -> None:
733
+ """Set multiple slice positions at once."""
734
+ self.slice_viewer.set_slice_positions(x, y, z)
735
+
736
+ def get_slice_positions(self) -> Tuple[int, int, int]:
737
+ """Get current slice positions (x, y, z)."""
738
+ return self.slice_viewer.get_current_positions()
739
+
740
+ # VTK interface methods for external use
741
+ def get_vtk_renderer(self) -> vtk.vtkRenderer:
742
+ """Return the VTK renderer."""
743
+ return self.vtk_widget.get_renderer()
744
+
745
+ def set_camera_y_direction(self, up_vector: Tuple[float, float, float] = (0, 1, 0)) -> None:
746
+ """Set camera's up direction."""
747
+ self.vtk_widget.set_camera_y_direction(up_vector)
748
+
749
+ def set_camera_pose(self, position: Tuple[float, float, float],
750
+ focal_point: Tuple[float, float, float],
751
+ view_up: Tuple[float, float, float] = (0, 1, 0)) -> None:
752
+ """Set camera position and orientation."""
753
+ self.vtk_widget.set_camera_pose(position, focal_point, view_up)
754
+
755
+ def set_camera_clipping_range(self, near_plane: float = 0.01, far_plane: float = 10**7) -> None:
756
+ """Set camera clipping range."""
757
+ self.vtk_widget.set_camera_clipping_range(near_plane, far_plane)
758
+
759
+ def set_mouse_interaction(self, enabled: bool) -> None:
760
+ """Enable/disable mouse interaction."""
761
+ self.vtk_widget.set_mouse_interaction(enabled)
762
+
763
+ def add_model_from_file(self, name: str, file_path: str,
764
+ color: Optional[Tuple[float, float, float]] = None,
765
+ scale: float = 1.0,
766
+ position: Tuple[float, float, float] = (0, 0, 0)) -> bool:
767
+ """Add 3D model from file."""
768
+ return self.vtk_widget.add_model_from_file(name, file_path, color, scale, position)
769
+
770
+ def remove_model(self, name: str) -> bool:
771
+ """Remove a model by name."""
772
+ return self.vtk_widget.remove_model(name)
773
+
774
+ def set_model_position(self, name: str, x: float, y: float, z: float) -> bool:
775
+ """Set model position."""
776
+ ans = self.vtk_widget.set_model_position(name, x, y, z)
777
+ self.vtk_widget.vtk_render()
778
+ return ans
779
+
780
+ def set_model_scale(self, name: str, scale: float) -> bool:
781
+ """Set model scale."""
782
+ ans = self.vtk_widget.set_model_scale(name, scale)
783
+ self.vtk_widget.vtk_render()
784
+ return ans
785
+
786
+ def set_model_pose(self, name: str,
787
+ translation_matrix: Optional[Union[np.ndarray, Tuple[float, float, float]]] = None,
788
+ rotation_matrix: Optional[np.ndarray] = None) -> bool:
789
+ """Set model pose with matrices."""
790
+ ans = self.vtk_widget.set_model_pose(name, translation_matrix, rotation_matrix)
791
+ self.vtk_widget.vtk_render()
792
+ return ans
793
+
794
+ def get_model_list(self) -> List[str]:
795
+ """Get list of all model names."""
796
+ return self.vtk_widget.get_model_list()
797
+
798
+ def clear_all_models(self) -> None:
799
+ """Remove all models."""
800
+ self.vtk_widget.clear_all_models()
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: medscope
3
+ Version: 0.1.0
4
+ Summary: Medical Intraoperative Real-time Visualization System.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: GGN_2015
8
+ Author-email: neko@jlulug.org
9
+ Requires-Python: >=3.10,<3.13
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: numpy
16
+ Requires-Dist: pyqt5
17
+ Requires-Dist: vtk
18
+ Description-Content-Type: text/markdown
19
+
20
+ # medscope
21
+ Medical Intraoperative Real-time Visualization System.
22
+
23
+ ## Demo
24
+
25
+ ![](/img/demo.gif)
26
+
27
+ ## Installation
28
+
29
+ > [!IMPORTANT]
30
+ > You should use python >=3.10, <3.13
31
+
32
+ ```bash
33
+ pip install medscope
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ > [!WARNING]
39
+ > Do not use multithreading, use `MedScopeWindow.add_timer`
40
+
41
+ ```python
42
+ from medscope import MedScopeWindow, MedScopeSystem
43
+ import numpy as np
44
+ import sys
45
+
46
+ # Generate a random 3x3 rotation matrix uniformly distributed over SO(3).
47
+ def random_rotation_matrix() -> np.ndarray:
48
+ phi = np.random.uniform(0, 2 * np.pi)
49
+ theta = np.random.uniform(0, np.pi)
50
+ psi = np.random.uniform(0, 2 * np.pi)
51
+ cos_phi, sin_phi = np.cos(phi), np.sin(phi)
52
+ cos_theta, sin_theta = np.cos(theta), np.sin(theta)
53
+ cos_psi, sin_psi = np.cos(psi), np.sin(psi)
54
+ R = np.array([
55
+ [cos_phi*cos_theta*cos_psi - sin_phi*sin_psi,
56
+ -cos_phi*cos_theta*sin_psi - sin_phi*cos_psi,
57
+ cos_phi*sin_theta],
58
+ [sin_phi*cos_theta*cos_psi + cos_phi*sin_psi,
59
+ -sin_phi*cos_theta*sin_psi + cos_phi*cos_psi,
60
+ sin_phi*sin_theta],
61
+ [-sin_theta*cos_psi,
62
+ sin_theta*sin_psi,
63
+ cos_theta]
64
+ ])
65
+
66
+ return R
67
+
68
+ # Initialize app and window
69
+ app = MedScopeSystem(sys.argv)
70
+ window = MedScopeWindow()
71
+
72
+ # Add a 3D model
73
+ window.add_model_from_file(
74
+ "bone_model",
75
+ "BONE-1.new.stl",
76
+ (1.0, 1.0, 1.0)) # white, random if not given
77
+
78
+ # Set the pose of camera
79
+ # you can change camera pos in callback function with add_timer
80
+ window.set_camera_pose(
81
+ (0, 0, -500),
82
+ (0, 0, 0),
83
+ (0, 1, 0)
84
+ )
85
+
86
+ # Volume data should be given in 3D np.ndarray
87
+ # and dtype should be np.uint8
88
+ window.set_volume(np.random.randint(0, 255, (256, 256, 256)).astype(np.uint8))
89
+
90
+ # Create a callback function to move your model
91
+ def move_model():
92
+ import random
93
+ x = random.randint(0, 256)
94
+ y = random.randint(0, 256)
95
+ z = random.randint(0, 256)
96
+ window.set_slice_positions(x, y, z)
97
+ window.set_model_pose(
98
+ "bone_model",
99
+ (x - 128, y - 128, z - 128),
100
+ random_rotation_matrix()
101
+ )
102
+
103
+ # Call move_model every 1ms (as quickly as the processor can)
104
+ window.add_timer("move_model", 1, move_model)
105
+ sys.exit(app.exec_())
106
+
107
+ ```
108
+
@@ -0,0 +1,6 @@
1
+ medscope/__init__.py,sha256=DQWwgwJkZroJP_RRn9fhanSMwZnrfqSdvlP-wM_Gslk,147
2
+ medscope/main.py,sha256=sZ_10dNhOG_cBdUV4GcDmndgY1y3u0TgT5p8x9CsTZ8,31328
3
+ medscope-0.1.0.dist-info/licenses/LICENSE,sha256=gmkEFqkF3KJjRnhXGoLSfX4xXtfPHT1ghq3YgCF7B3w,1086
4
+ medscope-0.1.0.dist-info/METADATA,sha256=LBHDSs16qu_IdAf267NMcEDcELTaXtT_naO5b3slR9I,2786
5
+ medscope-0.1.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
6
+ medscope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GGN_2015
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.