kneemesh 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.
- kneemesh/__init__.py +10 -0
- kneemesh/brep.py +214 -0
- kneemesh/config.py +178 -0
- kneemesh/disk.py +160 -0
- kneemesh/geo/__init__.py +1 -0
- kneemesh/geo/femur.py +1 -0
- kneemesh/geo/meniscus.py +651 -0
- kneemesh/geo/patella.py +1 -0
- kneemesh/geo/tibia.py +1 -0
- kneemesh/mesh/__init__.py +1 -0
- kneemesh/mesh/femur.py +1 -0
- kneemesh/mesh/meniscus.py +63 -0
- kneemesh/mesh/patella.py +1 -0
- kneemesh/mesh/tibia.py +1 -0
- kneemesh/pipeline.py +241 -0
- kneemesh/py.typed +0 -0
- kneemesh/tet_meshing.py +189 -0
- kneemesh/vol.py +189 -0
- kneemesh-0.1.0.dist-info/METADATA +79 -0
- kneemesh-0.1.0.dist-info/RECORD +23 -0
- kneemesh-0.1.0.dist-info/WHEEL +4 -0
- kneemesh-0.1.0.dist-info/entry_points.txt +4 -0
- kneemesh-0.1.0.dist-info/licenses/LICENSE +202 -0
kneemesh/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""kneemesh: B-rep geometry and meshes of knee structures from segmented images."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
__version__ = version("kneemesh")
|
|
8
|
+
|
|
9
|
+
# A library stays silent unless its user opts in with logger.enable("kneemesh").
|
|
10
|
+
logger.disable("kneemesh")
|
kneemesh/brep.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""B-rep values, gmsh sessions, and B-spline construction shared by every structure."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import gmsh
|
|
12
|
+
import numpy as np
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from scipy.interpolate import BSpline, make_interp_spline
|
|
15
|
+
from vtkmodules.util.numpy_support import numpy_to_vtk
|
|
16
|
+
from vtkmodules.vtkCommonCore import vtkPoints
|
|
17
|
+
from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkPolyData, vtkPolyLine
|
|
18
|
+
|
|
19
|
+
from kneemesh.vol import ContourSlice
|
|
20
|
+
|
|
21
|
+
MAX_SPLINE_ORDER = 25 # Open Cascade's limit on B-spline degree.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class BRep:
|
|
26
|
+
"""A B-rep shape held as the text of an Open Cascade .brep file."""
|
|
27
|
+
|
|
28
|
+
text: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def gmsh_session() -> Iterator[None]:
|
|
33
|
+
"""Run a block on a temporary gmsh model, leaving gmsh as it was found."""
|
|
34
|
+
owns_gmsh = not gmsh.isInitialized()
|
|
35
|
+
if owns_gmsh:
|
|
36
|
+
gmsh.initialize(readConfigFiles=False, interruptible=False)
|
|
37
|
+
gmsh.option.setNumber("General.Terminal", 0)
|
|
38
|
+
previous_model = gmsh.model.getCurrent()
|
|
39
|
+
gmsh.model.add(f"kneemesh-{uuid.uuid4().hex}")
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
gmsh.model.remove()
|
|
44
|
+
if owns_gmsh:
|
|
45
|
+
gmsh.finalize()
|
|
46
|
+
else:
|
|
47
|
+
gmsh.model.setCurrent(previous_model)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def export_brep() -> BRep:
|
|
51
|
+
"""Return the current gmsh model's highest-dimension shapes as a BRep. Call inside gmsh_session."""
|
|
52
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
53
|
+
path = Path(directory) / "shape.brep"
|
|
54
|
+
gmsh.write(str(path))
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
counts = [len(gmsh.model.getEntities(dim)) for dim in range(4)]
|
|
57
|
+
logger.debug("B-rep has {} vertices, {} edges, {} faces, and {} volumes ({} kB)", *counts, len(text) // 1024)
|
|
58
|
+
return BRep(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def chord_parameters(points: np.ndarray) -> np.ndarray:
|
|
62
|
+
"""Return parameters from 0 to 1 proportional to chord length around a closed contour, ending at the repeated first point."""
|
|
63
|
+
closed = np.vstack((points, points[:1]))
|
|
64
|
+
lengths = np.concatenate(([0.0], np.cumsum(np.linalg.norm(np.diff(closed, axis=0), axis=1))))
|
|
65
|
+
return lengths / lengths[-1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def periodic_spline(points: np.ndarray, spline_order: int) -> BSpline:
|
|
69
|
+
"""Return a periodic B-spline through points, over parameters 0 to 1 proportional to chord length."""
|
|
70
|
+
return make_interp_spline(chord_parameters(points), np.vstack((points, points[:1])), k=spline_order, bc_type="periodic")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def open_chord_parameters(points: np.ndarray) -> np.ndarray:
|
|
74
|
+
"""Return parameters from 0 to 1 proportional to chord length along an open sequence of points."""
|
|
75
|
+
lengths = np.concatenate(([0.0], np.cumsum(np.linalg.norm(np.diff(points, axis=0), axis=1))))
|
|
76
|
+
return lengths / lengths[-1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def clamped_interpolation(parameters: np.ndarray, points: np.ndarray, degree: int) -> tuple[np.ndarray, np.ndarray]:
|
|
80
|
+
"""Return the knots and poles of a clamped B-spline through points at parameters.
|
|
81
|
+
|
|
82
|
+
Averaged knots make this work at any degree. Both ends are pinned to the
|
|
83
|
+
exact end points, so curves fitted to the same end points meet exactly.
|
|
84
|
+
"""
|
|
85
|
+
if degree == 1:
|
|
86
|
+
knots = np.concatenate(([parameters[0]], parameters, [parameters[-1]]))
|
|
87
|
+
else:
|
|
88
|
+
interior = [parameters[index + 1 : index + degree + 1].mean() for index in range(len(parameters) - degree - 1)]
|
|
89
|
+
knots = np.concatenate(([parameters[0]] * (degree + 1), interior, [parameters[-1]] * (degree + 1)))
|
|
90
|
+
poles = np.array(make_interp_spline(parameters, points, k=degree, t=knots).c)
|
|
91
|
+
poles[0], poles[-1] = points[0], points[-1]
|
|
92
|
+
return knots, poles
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_bspline_curve(knots: np.ndarray, poles: np.ndarray, degree: int) -> int:
|
|
96
|
+
"""Add an open B-spline curve from scipy-style full knots and poles, returning its tag."""
|
|
97
|
+
unique_knots, multiplicities = np.unique(knots, return_counts=True)
|
|
98
|
+
tags = [gmsh.model.occ.addPoint(*pole.tolist()) for pole in poles]
|
|
99
|
+
return gmsh.model.occ.addBSpline(tags, degree=degree, knots=unique_knots.tolist(), multiplicities=multiplicities.tolist())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def add_swept_surface(rows: list[tuple[np.ndarray, np.ndarray]], degree: int, along: np.ndarray, along_degree: int) -> int:
|
|
103
|
+
"""Add a B-spline surface through section curves sharing one knot vector, returning its tag.
|
|
104
|
+
|
|
105
|
+
Each column of poles is interpolated along the curve at the same
|
|
106
|
+
parameters, so a surface's edge depends only on that edge's points and
|
|
107
|
+
matches its neighbor's exactly.
|
|
108
|
+
"""
|
|
109
|
+
knots = rows[0][0]
|
|
110
|
+
grid = np.array([poles for _, poles in rows])
|
|
111
|
+
columns = [clamped_interpolation(along, grid[:, column], along_degree) for column in range(grid.shape[1])]
|
|
112
|
+
along_knots = columns[0][0]
|
|
113
|
+
poles = np.stack([column_poles for _, column_poles in columns], axis=1)
|
|
114
|
+
unique_knots, multiplicities = np.unique(knots, return_counts=True)
|
|
115
|
+
unique_along, along_multiplicities = np.unique(along_knots, return_counts=True)
|
|
116
|
+
return gmsh.model.occ.addBSplineSurface(
|
|
117
|
+
[gmsh.model.occ.addPoint(*pole.tolist()) for row in poles for pole in row],
|
|
118
|
+
poles.shape[1],
|
|
119
|
+
degreeU=degree,
|
|
120
|
+
degreeV=along_degree,
|
|
121
|
+
knotsU=unique_knots.tolist(),
|
|
122
|
+
knotsV=unique_along.tolist(),
|
|
123
|
+
multiplicitiesU=multiplicities.tolist(),
|
|
124
|
+
multiplicitiesV=along_multiplicities.tolist(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def periodic_poles_and_knots(spline: BSpline, point_count: int) -> tuple[np.ndarray, list[float]]:
|
|
129
|
+
"""Return a periodic spline's poles and knots as Open Cascade takes them."""
|
|
130
|
+
# Scipy repeats the first poles at the end of its coefficients and pads its
|
|
131
|
+
# knots on both sides. Open Cascade takes each only once.
|
|
132
|
+
return spline.c[:point_count], spline.t[spline.k : len(spline.t) - spline.k].tolist()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def add_closed_wire(spline: BSpline, point_count: int) -> int:
|
|
136
|
+
"""Add a closed wire of one periodic B-spline, returning its tag."""
|
|
137
|
+
poles, knots = periodic_poles_and_knots(spline, point_count)
|
|
138
|
+
# Repeating the first tag is how gmsh is told the curve is periodic.
|
|
139
|
+
curve = gmsh.model.occ.addBSpline(add_closed_points(poles), degree=spline.k, knots=knots, multiplicities=[1] * len(knots))
|
|
140
|
+
return gmsh.model.occ.addWire([curve], checkClosed=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def split_periodic_spline(spline: BSpline, parameters: np.ndarray) -> list[BSpline]:
|
|
144
|
+
"""Split a periodic spline exactly into clamped splines at parameters.
|
|
145
|
+
|
|
146
|
+
parameters must be sorted, distinct, and start at 0, so the last piece ends at 1.
|
|
147
|
+
"""
|
|
148
|
+
degree = spline.k
|
|
149
|
+
splits = [float(parameter) for parameter in parameters] + [1.0]
|
|
150
|
+
|
|
151
|
+
# Raising a knot's multiplicity to the degree makes the curve pass through
|
|
152
|
+
# a pole there, so the poles between two such knots define that piece alone.
|
|
153
|
+
refined = BSpline(spline.t, spline.c, degree)
|
|
154
|
+
for split in splits:
|
|
155
|
+
missing = degree - int(np.sum(refined.t == split))
|
|
156
|
+
if missing > 0:
|
|
157
|
+
refined = refined.insert_knot(split, missing)
|
|
158
|
+
# Index from a start knot's last copy and an end knot's first, which also
|
|
159
|
+
# holds where scipy clamps the ends of a degree 1 spline instead of padding.
|
|
160
|
+
pieces = []
|
|
161
|
+
for start, end in pairwise(splits):
|
|
162
|
+
after_start = int(np.flatnonzero(refined.t == start)[-1]) + 1
|
|
163
|
+
at_end = int(np.flatnonzero(refined.t == end)[0])
|
|
164
|
+
knots = np.concatenate(([start] * (degree + 1), refined.t[after_start:at_end], [end] * (degree + 1)))
|
|
165
|
+
pieces.append(BSpline(knots, refined.c[after_start - 1 - degree : at_end], degree))
|
|
166
|
+
return pieces
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def add_closed_points(poles: np.ndarray) -> list[int]:
|
|
170
|
+
"""Add a point per pole, returning their tags with the first repeated at the end.
|
|
171
|
+
|
|
172
|
+
gmsh treats a B-spline whose first and last point tags match as periodic.
|
|
173
|
+
"""
|
|
174
|
+
tags = [gmsh.model.occ.addPoint(*pole.tolist()) for pole in poles]
|
|
175
|
+
return [*tags, tags[0]]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def contours_to_polydata(slices: list[ContourSlice]) -> vtkPolyData:
|
|
179
|
+
"""Assemble contour slices into a single poly data of closed polylines.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
slices: Contours from slice_isocontour.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A vtkPolyData with one vtkPolyLine per slice, in the order given. Each
|
|
186
|
+
polyline ends on its own first point id to close the contour, so no
|
|
187
|
+
point is duplicated.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If slices is empty.
|
|
191
|
+
"""
|
|
192
|
+
if not slices:
|
|
193
|
+
raise ValueError("No contour slices to assemble")
|
|
194
|
+
|
|
195
|
+
points = vtkPoints()
|
|
196
|
+
points.SetData(numpy_to_vtk(np.vstack([contour.points for contour in slices]), deep=True))
|
|
197
|
+
|
|
198
|
+
lines = vtkCellArray()
|
|
199
|
+
first_id = 0
|
|
200
|
+
for contour in slices:
|
|
201
|
+
count = len(contour.points)
|
|
202
|
+
polyline = vtkPolyLine()
|
|
203
|
+
ids = polyline.GetPointIds()
|
|
204
|
+
ids.SetNumberOfIds(count + 1)
|
|
205
|
+
for index in range(count):
|
|
206
|
+
ids.SetId(index, first_id + index)
|
|
207
|
+
ids.SetId(count, first_id)
|
|
208
|
+
lines.InsertNextCell(polyline)
|
|
209
|
+
first_id += count
|
|
210
|
+
|
|
211
|
+
poly_data = vtkPolyData()
|
|
212
|
+
poly_data.SetPoints(points)
|
|
213
|
+
poly_data.SetLines(lines)
|
|
214
|
+
return poly_data
|
kneemesh/config.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Configuration models and parsing them from JSON."""
|
|
2
|
+
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Any, Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from kneemesh.tet_meshing import Algorithm2D, Algorithm3D
|
|
10
|
+
from kneemesh.vol import DEFAULT_SMOOTHING_ITERATIONS, DEFAULT_SMOOTHING_PASS_BAND
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseConfig(BaseModel):
|
|
14
|
+
"""Base class for configuration models.
|
|
15
|
+
|
|
16
|
+
Unknown fields are rejected so that misspelled options fail at load time
|
|
17
|
+
instead of being silently ignored.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_json(cls, path: str | PathLike[str]) -> Self:
|
|
24
|
+
"""Parse and validate a configuration from a JSON file."""
|
|
25
|
+
return cls.model_validate_json(Path(path).read_bytes())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _none_from_text(value: Any) -> Any:
|
|
29
|
+
"""Read "none" or "null" as None, so command-line overrides can clear optional values."""
|
|
30
|
+
return None if isinstance(value, str) and value.strip().lower() in ("none", "null") else value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
OptionalFloat = Annotated[float | None, BeforeValidator(_none_from_text)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LabelMapBrep(BaseConfig):
|
|
37
|
+
"""Settings every B-rep pipeline shares: the LabelMap to read, the structure's label in it, and the file to write."""
|
|
38
|
+
|
|
39
|
+
label_map: Path = Field(default=Path("label_map.nii"), description="LabelMap to read: .nii, .nrrd, or .mhd")
|
|
40
|
+
output: Path = Field(default=Path("structure.brep"), description="B-rep file to write")
|
|
41
|
+
label: int = Field(default=1, description="Value of the structure in the LabelMap")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BrepMesh(BaseConfig):
|
|
45
|
+
"""Settings every meshing pipeline shares: the B-rep to read, the mesh file to write, and the element order."""
|
|
46
|
+
|
|
47
|
+
brep: Path = Field(default=Path("structure.brep"), description="B-rep file to read")
|
|
48
|
+
output: Path = Field(default=Path("structure.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
49
|
+
element_order: int = Field(default=1, description="Polynomial order of the elements")
|
|
50
|
+
second_order_incomplete: bool = Field(default=False, description="Omit face and interior nodes from high-order elements")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MeniscusBrep(LabelMapBrep):
|
|
54
|
+
"""Settings for building a meniscus B-rep from a segmented LabelMap, with the defaults of each step."""
|
|
55
|
+
|
|
56
|
+
output: Path = Field(default=Path("meniscus.brep"), description="B-rep file to write")
|
|
57
|
+
sigma: OptionalFloat = Field(
|
|
58
|
+
default=None,
|
|
59
|
+
description="Gaussian smoothing of the distance field in physical units. None spans 3 voxel edges per axis, 0 disables",
|
|
60
|
+
)
|
|
61
|
+
level: float = Field(default=0.0, description="Distance of the surface from the label boundary; positive is outside")
|
|
62
|
+
smoothing_iterations: int = Field(
|
|
63
|
+
default=DEFAULT_SMOOTHING_ITERATIONS, ge=0, description="Windowed sinc smoothing iterations on the surface before slicing; 0 disables"
|
|
64
|
+
)
|
|
65
|
+
smoothing_pass_band: float = Field(
|
|
66
|
+
default=DEFAULT_SMOOTHING_PASS_BAND, gt=0, le=2, description="Windowed sinc pass band in (0, 2]; lower smooths and shrinks more"
|
|
67
|
+
)
|
|
68
|
+
spacing: float = Field(default=5.0, description="Arc-length distance between slices along the centerline, in physical units")
|
|
69
|
+
points_per_slice: int = Field(default=32, description="Points on each slice contour. Use about 10 for hexahedral blocks")
|
|
70
|
+
spline_order: int = Field(default=3, description="Polynomial degree of the B-spline geometry")
|
|
71
|
+
cap: Literal["none", "planar", "smooth"] = Field(default="planar", description="How to close the ends; ignored for hexahedral blocks")
|
|
72
|
+
cap_offset: OptionalFloat = Field(
|
|
73
|
+
default=None, description="Apex offset of smooth caps in physical units. None is a quarter of the equivalent radius"
|
|
74
|
+
)
|
|
75
|
+
hex_blocks: bool = Field(default=False, description="Build the five-block butterfly solid with planar caps that hexahedral meshing needs")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MeniscusMesh(BrepMesh):
|
|
79
|
+
"""Settings for meshing a meniscus B-rep, with the defaults of each meshing function."""
|
|
80
|
+
|
|
81
|
+
brep: Path = Field(default=Path("meniscus.brep"), description="B-rep file to read")
|
|
82
|
+
output: Path = Field(default=Path("meniscus.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
83
|
+
element: Literal["tetrahedral", "hexahedral"] = Field(
|
|
84
|
+
default="tetrahedral", description="Element shape. Hexahedral needs a B-rep built with hex_blocks"
|
|
85
|
+
)
|
|
86
|
+
min_size: float = Field(default=0.0, description="Smallest tetrahedron size in physical units")
|
|
87
|
+
max_size: OptionalFloat = Field(default=None, description="Largest tetrahedron size in physical units. None lets gmsh choose")
|
|
88
|
+
elements_per_circle: int = Field(default=0, description="Tetrahedra per circle of surface curvature; 0 disables curvature sizing")
|
|
89
|
+
algorithm_2d: Algorithm2D = Field(default=Algorithm2D.FRONTAL_DELAUNAY, description="gmsh surface algorithm for tetrahedral meshing")
|
|
90
|
+
algorithm_3d: Algorithm3D = Field(default=Algorithm3D.DELAUNAY, description="gmsh volume algorithm for tetrahedral meshing")
|
|
91
|
+
elements_per_edge: int = Field(default=4, description="Hexahedra along each edge of a cap")
|
|
92
|
+
elements_along: int = Field(default=20, description="Hexahedra along the solid between the caps")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TibialCartilageBrep(LabelMapBrep):
|
|
96
|
+
"""Settings for building a tibial cartilage B-rep from a segmented LabelMap. Not implemented yet."""
|
|
97
|
+
|
|
98
|
+
output: Path = Field(default=Path("tibial_cartilage.brep"), description="B-rep file to write")
|
|
99
|
+
name: str = Field(
|
|
100
|
+
default="tibial_cartilage",
|
|
101
|
+
description="Name of the cartilage plate, such as lateral or medial. Both plates share one B-rep and meshing strategy",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TibialCartilageMesh(BrepMesh):
|
|
106
|
+
"""Settings for meshing a tibial cartilage B-rep. Not implemented yet."""
|
|
107
|
+
|
|
108
|
+
brep: Path = Field(default=Path("tibial_cartilage.brep"), description="B-rep file to read")
|
|
109
|
+
output: Path = Field(default=Path("tibial_cartilage.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
110
|
+
name: str = Field(
|
|
111
|
+
default="tibial_cartilage",
|
|
112
|
+
description="Name of the cartilage plate, such as lateral or medial. Both plates share one B-rep and meshing strategy",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FemoralCartilageBrep(LabelMapBrep):
|
|
117
|
+
"""Settings for building a femoral cartilage B-rep from a segmented LabelMap. Not implemented yet."""
|
|
118
|
+
|
|
119
|
+
output: Path = Field(default=Path("femoral_cartilage.brep"), description="B-rep file to write")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FemoralCartilageMesh(BrepMesh):
|
|
123
|
+
"""Settings for meshing a femoral cartilage B-rep. Not implemented yet."""
|
|
124
|
+
|
|
125
|
+
brep: Path = Field(default=Path("femoral_cartilage.brep"), description="B-rep file to read")
|
|
126
|
+
output: Path = Field(default=Path("femoral_cartilage.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PatellarCartilageBrep(LabelMapBrep):
|
|
130
|
+
"""Settings for building a patellar cartilage B-rep from a segmented LabelMap. Not implemented yet."""
|
|
131
|
+
|
|
132
|
+
output: Path = Field(default=Path("patellar_cartilage.brep"), description="B-rep file to write")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class PatellarCartilageMesh(BrepMesh):
|
|
136
|
+
"""Settings for meshing a patellar cartilage B-rep. Not implemented yet."""
|
|
137
|
+
|
|
138
|
+
brep: Path = Field(default=Path("patellar_cartilage.brep"), description="B-rep file to read")
|
|
139
|
+
output: Path = Field(default=Path("patellar_cartilage.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TibiaBrep(LabelMapBrep):
|
|
143
|
+
"""Settings for building a tibia B-rep from a segmented LabelMap. Not implemented yet."""
|
|
144
|
+
|
|
145
|
+
output: Path = Field(default=Path("tibia.brep"), description="B-rep file to write")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TibiaMesh(BrepMesh):
|
|
149
|
+
"""Settings for meshing a tibia B-rep. Not implemented yet."""
|
|
150
|
+
|
|
151
|
+
brep: Path = Field(default=Path("tibia.brep"), description="B-rep file to read")
|
|
152
|
+
output: Path = Field(default=Path("tibia.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FemurBrep(LabelMapBrep):
|
|
156
|
+
"""Settings for building a femur B-rep from a segmented LabelMap. Not implemented yet."""
|
|
157
|
+
|
|
158
|
+
output: Path = Field(default=Path("femur.brep"), description="B-rep file to write")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class FemurMesh(BrepMesh):
|
|
162
|
+
"""Settings for meshing a femur B-rep. Not implemented yet."""
|
|
163
|
+
|
|
164
|
+
brep: Path = Field(default=Path("femur.brep"), description="B-rep file to read")
|
|
165
|
+
output: Path = Field(default=Path("femur.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class PatellaBrep(LabelMapBrep):
|
|
169
|
+
"""Settings for building a patella B-rep from a segmented LabelMap. Not implemented yet."""
|
|
170
|
+
|
|
171
|
+
output: Path = Field(default=Path("patella.brep"), description="B-rep file to write")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class PatellaMesh(BrepMesh):
|
|
175
|
+
"""Settings for meshing a patella B-rep. Not implemented yet."""
|
|
176
|
+
|
|
177
|
+
brep: Path = Field(default=Path("patella.brep"), description="B-rep file to read")
|
|
178
|
+
output: Path = Field(default=Path("patella.msh"), description="Mesh file to write; gmsh infers the format from the extension")
|
kneemesh/disk.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Reading and writing LabelMaps as .nii, .nrrd, or .mhd + .raw, poly data as .vtp, and B-reps as .brep, and writing meshes."""
|
|
2
|
+
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import gmsh
|
|
7
|
+
import numpy as np
|
|
8
|
+
import SimpleITK as sitk
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from vtkmodules.vtkCommonDataModel import vtkPolyData
|
|
11
|
+
from vtkmodules.vtkIOXML import vtkXMLPolyDataReader, vtkXMLPolyDataWriter
|
|
12
|
+
|
|
13
|
+
from kneemesh.brep import BRep, gmsh_session
|
|
14
|
+
from kneemesh.tet_meshing import Mesh
|
|
15
|
+
from kneemesh.vol import LABEL_MAP_PIXEL_IDS
|
|
16
|
+
|
|
17
|
+
SUPPORTED_SUFFIXES = frozenset({".nii", ".nrrd", ".mhd"})
|
|
18
|
+
POLY_DATA_SUFFIXES = frozenset({".vtp"})
|
|
19
|
+
BREP_SUFFIXES = frozenset({".brep"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_label_map(path: str | PathLike[str]) -> sitk.Image:
|
|
23
|
+
"""Read a segmented LabelMap from disk.
|
|
24
|
+
|
|
25
|
+
For a .mhd file, the .raw file it references must also exist.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If the file format is unsupported or the image is not
|
|
29
|
+
sitkUInt8, sitkUInt16, or sitkInt16.
|
|
30
|
+
"""
|
|
31
|
+
path = _validated_path(path)
|
|
32
|
+
image = sitk.ReadImage(str(path))
|
|
33
|
+
_require_label_map(image)
|
|
34
|
+
logger.debug("Read {} LabelMap of size {} and spacing {} from {}", image.GetPixelIDTypeAsString(), image.GetSize(), image.GetSpacing(), path)
|
|
35
|
+
return image
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_label_map(image: sitk.Image, path: str | PathLike[str]) -> None:
|
|
39
|
+
"""Write a segmented LabelMap to disk, overwriting any existing file.
|
|
40
|
+
|
|
41
|
+
A .mhd path also writes a .raw file alongside it.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If the file format is unsupported or the image is not
|
|
45
|
+
sitkUInt8, sitkUInt16, or sitkInt16.
|
|
46
|
+
"""
|
|
47
|
+
path = _validated_path(path)
|
|
48
|
+
_require_label_map(image)
|
|
49
|
+
sitk.WriteImage(image, str(path), useCompression=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_poly_data(path: str | PathLike[str]) -> vtkPolyData:
|
|
53
|
+
"""Read poly data from a VTK XML .vtp file.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If the suffix is not .vtp or the file is not VTK XML poly data.
|
|
57
|
+
FileNotFoundError: If the file does not exist.
|
|
58
|
+
"""
|
|
59
|
+
path = _validated_path(path, POLY_DATA_SUFFIXES)
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
raise FileNotFoundError(f"No such file: {path}")
|
|
62
|
+
|
|
63
|
+
# The reader logs failures and returns empty output without any error state.
|
|
64
|
+
reader = vtkXMLPolyDataReader()
|
|
65
|
+
if not reader.CanReadFile(str(path)):
|
|
66
|
+
raise ValueError(f"Not a VTK XML poly data file: {path}")
|
|
67
|
+
reader.SetFileName(str(path))
|
|
68
|
+
reader.Update()
|
|
69
|
+
return reader.GetOutput()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_poly_data(poly_data: vtkPolyData, path: str | PathLike[str]) -> None:
|
|
73
|
+
"""Write poly data to a VTK XML .vtp file, overwriting any existing file.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If the suffix is not .vtp.
|
|
77
|
+
OSError: If the file cannot be written.
|
|
78
|
+
"""
|
|
79
|
+
path = _validated_path(path, POLY_DATA_SUFFIXES)
|
|
80
|
+
|
|
81
|
+
writer = vtkXMLPolyDataWriter()
|
|
82
|
+
writer.SetFileName(str(path))
|
|
83
|
+
writer.SetInputData(poly_data)
|
|
84
|
+
if not writer.Write():
|
|
85
|
+
raise OSError(f"Could not write poly data to {path}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_brep(path: str | PathLike[str]) -> BRep:
|
|
89
|
+
"""Read a B-rep from an Open Cascade .brep file.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If the suffix is not .brep or the file holds no valid shape.
|
|
93
|
+
FileNotFoundError: If the file does not exist.
|
|
94
|
+
"""
|
|
95
|
+
path = _validated_path(path, BREP_SUFFIXES)
|
|
96
|
+
if not path.is_file():
|
|
97
|
+
raise FileNotFoundError(f"No such file: {path}")
|
|
98
|
+
|
|
99
|
+
with gmsh_session():
|
|
100
|
+
try:
|
|
101
|
+
shapes = gmsh.model.occ.importShapes(str(path))
|
|
102
|
+
except Exception as error: # gmsh raises only the bare Exception type.
|
|
103
|
+
raise ValueError(f"Not a valid B-rep file: {path}") from error
|
|
104
|
+
if not shapes:
|
|
105
|
+
raise ValueError(f"Not a valid B-rep file: {path}")
|
|
106
|
+
return BRep(path.read_text(encoding="utf-8"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_brep(brep: BRep, path: str | PathLike[str]) -> None:
|
|
110
|
+
"""Write a B-rep to an Open Cascade .brep file, overwriting any existing file.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If the suffix is not .brep.
|
|
114
|
+
OSError: If the file cannot be written.
|
|
115
|
+
"""
|
|
116
|
+
path = _validated_path(path, BREP_SUFFIXES)
|
|
117
|
+
path.write_text(brep.text, encoding="utf-8")
|
|
118
|
+
logger.debug("Wrote B-rep to {}", path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_mesh(mesh: Mesh, path: str | PathLike[str]) -> None:
|
|
122
|
+
"""Write a mesh in the format gmsh infers from the extension, overwriting any existing file.
|
|
123
|
+
|
|
124
|
+
Volume formats include .msh, .vtk, .inp, .unv, .bdf, .med, .mesh, and
|
|
125
|
+
.su2. The mesh has only volume elements, so surface formats such as .stl
|
|
126
|
+
hold no elements.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If gmsh does not recognize the extension.
|
|
130
|
+
OSError: If the file cannot be written.
|
|
131
|
+
"""
|
|
132
|
+
path = Path(path)
|
|
133
|
+
with gmsh_session():
|
|
134
|
+
volume = gmsh.model.addDiscreteEntity(3)
|
|
135
|
+
gmsh.model.mesh.addNodes(3, volume, np.arange(1, len(mesh.nodes) + 1).tolist(), mesh.nodes.ravel().tolist())
|
|
136
|
+
first_tag = 1
|
|
137
|
+
for name, connectivity in mesh.elements.items():
|
|
138
|
+
element_tags = np.arange(first_tag, first_tag + len(connectivity))
|
|
139
|
+
gmsh.model.mesh.addElementsByType(volume, mesh.element_types[name], element_tags.tolist(), (connectivity + 1).ravel().tolist())
|
|
140
|
+
first_tag += len(connectivity)
|
|
141
|
+
try:
|
|
142
|
+
gmsh.write(str(path))
|
|
143
|
+
except Exception as error: # gmsh raises only the bare Exception type.
|
|
144
|
+
if "Unknown output file format" in str(error):
|
|
145
|
+
raise ValueError(f"gmsh cannot write meshes in the format of {path.name!r}") from error
|
|
146
|
+
raise OSError(f"Could not write mesh to {path}") from error
|
|
147
|
+
logger.debug("Wrote mesh to {}", path)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _validated_path(path: str | PathLike[str], suffixes: frozenset[str] = SUPPORTED_SUFFIXES) -> Path:
|
|
151
|
+
path = Path(path)
|
|
152
|
+
if path.suffix.lower() not in suffixes:
|
|
153
|
+
supported = ", ".join(sorted(suffixes))
|
|
154
|
+
raise ValueError(f"Unsupported file format {path.name!r}; expected one of: {supported}")
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _require_label_map(image: sitk.Image) -> None:
|
|
159
|
+
if image.GetPixelID() not in LABEL_MAP_PIXEL_IDS:
|
|
160
|
+
raise ValueError(f"LabelMap must be sitkUInt8, sitkUInt16, or sitkInt16, got {image.GetPixelIDTypeAsString()}")
|
kneemesh/geo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""B-rep generation for each knee structure, one module per structure."""
|
kneemesh/geo/femur.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""B-rep generation for the femoral cartilage and the femur. Not implemented yet."""
|