deprojpy 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.
deprojpy/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """DeProj's height-map workflow for Python."""
2
+
3
+ from .core import from_heightmap, from_labels
4
+ from .heightmap import compute_curvatures, get_z, prepare_heightmap
5
+ from .io import load_label_heightmap_pair, load_tiff_pair
6
+ from .labels import labels_to_objects
7
+ from .mask import mask_to_objects
8
+ from .models import DeprojResult, Epicell
9
+ from .plotting import save_plots
10
+ from .surface_distance import (
11
+ SurfaceDistanceCalculator,
12
+ SurfaceGraph,
13
+ build_surface_graph,
14
+ cell_centers_xy_pixels,
15
+ heightmap_from_cell_boundaries,
16
+ sample_height_at_xy,
17
+ surface_graph_cell_distance,
18
+ surface_straight_cell_distance,
19
+ surface_straight_distance,
20
+ )
21
+
22
+ __all__ = [
23
+ "DeprojResult",
24
+ "Epicell",
25
+ "SurfaceDistanceCalculator",
26
+ "SurfaceGraph",
27
+ "build_surface_graph",
28
+ "cell_centers_xy_pixels",
29
+ "compute_curvatures",
30
+ "from_heightmap",
31
+ "from_labels",
32
+ "get_z",
33
+ "labels_to_objects",
34
+ "load_label_heightmap_pair",
35
+ "load_tiff_pair",
36
+ "mask_to_objects",
37
+ "prepare_heightmap",
38
+ "heightmap_from_cell_boundaries",
39
+ "sample_height_at_xy",
40
+ "save_plots",
41
+ "surface_graph_cell_distance",
42
+ "surface_straight_cell_distance",
43
+ "surface_straight_distance",
44
+ ]
deprojpy/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .core import from_heightmap, from_labels
8
+ from .io import load_label_heightmap_pair, load_tiff_pair
9
+ from .plotting import save_plots
10
+
11
+
12
+ def _add_common_options(parser: argparse.ArgumentParser) -> None:
13
+ parser.add_argument("--csv", type=Path)
14
+ parser.add_argument("--pixel-size", type=float, default=0.183)
15
+ parser.add_argument("--voxel-depth", type=float, default=1.0)
16
+ parser.add_argument("--units", default="µm")
17
+ parser.add_argument("--invert-z", action=argparse.BooleanOptionalAction, default=True)
18
+ parser.add_argument("--plots", type=Path, metavar="DIR", help="save plot PNG files in DIR")
19
+
20
+
21
+ def _print_summary(shape, result, frame) -> None:
22
+ print(f"Input shape: {shape}")
23
+ print(f"Retained cells: {len(result.epicells)}")
24
+ print(
25
+ "Junction graph: "
26
+ f"{result.junction_graph.number_of_nodes()} nodes, "
27
+ f"{result.junction_graph.number_of_edges()} edges"
28
+ )
29
+ print(f"DataFrame shape: {frame.shape}")
30
+ summary_columns = ["area", "perimeter", "eccentricity", "n_neighbors"]
31
+ print(frame[summary_columns].describe().to_string())
32
+
33
+
34
+ def _finish(args, segmentation, heightmap, result, *, labels=None) -> None:
35
+ frame = result.to_dataframe()
36
+ _print_summary(segmentation.shape, result, frame)
37
+ if args.csv:
38
+ args.csv.parent.mkdir(parents=True, exist_ok=True)
39
+ result.to_csv(args.csv)
40
+ print(f"Wrote CSV: {args.csv}")
41
+ if args.plots:
42
+ paths = save_plots(
43
+ args.plots,
44
+ None if labels is not None else segmentation,
45
+ heightmap,
46
+ result,
47
+ frame,
48
+ labels=labels,
49
+ )
50
+ for path in paths:
51
+ print(f"Wrote plot: {path}")
52
+
53
+
54
+ def _build_parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(
56
+ description="Run DeProj-compatible mask/label and height-map analysis"
57
+ )
58
+ subparsers = parser.add_subparsers(dest="mode")
59
+
60
+ binary = subparsers.add_parser("binary-mask", help="run the binary-ridge mask workflow")
61
+ binary.add_argument("mask")
62
+ binary.add_argument("heightmap")
63
+ _add_common_options(binary)
64
+
65
+ labels = subparsers.add_parser("labels", help="run the labeled-image workflow")
66
+ labels.add_argument("labels")
67
+ labels.add_argument("heightmap")
68
+ _add_common_options(labels)
69
+ labels.add_argument("--background", type=int, default=0)
70
+ labels.add_argument("--drop-border-cells", action=argparse.BooleanOptionalAction, default=True)
71
+ labels.add_argument("--junction-background", type=int)
72
+ labels.add_argument("--min-junction-labels", type=int, default=3)
73
+ labels.add_argument("--junction-merge-epsilon", type=float, default=0.0)
74
+ labels.add_argument("--preprocess-labels", action=argparse.BooleanOptionalAction, default=True)
75
+ return parser
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> None:
79
+ if argv is None:
80
+ argv = sys.argv[1:]
81
+ if argv and argv[0] not in {"binary-mask", "labels", "-h", "--help"}:
82
+ # Backward-compatible old form:
83
+ # deprojpy-smoke Segmentation-2.tif HeightMap-2.tif [options]
84
+ legacy = argparse.ArgumentParser(
85
+ description="Run DeProj-compatible binary mask and height-map analysis"
86
+ )
87
+ legacy.add_argument("mask")
88
+ legacy.add_argument("heightmap")
89
+ _add_common_options(legacy)
90
+ args = legacy.parse_args(argv)
91
+ args.mode = "binary-mask"
92
+ else:
93
+ parser = _build_parser()
94
+ args, unknown = parser.parse_known_args(argv)
95
+ if unknown:
96
+ parser.error(f"unrecognized arguments: {' '.join(unknown)}")
97
+
98
+ if args.mode == "labels":
99
+ labels, heightmap = load_label_heightmap_pair(
100
+ args.labels,
101
+ args.heightmap,
102
+ background=args.background,
103
+ preprocess_labels=args.preprocess_labels,
104
+ )
105
+ result = from_labels(
106
+ labels,
107
+ heightmap,
108
+ pixel_size=args.pixel_size,
109
+ voxel_depth=args.voxel_depth,
110
+ units=args.units,
111
+ invert_z=args.invert_z,
112
+ background=args.background,
113
+ drop_border_cells=args.drop_border_cells,
114
+ junction_background=args.junction_background,
115
+ min_junction_labels=args.min_junction_labels,
116
+ junction_merge_epsilon=args.junction_merge_epsilon,
117
+ )
118
+ _finish(args, labels, heightmap, result, labels=labels)
119
+ else:
120
+ mask, heightmap = load_tiff_pair(args.mask, args.heightmap)
121
+ result = from_heightmap(
122
+ mask,
123
+ heightmap,
124
+ pixel_size=args.pixel_size,
125
+ voxel_depth=args.voxel_depth,
126
+ units=args.units,
127
+ invert_z=args.invert_z,
128
+ )
129
+ _finish(args, mask, heightmap, result)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
deprojpy/core.py ADDED
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ import networkx as nx
6
+ import numpy as np
7
+
8
+ from .geometry import (
9
+ BOUNDARY_SIMPLIFICATION_TOLERANCE_PX,
10
+ fit_ellipse_3d,
11
+ fit_plane,
12
+ polygon_metrics,
13
+ simplify_boundary_pixels,
14
+ )
15
+ from .heightmap import compute_curvatures, get_z, prepare_heightmap
16
+ from .labels import labels_to_objects
17
+ from .mask import mask_to_objects
18
+ from .models import DeprojResult, Epicell
19
+ from .objects import MaskObject
20
+
21
+
22
+ def _validate_common_inputs(
23
+ segmentation: np.ndarray,
24
+ heightmap: np.ndarray,
25
+ *,
26
+ segmentation_name: str,
27
+ pixel_size: float,
28
+ voxel_depth: float,
29
+ ) -> tuple[np.ndarray, np.ndarray]:
30
+ segmentation = np.asarray(segmentation)
31
+ heightmap = np.asarray(heightmap)
32
+ if segmentation.ndim != 2 or heightmap.ndim != 2:
33
+ raise ValueError(
34
+ f"{segmentation_name} and heightmap must both be 2-D arrays; "
35
+ f"received shapes {segmentation.shape} and {heightmap.shape}"
36
+ )
37
+ if segmentation.shape != heightmap.shape:
38
+ raise ValueError(
39
+ f"{segmentation_name} and heightmap must have identical shapes; "
40
+ f"got {segmentation.shape} and {heightmap.shape}"
41
+ )
42
+ if not np.isfinite(pixel_size) or pixel_size <= 0:
43
+ raise ValueError("pixel_size must be a positive finite number")
44
+ if not np.isfinite(voxel_depth) or voxel_depth <= 0:
45
+ raise ValueError("voxel_depth must be a positive finite number")
46
+ return segmentation, heightmap
47
+
48
+
49
+ def _from_objects_and_graph(
50
+ objects: list[MaskObject],
51
+ graph: nx.Graph,
52
+ heightmap: np.ndarray,
53
+ *,
54
+ source_shape: tuple[int, int],
55
+ pixel_size: float = 1.0,
56
+ voxel_depth: float = 1.0,
57
+ units: str = "pixels",
58
+ invert_z: bool = False,
59
+ inpaint_zeros: bool = True,
60
+ prune_zeros: bool = True,
61
+ cell_id_policy: Literal["sequential", "source_label"] = "sequential",
62
+ ) -> DeprojResult:
63
+ """Run shared height-map deprojection from extracted 2-D objects and graph."""
64
+ if not objects:
65
+ raise ValueError("object parsing found no retained cells")
66
+ median_area = np.median(
67
+ [polygon_metrics(np.c_[obj.boundary, np.zeros(len(obj.boundary))])[2] for obj in objects]
68
+ )
69
+ smooth_scale = 2.0 * np.sqrt(median_area) / np.pi
70
+ prepared = prepare_heightmap(
71
+ heightmap, voxel_depth, smooth_scale, invert_z, inpaint_zeros, prune_zeros
72
+ )
73
+ if not prune_zeros and not np.all(np.isfinite(prepared)):
74
+ raise ValueError(
75
+ "heightmap contains NaN or infinite values after preprocessing; "
76
+ "enable prune_zeros or provide a finite height map"
77
+ )
78
+
79
+ graph = graph.copy()
80
+ bad_junctions: set[int] = set()
81
+ for node_id, data in graph.nodes(data=True):
82
+ xy = np.asarray(data["centroid"], dtype=float) * pixel_size
83
+ z = get_z(xy[None, :], prepared, pixel_size)[0]
84
+ data["centroid"] = np.array([xy[0], xy[1], z])
85
+ if prune_zeros and (not np.isfinite(z) or z == 0):
86
+ bad_junctions.add(int(node_id))
87
+
88
+ curvatures = compute_curvatures(prepared, smooth_scale, pixel_size)
89
+ cells: list[Epicell] = []
90
+ for obj in objects:
91
+ if bad_junctions.intersection(map(int, obj.junction_ids)):
92
+ continue
93
+
94
+ # Simplify in pixel coordinates before height-map sampling or physical
95
+ # scaling. This keeps the tolerance independent of pixel_size and avoids
96
+ # carrying redundant contour vertices into the 3-D geometry path.
97
+ boundary_px = simplify_boundary_pixels(
98
+ np.asarray(obj.boundary, dtype=float),
99
+ tolerance_px=BOUNDARY_SIMPLIFICATION_TOLERANCE_PX,
100
+ )
101
+ xy = boundary_px * pixel_size
102
+ z = get_z(xy, prepared, pixel_size)
103
+ if prune_zeros and (not np.all(np.isfinite(z)) or np.any(z == 0)):
104
+ continue
105
+ boundary = np.c_[xy, z]
106
+ area, perimeter, area2d, perimeter2d = polygon_metrics(boundary)
107
+ rotation, euler = fit_plane(boundary)
108
+ ellipse, eccentricity, direction = fit_ellipse_3d(boundary, rotation)
109
+ center = boundary.mean(axis=0)
110
+ px = int(np.clip(np.rint(center[0] / pixel_size), 0, source_shape[1] - 1))
111
+ py = int(np.clip(np.rint(center[1] / pixel_size), 0, source_shape[0] - 1))
112
+ local_curvature = np.array([field[py, px] for field in curvatures])
113
+ source_label = obj.source_label
114
+ cell_id = (
115
+ int(source_label)
116
+ if cell_id_policy == "source_label" and source_label is not None
117
+ else len(cells) + 1
118
+ )
119
+ cells.append(
120
+ Epicell(
121
+ id=cell_id,
122
+ source_label=source_label,
123
+ boundary=boundary,
124
+ center=center,
125
+ junction_ids=obj.junction_ids.copy(),
126
+ n_neighbors=len(obj.junction_ids),
127
+ area=area,
128
+ perimeter=perimeter,
129
+ euler_angles=euler,
130
+ curvatures=local_curvature,
131
+ ellipse_fit=ellipse,
132
+ eccentricity=eccentricity,
133
+ proj_direction=direction,
134
+ uncorrected_area=area2d,
135
+ uncorrected_perimeter=perimeter2d,
136
+ area_error=area - area2d,
137
+ perimeter_error=perimeter - perimeter2d,
138
+ )
139
+ )
140
+ graph.remove_nodes_from(bad_junctions)
141
+ return DeprojResult(
142
+ cells,
143
+ graph,
144
+ units=units,
145
+ pixel_size=pixel_size,
146
+ voxel_depth=voxel_depth,
147
+ source_shape=source_shape,
148
+ prepared_heightmap=prepared,
149
+ )
150
+
151
+
152
+ def from_heightmap(
153
+ mask: np.ndarray,
154
+ heightmap: np.ndarray,
155
+ pixel_size: float = 1.0,
156
+ voxel_depth: float = 1.0,
157
+ units: str = "pixels",
158
+ invert_z: bool = False,
159
+ inpaint_zeros: bool = True,
160
+ prune_zeros: bool = True,
161
+ ) -> DeprojResult:
162
+ """Deproject binary-ridge segmented cell contours onto a height-map surface."""
163
+ mask, heightmap = _validate_common_inputs(
164
+ mask,
165
+ heightmap,
166
+ segmentation_name="mask",
167
+ pixel_size=pixel_size,
168
+ voxel_depth=voxel_depth,
169
+ )
170
+ objects, graph = mask_to_objects(mask)
171
+ return _from_objects_and_graph(
172
+ objects,
173
+ graph,
174
+ heightmap,
175
+ source_shape=mask.shape,
176
+ pixel_size=pixel_size,
177
+ voxel_depth=voxel_depth,
178
+ units=units,
179
+ invert_z=invert_z,
180
+ inpaint_zeros=inpaint_zeros,
181
+ prune_zeros=prune_zeros,
182
+ cell_id_policy="sequential",
183
+ )
184
+
185
+
186
+ def from_labels(
187
+ labels: np.ndarray,
188
+ heightmap: np.ndarray,
189
+ pixel_size: float = 1.0,
190
+ voxel_depth: float = 1.0,
191
+ units: str = "pixels",
192
+ invert_z: bool = False,
193
+ inpaint_zeros: bool = True,
194
+ prune_zeros: bool = True,
195
+ *,
196
+ background: int = 0,
197
+ drop_border_cells: bool = True,
198
+ min_junction_labels: int = 3,
199
+ junction_background: int | None = None,
200
+ junction_connectivity: int = 2,
201
+ junction_merge_epsilon: float = 0.0,
202
+ ) -> DeprojResult:
203
+ """Deproject labeled-image cell contours onto a height-map surface."""
204
+ labels, heightmap = _validate_common_inputs(
205
+ labels,
206
+ heightmap,
207
+ segmentation_name="labels",
208
+ pixel_size=pixel_size,
209
+ voxel_depth=voxel_depth,
210
+ )
211
+ objects, graph = labels_to_objects(
212
+ labels,
213
+ background=background,
214
+ drop_border_cells=drop_border_cells,
215
+ min_junction_labels=min_junction_labels,
216
+ junction_background=junction_background,
217
+ junction_connectivity=junction_connectivity,
218
+ junction_merge_epsilon=junction_merge_epsilon,
219
+ )
220
+ return _from_objects_and_graph(
221
+ objects,
222
+ graph,
223
+ heightmap,
224
+ source_shape=labels.shape,
225
+ pixel_size=pixel_size,
226
+ voxel_depth=voxel_depth,
227
+ units=units,
228
+ invert_z=invert_z,
229
+ inpaint_zeros=inpaint_zeros,
230
+ prune_zeros=prune_zeros,
231
+ cell_id_policy="source_label",
232
+ )
deprojpy/geometry.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+
5
+ import numpy as np
6
+ from skimage.measure import approximate_polygon
7
+
8
+ # Boundary simplification happens in pixel coordinates so the tolerance is
9
+ # independent of pixel_size. 0.5 px is intentionally conservative.
10
+ BOUNDARY_SIMPLIFICATION_TOLERANCE_PX = 0.5
11
+
12
+
13
+ def simplify_boundary_pixels(
14
+ boundary_xy: np.ndarray,
15
+ *,
16
+ tolerance_px: float = BOUNDARY_SIMPLIFICATION_TOLERANCE_PX,
17
+ ) -> np.ndarray:
18
+ """
19
+ Simplify an ordered cell boundary in pixel coordinates.
20
+
21
+ Parameters
22
+ ----------
23
+ boundary_xy : np.ndarray
24
+ Ordered ``(n, 2)`` boundary coordinates in geometric ``(x, y)`` pixel
25
+ order. The boundary may be open or explicitly closed.
26
+ tolerance_px : float, optional
27
+ Ramer-Douglas-Peucker distance tolerance in pixels. Larger values allow
28
+ stronger simplification. Default is the internal conservative tolerance
29
+ ``0.5`` px.
30
+
31
+ Returns
32
+ -------
33
+ np.ndarray
34
+ Simplified ordered boundary in ``(x, y)`` pixel coordinates. The output
35
+ follows the project convention of an open polygon: the first vertex is
36
+ not repeated at the end.
37
+ """
38
+ boundary = np.asarray(boundary_xy, dtype=float)
39
+ if boundary.ndim != 2 or boundary.shape[1] != 2:
40
+ raise ValueError("boundary_xy must have shape (n, 2)")
41
+ if not np.isfinite(tolerance_px) or tolerance_px < 0:
42
+ raise ValueError("tolerance_px must be a non-negative finite number")
43
+ if len(boundary) < 4:
44
+ return boundary.copy()
45
+
46
+ # approximate_polygon expects an explicit closed ring for polygonal
47
+ # boundaries. DeProjPy stores boundaries open and closes them implicitly in
48
+ # metric calculations, so strip the repeated endpoint after simplification.
49
+ closed = (
50
+ boundary
51
+ if np.allclose(boundary[0], boundary[-1])
52
+ else np.vstack([boundary, boundary[0]])
53
+ )
54
+ reduced = approximate_polygon(closed, tolerance=float(tolerance_px))
55
+ if len(reduced) > 1 and np.allclose(reduced[0], reduced[-1]):
56
+ reduced = reduced[:-1]
57
+
58
+ # Tiny or highly simplified contours can collapse below a usable polygon.
59
+ # In that case, keep the detailed boundary rather than fabricating geometry.
60
+ if len(reduced) < 4:
61
+ return boundary[:-1].copy() if np.allclose(boundary[0], boundary[-1]) else boundary.copy()
62
+ return np.asarray(reduced, dtype=float)
63
+
64
+
65
+ def polygon_metrics(boundary: np.ndarray) -> tuple[float, float, float, float]:
66
+ """
67
+ Compute 3D and 2D area and perimeter of a polygon defined by a boundary using
68
+ the shoelace formula and cross product. The 3D area is calculated using the
69
+ cross product of adjacent edges, while the 2D area is computed using the
70
+ shoelace formula. The perimeter is calculated as the sum of distances between
71
+ consecutive points in both 3D and 2D.
72
+ """
73
+ centered = boundary - boundary.mean(axis=0)
74
+ nxt = np.roll(centered, -1, axis=0)
75
+ area3d = 0.5 * np.linalg.norm(np.cross(centered, nxt), axis=1).sum()
76
+ x, y = centered[:, 0], centered[:, 1]
77
+ area2d = 0.5 * abs(np.sum(x * np.roll(y, -1) - y * np.roll(x, -1)))
78
+ perimeter3d = np.linalg.norm(np.roll(centered, -1, axis=0) - centered, axis=1).sum()
79
+ perimeter2d = np.linalg.norm(
80
+ np.roll(centered[:, :2], -1, axis=0) - centered[:, :2], axis=1
81
+ ).sum()
82
+ return float(area3d), float(perimeter3d), float(area2d), float(perimeter2d)
83
+
84
+
85
+ def fit_plane(boundary: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
86
+ """
87
+ Fit a plane to the given boundary points.
88
+ """
89
+ centered = boundary - boundary.mean(axis=0)
90
+ _, _, rotation = np.linalg.svd(centered, full_matrices=False)
91
+ rotation = rotation.T
92
+ if np.linalg.det(rotation) < 0:
93
+ rotation[:, -1] *= -1
94
+ return rotation, rot_to_euler_zxz(rotation)
95
+
96
+
97
+ def rot_to_euler_zxz(r: np.ndarray) -> np.ndarray:
98
+ """
99
+ Convert a rotation matrix to Euler angles in the ZXZ convention.
100
+ The function computes the Euler angles (alpha, beta, gamma) from the given rotation
101
+ matrix. The angles are returned in radians and represent rotations about the Z, X,
102
+ and Z axes, respectively.
103
+ """
104
+ beta = np.arccos(np.clip(r[2, 2], -1.0, 1.0))
105
+ if abs(np.sin(beta)) > 1e-12:
106
+ alpha = np.arctan2(r[0, 2], -r[1, 2])
107
+ gamma = np.arctan2(r[2, 0], r[2, 1])
108
+ elif r[2, 2] > 0:
109
+ alpha, gamma = np.arctan2(-r[0, 1], r[0, 0]), 0.0
110
+ else:
111
+ alpha, gamma = -np.arctan2(-r[0, 1], r[0, 0]), 0.0
112
+ return np.array([alpha, beta, gamma], dtype=float)
113
+
114
+
115
+ def fit_ellipse_3d(boundary: np.ndarray, rotation: np.ndarray) -> tuple[np.ndarray, float, float]:
116
+ """Fit a moment-equivalent ellipse in the local best-fit plane."""
117
+ center = boundary.mean(axis=0)
118
+ local = (boundary - center) @ rotation
119
+ xy = local[:, :2]
120
+ try:
121
+ covariance = np.cov(xy, rowvar=False)
122
+ values, vectors = np.linalg.eigh(covariance)
123
+ order = np.argsort(values)[::-1]
124
+ values, vectors = values[order], vectors[:, order]
125
+ # For points sampled around an ellipse boundary, variance = axis^2 / 2.
126
+ axes = np.sqrt(np.maximum(2.0 * values, 0.0))
127
+ a, b = float(axes[0]), float(axes[1])
128
+ theta = float(np.arctan2(vectors[1, 0], vectors[0, 0]))
129
+ major_local = np.array([np.cos(theta), np.sin(theta), 0.0])
130
+ major_global = major_local @ rotation.T
131
+ direction = float(np.arctan2(major_global[1], major_global[0]))
132
+ eccentricity = float(np.sqrt(max(0.0, 1.0 - (b / a) ** 2))) if a > 0 else np.nan
133
+ return np.array([*center, a, b, theta]), eccentricity, direction
134
+ except np.linalg.LinAlgError:
135
+ warnings.warn("ellipse fit failed", RuntimeWarning, stacklevel=2)
136
+ return np.full(6, np.nan), np.nan, np.nan
deprojpy/heightmap.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy import ndimage as ndi
5
+ from skimage.restoration import inpaint
6
+
7
+
8
+ def _nan_gaussian(image: np.ndarray, sigma: float) -> np.ndarray:
9
+ valid = np.isfinite(image)
10
+ values = ndi.gaussian_filter(np.where(valid, image, 0.0), sigma, mode="nearest")
11
+ weights = ndi.gaussian_filter(valid.astype(float), sigma, mode="nearest")
12
+ return np.divide(values, weights, out=np.full_like(values, np.nan), where=weights > 1e-12)
13
+
14
+
15
+ def prepare_heightmap(
16
+ heightmap: np.ndarray,
17
+ voxel_depth: float = 1.0,
18
+ smooth_scale: float = -1.0,
19
+ invert_z: bool = False,
20
+ inpaint_zeros: bool = True,
21
+ prune_zeros: bool = True,
22
+ ) -> np.ndarray:
23
+ """Prepare a height map and return Z in physical units.
24
+
25
+ Zero regions are biharmonically interpolated when requested. Remaining
26
+ zeros become NaN when pruning is enabled. Inversion happens before unit
27
+ scaling, as in the MATLAB workflow.
28
+ """
29
+ h = np.asarray(heightmap, dtype=float).copy()
30
+ if h.ndim != 2:
31
+ raise ValueError(f"heightmap must be a 2-D array; received shape {h.shape}")
32
+ if h.size == 0:
33
+ raise ValueError("heightmap must not be empty")
34
+ if not np.isfinite(voxel_depth) or voxel_depth <= 0:
35
+ raise ValueError("voxel_depth must be a positive finite number")
36
+ if not np.any(np.isfinite(h)):
37
+ raise ValueError("heightmap must contain at least one finite value")
38
+ zero_mask = h == 0
39
+ if inpaint_zeros and zero_mask.any():
40
+ if zero_mask.all():
41
+ if prune_zeros:
42
+ raise ValueError(
43
+ "heightmap contains only zeros, so no finite surface remains "
44
+ "after zero pruning"
45
+ )
46
+ else:
47
+ h = inpaint.inpaint_biharmonic(h, zero_mask, channel_axis=None)
48
+ if prune_zeros:
49
+ h[h == 0] = np.nan
50
+ if smooth_scale > 0:
51
+ h = _nan_gaussian(h, smooth_scale)
52
+ if invert_z:
53
+ finite = h[np.isfinite(h)]
54
+ if finite.size:
55
+ h = finite.max() - h
56
+ h *= float(voxel_depth)
57
+ if not np.any(np.isfinite(h)):
58
+ raise ValueError(
59
+ "heightmap contains no finite values after preprocessing; check zero "
60
+ "handling and the input image"
61
+ )
62
+ return h
63
+
64
+
65
+ def get_z(points_xy: np.ndarray, heightmap: np.ndarray, pixel_size: float) -> np.ndarray:
66
+ """Sample nearest height-map pixels for physical ``(x, y)`` points."""
67
+ points = np.asarray(points_xy, dtype=float)
68
+ # Coordinates in this port are zero-based; np.rint is nearest-pixel sampling.
69
+ pixels = np.rint(points / pixel_size).astype(int)
70
+ x = np.clip(pixels[:, 0], 0, heightmap.shape[1] - 1)
71
+ y = np.clip(pixels[:, 1], 0, heightmap.shape[0] - 1)
72
+ return np.asarray(heightmap[y, x], dtype=float)
73
+
74
+
75
+ def compute_curvatures(
76
+ heightmap: np.ndarray,
77
+ object_scale: float | None = None,
78
+ pixel_size: float = 1.0,
79
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
80
+ """Compute mean, Gaussian, and principal curvatures of z=f(x,y)."""
81
+ h = np.asarray(heightmap, dtype=float)
82
+ if object_scale is not None and np.isfinite(object_scale) and object_scale > 0:
83
+ h = _nan_gaussian(h, 3.0 * object_scale)
84
+ # np.gradient returns derivatives in axis order: y, x.
85
+ hy, hx = np.gradient(h, pixel_size, pixel_size)
86
+ hxy, hxx = np.gradient(hx, pixel_size, pixel_size)
87
+ hyy, _ = np.gradient(hy, pixel_size, pixel_size)
88
+ norm = 1.0 + hx * hx + hy * hy
89
+ gaussian = (hxx * hyy - hxy * hxy) / (norm * norm)
90
+ mean = ((1.0 + hx * hx) * hyy + (1.0 + hy * hy) * hxx - 2.0 * hx * hy * hxy) / (2.0 * norm**1.5)
91
+ discriminant = np.maximum(mean * mean - gaussian, 0.0)
92
+ root = np.sqrt(discriminant)
93
+ return mean, gaussian, mean + root, mean - root