bone-cutting-plane-visualization 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.
@@ -0,0 +1,61 @@
1
+ """Standalone data contracts and VTK rendering for bone cutting-plane plans."""
2
+
3
+ from .classification import classify_regions
4
+ from .data import (
5
+ LabeledVolume,
6
+ RegionClassificationVolume,
7
+ ResectionPlanData,
8
+ SelectedCuttingPlanes,
9
+ TumorSafetyMarginData,
10
+ VolumeGeometry,
11
+ )
12
+ from .geometry import plane_box_intersection
13
+ from .ubd import (
14
+ IncompleteResectionPlanError,
15
+ load_labeled_volume,
16
+ load_resection_plan,
17
+ save_resection_plan,
18
+ )
19
+ from .visualization import (
20
+ BoneTumorVisualizer,
21
+ MeshLayer,
22
+ PatientOrientationCube,
23
+ PolygonLayer,
24
+ SurfaceLayer,
25
+ VisualizationModel,
26
+ VisualizationScene,
27
+ patient_orientation_cube,
28
+ resection_boundary,
29
+ visualize_bone_and_tumor,
30
+ visualize_labeled_volume,
31
+ visualize_resection,
32
+ visualize_tumor_convex_hull,
33
+ )
34
+
35
+ __all__ = [
36
+ "BoneTumorVisualizer",
37
+ "IncompleteResectionPlanError",
38
+ "LabeledVolume",
39
+ "MeshLayer",
40
+ "PatientOrientationCube",
41
+ "PolygonLayer",
42
+ "RegionClassificationVolume",
43
+ "ResectionPlanData",
44
+ "SelectedCuttingPlanes",
45
+ "SurfaceLayer",
46
+ "TumorSafetyMarginData",
47
+ "VisualizationModel",
48
+ "VisualizationScene",
49
+ "VolumeGeometry",
50
+ "classify_regions",
51
+ "load_labeled_volume",
52
+ "load_resection_plan",
53
+ "patient_orientation_cube",
54
+ "plane_box_intersection",
55
+ "resection_boundary",
56
+ "save_resection_plan",
57
+ "visualize_bone_and_tumor",
58
+ "visualize_labeled_volume",
59
+ "visualize_resection",
60
+ "visualize_tumor_convex_hull",
61
+ ]
@@ -0,0 +1,38 @@
1
+ """Portable region classification matching the historical native implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .data import LabeledVolume, RegionClassificationVolume, SelectedCuttingPlanes
8
+
9
+
10
+ def classify_regions(
11
+ volume: LabeledVolume,
12
+ cutting_planes: SelectedCuttingPlanes,
13
+ *,
14
+ chunk_size: int = 1_000_000,
15
+ ) -> RegionClassificationVolume:
16
+ """Classify normal bone by the first plane whose positive half-space contains it."""
17
+
18
+ if not isinstance(volume, LabeledVolume):
19
+ raise TypeError("volume must be a LabeledVolume")
20
+ if not isinstance(cutting_planes, SelectedCuttingPlanes):
21
+ raise TypeError("cutting_planes must be SelectedCuttingPlanes")
22
+ if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or chunk_size <= 0:
23
+ raise ValueError("chunk_size must be a positive integer")
24
+
25
+ result = volume.data.astype(np.int32, copy=True)
26
+ result[result == -1] = 2
27
+ flat = result.ravel()
28
+ dimensions = volume.shape
29
+
30
+ for plane_index, equation in enumerate(cutting_planes.equations, start=1):
31
+ remaining = np.flatnonzero(flat == 2)
32
+ for start in range(0, len(remaining), chunk_size):
33
+ positions = remaining[start : start + chunk_size]
34
+ i, j, k = np.unravel_index(positions, dimensions)
35
+ values = i * equation[0] + j * equation[1] + k * equation[2] + equation[3]
36
+ flat[positions[values > 0.0]] = -plane_index
37
+
38
+ return RegionClassificationVolume(result)
@@ -0,0 +1,107 @@
1
+ """Command-line entry points for UBD-backed visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from .ubd import IncompleteResectionPlanError, load_labeled_volume, load_resection_plan
9
+ from .visualization import (
10
+ BoneTumorVisualizer,
11
+ visualize_bone_and_tumor,
12
+ visualize_resection,
13
+ visualize_tumor_convex_hull,
14
+ )
15
+
16
+
17
+ def _render_options(args: argparse.Namespace) -> dict:
18
+ return {
19
+ "interactive": not args.offscreen,
20
+ "offscreen": args.offscreen,
21
+ "screenshot": args.screenshot,
22
+ "window_size": tuple(args.window_size),
23
+ }
24
+
25
+
26
+ def _view(args: argparse.Namespace) -> int:
27
+ volume, geometry = load_labeled_volume(args.ubd)
28
+ visualize_bone_and_tumor(volume, geometry=geometry, **_render_options(args))
29
+ return 0
30
+
31
+
32
+ def _hull(args: argparse.Namespace) -> int:
33
+ volume, geometry = load_labeled_volume(args.ubd)
34
+ visualize_tumor_convex_hull(volume, geometry=geometry, **_render_options(args))
35
+ return 0
36
+
37
+
38
+ def _plan(args: argparse.Namespace) -> int:
39
+ plan = load_resection_plan(args.ubd)
40
+ visualize_resection(
41
+ plan,
42
+ show_retained=args.view != "resected",
43
+ boundary_only=args.view == "boundary",
44
+ **_render_options(args),
45
+ )
46
+ return 0
47
+
48
+
49
+ def _inspect(args: argparse.Namespace) -> int:
50
+ try:
51
+ plan = load_resection_plan(args.ubd)
52
+ except IncompleteResectionPlanError:
53
+ volume, geometry = load_labeled_volume(args.ubd)
54
+ print(f"kind=labeled-volume shape={volume.shape} mmpd={geometry.spacing[0]:g}")
55
+ return 0
56
+ print(
57
+ f"kind=resection-plan shape={plan.labeled_volume.shape} "
58
+ f"planes={plan.cutting_planes.count} keep_rate={plan.keep_rate:.6f} "
59
+ f"mmpd={plan.geometry.spacing[0]:g}"
60
+ )
61
+ return 0
62
+
63
+
64
+ def _add_render_arguments(parser: argparse.ArgumentParser) -> None:
65
+ parser.add_argument("--screenshot", type=Path, help="also save the rendered view as PNG")
66
+ parser.add_argument(
67
+ "--offscreen", action="store_true", help="render without opening an interactive window"
68
+ )
69
+ parser.add_argument(
70
+ "--window-size",
71
+ type=int,
72
+ nargs=2,
73
+ default=(1100, 800),
74
+ metavar=("WIDTH", "HEIGHT"),
75
+ )
76
+
77
+
78
+ def build_parser() -> argparse.ArgumentParser:
79
+ parser = argparse.ArgumentParser(prog="bone-cutting-plane-viz")
80
+ subparsers = parser.add_subparsers(dest="command", required=True)
81
+ view = subparsers.add_parser("view", help="visualize bone and tumor from .ubd.npz")
82
+ view.add_argument("ubd", type=Path)
83
+ _add_render_arguments(view)
84
+ view.set_defaults(handler=_view)
85
+ hull = subparsers.add_parser("hull", help="visualize bone, tumor, and tumor hull")
86
+ hull.add_argument("ubd", type=Path)
87
+ _add_render_arguments(hull)
88
+ hull.set_defaults(handler=_hull)
89
+ plan = subparsers.add_parser("plan", help="visualize a complete resection-plan .ubd.npz")
90
+ plan.add_argument("ubd", type=Path)
91
+ plan.add_argument("--view", choices=("kept", "resected", "boundary"), default="kept")
92
+ _add_render_arguments(plan)
93
+ plan.set_defaults(handler=_plan)
94
+ inspect = subparsers.add_parser("inspect", help="inspect a visualization .ubd.npz")
95
+ inspect.add_argument("ubd", type=Path)
96
+ inspect.set_defaults(handler=_inspect)
97
+ return parser
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ args = build_parser().parse_args(argv)
102
+ if hasattr(args, "window_size") and any(value <= 0 for value in args.window_size):
103
+ raise ValueError("window dimensions must be positive")
104
+ return int(args.handler(args))
105
+
106
+
107
+ __all__ = ["BoneTumorVisualizer", "build_parser", "main"]
@@ -0,0 +1,171 @@
1
+ """Array-based adapters for callers migrating to the typed public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from .classification import classify_regions
12
+ from .data import (
13
+ LabeledVolume,
14
+ RegionClassificationVolume,
15
+ ResectionPlanData,
16
+ SelectedCuttingPlanes,
17
+ TumorSafetyMarginData,
18
+ VolumeGeometry,
19
+ )
20
+ from .visualization import (
21
+ BOUNDARY_COLOR_MAP,
22
+ BOUNDARY_LABEL_NAMES,
23
+ DEFAULT_COLOR_MAP,
24
+ DEFAULT_LABEL_NAMES,
25
+ BoneTumorVisualizer,
26
+ VisualizationScene,
27
+ )
28
+
29
+ RegionSolver = Callable[[np.ndarray, np.ndarray], np.ndarray]
30
+
31
+
32
+ def plan_from_arrays(
33
+ volume: np.ndarray,
34
+ equations: np.ndarray,
35
+ *,
36
+ solved_volume: np.ndarray | None = None,
37
+ geometry: VolumeGeometry | None = None,
38
+ region_solver: RegionSolver | None = None,
39
+ danger_bone_mask: np.ndarray | None = None,
40
+ safety_margin_mm: float = 0.0,
41
+ ) -> ResectionPlanData:
42
+ """Convert the former NumPy arguments into one validated plan object."""
43
+
44
+ labeled = LabeledVolume(volume)
45
+ planes = SelectedCuttingPlanes(equations)
46
+ if solved_volume is not None and region_solver is not None:
47
+ raise ValueError("provide solved_volume or region_solver, not both")
48
+ if solved_volume is not None:
49
+ classification = RegionClassificationVolume(solved_volume)
50
+ elif region_solver is not None:
51
+ classification = RegionClassificationVolume(region_solver(labeled.data, planes.equations))
52
+ else:
53
+ classification = classify_regions(labeled, planes)
54
+ resolved_geometry = geometry if geometry is not None else VolumeGeometry()
55
+
56
+ safety = None
57
+ danger = (
58
+ np.zeros(labeled.shape, dtype=bool)
59
+ if danger_bone_mask is None
60
+ else np.asarray(danger_bone_mask, dtype=bool)
61
+ )
62
+ if danger.shape != labeled.shape:
63
+ raise ValueError("danger_bone_mask must have the same shape as volume")
64
+ if safety_margin_mm > 0 or np.any(danger):
65
+ protected = labeled.tumor_mask | danger
66
+ planning = labeled.to_numpy(copy=True)
67
+ planning[protected] = 1
68
+ safety = TumorSafetyMarginData(LabeledVolume(planning), protected, danger, safety_margin_mm)
69
+ return ResectionPlanData(labeled, planes, classification, resolved_geometry, safety)
70
+
71
+
72
+ def visualize_resection_arrays(
73
+ volume: np.ndarray,
74
+ equations: np.ndarray,
75
+ *,
76
+ solved_volume: np.ndarray | None = None,
77
+ geometry: VolumeGeometry | None = None,
78
+ region_solver: RegionSolver | None = None,
79
+ show_retained: bool = True,
80
+ danger_bone_mask: np.ndarray | None = None,
81
+ safety_margin_mm: float = 0.0,
82
+ **render_options: Any,
83
+ ) -> VisualizationScene:
84
+ plan = plan_from_arrays(
85
+ volume,
86
+ equations,
87
+ solved_volume=solved_volume,
88
+ geometry=geometry,
89
+ region_solver=region_solver,
90
+ danger_bone_mask=danger_bone_mask,
91
+ safety_margin_mm=safety_margin_mm,
92
+ )
93
+ return BoneTumorVisualizer(plan.geometry).show_resection(
94
+ plan, show_retained=show_retained, **render_options
95
+ )
96
+
97
+
98
+ def visualize_volume(
99
+ data: np.ndarray,
100
+ color_map: Mapping[float, tuple[float, float, float, float]] | None = None,
101
+ *,
102
+ label_names: Mapping[float, str] | None = None,
103
+ title: str = "Volume visualization",
104
+ geometry: VolumeGeometry | None = None,
105
+ interactive: bool = True,
106
+ offscreen: bool = False,
107
+ screenshot: str | Path | None = None,
108
+ window_size: tuple[int, int] = (1100, 800),
109
+ ) -> VisualizationScene:
110
+ visualizer = BoneTumorVisualizer(geometry)
111
+ model = visualizer.prepare_labeled_volume(
112
+ data,
113
+ color_map=DEFAULT_COLOR_MAP if color_map is None else color_map,
114
+ label_names=DEFAULT_LABEL_NAMES if label_names is None else label_names,
115
+ title=title,
116
+ )
117
+ return visualizer.render(
118
+ model,
119
+ interactive=interactive,
120
+ offscreen=offscreen,
121
+ screenshot=screenshot,
122
+ window_size=window_size,
123
+ )
124
+
125
+
126
+ def visualize_solution(
127
+ data: np.ndarray,
128
+ equations: np.ndarray,
129
+ show_volume: bool,
130
+ show_only_resected_bone: bool = False,
131
+ show_only_boundary: bool = False,
132
+ *,
133
+ geometry: VolumeGeometry | None = None,
134
+ interactive: bool = True,
135
+ offscreen: bool = False,
136
+ screenshot: str | Path | None = None,
137
+ window_size: tuple[int, int] = (1100, 800),
138
+ ) -> VisualizationScene | None:
139
+ plan = plan_from_arrays(data, equations, geometry=geometry)
140
+ retained = int(np.count_nonzero(plan.classification.retained_mask))
141
+ resected = int(np.count_nonzero(plan.classification.resected_mask))
142
+ print(f"- keep_rate: {plan.keep_rate * 100:7.3f}% ({retained}/{retained + resected})")
143
+ if not show_volume:
144
+ return None
145
+ visualizer = BoneTumorVisualizer(plan.geometry)
146
+ render_options = {
147
+ "interactive": interactive,
148
+ "offscreen": offscreen,
149
+ "screenshot": screenshot,
150
+ "window_size": window_size,
151
+ }
152
+ if show_only_boundary:
153
+ boundary = visualizer.prepare_resection_boundary(plan)
154
+ return visualizer.render(boundary, **render_options)
155
+ return visualizer.show_resection(
156
+ plan,
157
+ show_retained=not show_only_resected_bone,
158
+ **render_options,
159
+ )
160
+
161
+
162
+ __all__ = [
163
+ "BOUNDARY_COLOR_MAP",
164
+ "BOUNDARY_LABEL_NAMES",
165
+ "DEFAULT_COLOR_MAP",
166
+ "DEFAULT_LABEL_NAMES",
167
+ "plan_from_arrays",
168
+ "visualize_resection_arrays",
169
+ "visualize_solution",
170
+ "visualize_volume",
171
+ ]