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 +44 -0
- deprojpy/cli.py +133 -0
- deprojpy/core.py +232 -0
- deprojpy/geometry.py +136 -0
- deprojpy/heightmap.py +93 -0
- deprojpy/io.py +72 -0
- deprojpy/labels.py +104 -0
- deprojpy/mask.py +125 -0
- deprojpy/models.py +142 -0
- deprojpy/objects.py +20 -0
- deprojpy/plotting.py +730 -0
- deprojpy/surface_distance/__init__.py +21 -0
- deprojpy/surface_distance/_api.py +36 -0
- deprojpy/surface_distance/_boundary_surface.py +118 -0
- deprojpy/surface_distance/_calculator.py +540 -0
- deprojpy/surface_distance/_graph.py +467 -0
- deprojpy/surface_distance/_helpers.py +75 -0
- deprojpy/surface_distance/_straight.py +241 -0
- deprojpy-0.1.0.dist-info/METADATA +225 -0
- deprojpy-0.1.0.dist-info/RECORD +24 -0
- deprojpy-0.1.0.dist-info/WHEEL +5 -0
- deprojpy-0.1.0.dist-info/entry_points.txt +2 -0
- deprojpy-0.1.0.dist-info/licenses/LICENSE +28 -0
- deprojpy-0.1.0.dist-info/top_level.txt +1 -0
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
|