pointclouds 0.0.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.
- pointclouds/__init__.py +20 -0
- pointclouds/classify/__init__.py +1 -0
- pointclouds/classify/ground.py +1 -0
- pointclouds/classify/trees.py +1 -0
- pointclouds/convert.py +35 -0
- pointclouds/io/__init__.py +1 -0
- pointclouds/io/_records.py +196 -0
- pointclouds/io/copc.py +570 -0
- pointclouds/io/ept.py +724 -0
- pointclouds/io/las.py +119 -0
- pointclouds/ops/__init__.py +1 -0
- pointclouds/ops/clip.py +99 -0
- pointclouds/ops/decimate.py +107 -0
- pointclouds/ops/filter.py +109 -0
- pointclouds/ops/grid.py +241 -0
- pointclouds/ops/merge.py +107 -0
- pointclouds/points.py +801 -0
- pointclouds-0.0.0.dist-info/METADATA +95 -0
- pointclouds-0.0.0.dist-info/RECORD +21 -0
- pointclouds-0.0.0.dist-info/WHEEL +4 -0
- pointclouds-0.0.0.dist-info/licenses/LICENSE.md +675 -0
pointclouds/io/las.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""laspy-backed LAS/LAZ engine: header-first read, chunked record read, write. (Phase 1 - T-8).
|
|
2
|
+
|
|
3
|
+
Two entry points, deliberately separate, because the split is the point of the design:
|
|
4
|
+
|
|
5
|
+
- :func:`read_header` opens the file and returns metadata only. No point record is decompressed, so the cost is
|
|
6
|
+
constant whatever the file's size - a 40 GB tile answers as fast as a 40 KB one.
|
|
7
|
+
- :func:`read_points` decompresses the record arrays, optionally only the dimensions asked for.
|
|
8
|
+
|
|
9
|
+
Everything here imports with the core dependency set (numpy, laspy, pyproj). Spatially-indexed windowed reads
|
|
10
|
+
live in :mod:`pointclouds.io.copc` (T-16) and writing lands with T-13; neither belongs in this module. The
|
|
11
|
+
record-array contract the two readers share is in :mod:`pointclouds.io._records`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import laspy
|
|
20
|
+
import numpy as np
|
|
21
|
+
from pyproj import CRS
|
|
22
|
+
|
|
23
|
+
from pointclouds.io import _records
|
|
24
|
+
|
|
25
|
+
__all__ = ["LasHeaderInfo", "read_header", "read_points"]
|
|
26
|
+
|
|
27
|
+
_SUFFIXES = {".las", ".laz"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class LasHeaderInfo:
|
|
32
|
+
"""Everything the LAS/LAZ header can answer without touching a point record.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
count: the header's point count. On a lazy cloud this is known before any record is read, which is why it
|
|
36
|
+
is not simply ``len(cloud)``.
|
|
37
|
+
bounds: ``(xmin, ymin, xmax, ymax)`` as recorded in the header.
|
|
38
|
+
crs: the file's CRS as a :class:`pyproj.CRS`, or ``None`` when the header carries no SRS. Never a GDAL or
|
|
39
|
+
pyramids object - that is the dependency boundary this package exists to keep.
|
|
40
|
+
dimensions: every dimension name the point format declares, in file order.
|
|
41
|
+
version: the LAS version string, e.g. ``"1.4"``.
|
|
42
|
+
point_format: the point format id, e.g. ``3``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
count: int
|
|
46
|
+
bounds: tuple[float, float, float, float]
|
|
47
|
+
crs: CRS | None
|
|
48
|
+
dimensions: tuple[str, ...]
|
|
49
|
+
version: str
|
|
50
|
+
point_format: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve(path: str | Path) -> Path:
|
|
54
|
+
"""Return ``path`` as a :class:`~pathlib.Path`, rejecting what this engine cannot read.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: the LAS/LAZ file to open.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The resolved path.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
FileNotFoundError: when the path does not exist, naming the path rather than surfacing laspy's error.
|
|
64
|
+
ValueError: when the suffix is neither ``.las`` nor ``.laz``.
|
|
65
|
+
"""
|
|
66
|
+
resolved = Path(path)
|
|
67
|
+
if resolved.suffix.lower() not in _SUFFIXES:
|
|
68
|
+
raise ValueError(f"{resolved.name!r} is not a LAS/LAZ file; expected one of {sorted(_SUFFIXES)}")
|
|
69
|
+
if not resolved.is_file():
|
|
70
|
+
raise FileNotFoundError(f"no such point cloud file: {resolved}")
|
|
71
|
+
return resolved
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_header(path: str | Path) -> LasHeaderInfo:
|
|
75
|
+
"""Read the header of a LAS/LAZ file, touching no point records.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: the file to open.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A :class:`LasHeaderInfo` with count, bounds, CRS, dimensions and format metadata.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
FileNotFoundError: when the path does not exist.
|
|
85
|
+
ValueError: when the suffix is neither ``.las`` nor ``.laz``.
|
|
86
|
+
"""
|
|
87
|
+
resolved = _resolve(path)
|
|
88
|
+
with laspy.open(resolved) as reader:
|
|
89
|
+
header = reader.header
|
|
90
|
+
return LasHeaderInfo(
|
|
91
|
+
count=int(header.point_count),
|
|
92
|
+
bounds=(float(header.x_min), float(header.y_min), float(header.x_max), float(header.y_max)),
|
|
93
|
+
crs=header.parse_crs(),
|
|
94
|
+
dimensions=tuple(str(name) for name in header.point_format.dimension_names),
|
|
95
|
+
version=str(header.version),
|
|
96
|
+
point_format=int(header.point_format.id),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_points(path: str | Path, dimensions: tuple[str, ...] | None = None) -> dict[str, np.ndarray]:
|
|
101
|
+
"""Read the point records of a LAS/LAZ file.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: the file to open.
|
|
105
|
+
dimensions: dimension names to read, case-insensitive. ``None`` reads every dimension this engine maps
|
|
106
|
+
onto a :class:`~pointclouds.points.PointCloud` field.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A mapping of lower-case dimension name to array. A dimension the file's point format does not declare
|
|
110
|
+
comes back as a zero-length array of the right dtype, never as a missing key.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
FileNotFoundError: when the path does not exist.
|
|
114
|
+
ValueError: when the suffix is neither ``.las`` nor ``.laz``.
|
|
115
|
+
KeyError: when a requested dimension is not one this engine knows.
|
|
116
|
+
"""
|
|
117
|
+
resolved = _resolve(path)
|
|
118
|
+
requested = _records.resolve(dimensions)
|
|
119
|
+
return _records.extract(laspy.read(resolved), requested)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Operations on a point cloud: filter, decimate, clip, merge, grid."""
|
pointclouds/ops/clip.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Clip a cloud to an extent or a polygon. (Phase 2 - T-9).
|
|
2
|
+
|
|
3
|
+
ADR - clip stays in the pure-numpy core (the §9 open question, settled here).
|
|
4
|
+
The core's selling point is a pure-PyPI wheel that installs anywhere numpy runs, so adding shapely to
|
|
5
|
+
``[project.dependencies]`` was rejected. Both clip forms are therefore pure numpy:
|
|
6
|
+
|
|
7
|
+
- ``bbox`` is a vectorised coordinate comparison (:func:`pointclouds.ops.filter.bbox_mask`).
|
|
8
|
+
- ``polygon`` is a vectorised even-odd ray-casting point-in-polygon test over a single ring.
|
|
9
|
+
|
|
10
|
+
So the core dependency set is unchanged. Out of scope for the core, and left to the ``[gis]`` vector path
|
|
11
|
+
(pyramids ``FeatureCollection``): polygons with holes, multipart polygons, and shapely/OGR geometry objects.
|
|
12
|
+
|
|
13
|
+
:func:`clip_mask` returns a boolean mask; :meth:`PointCloud._take` does the subsetting.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from pointclouds.ops.filter import bbox_mask
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pointclouds.points import PointCloud
|
|
26
|
+
|
|
27
|
+
__all__ = ["clip_mask"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clip_mask(
|
|
31
|
+
cloud: PointCloud,
|
|
32
|
+
*,
|
|
33
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
34
|
+
polygon: np.ndarray | None = None,
|
|
35
|
+
) -> np.ndarray:
|
|
36
|
+
"""Return the mask of points kept by a bbox or polygon clip.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
cloud: the cloud to clip; its records are loaded if they are not in memory yet.
|
|
40
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` extent, edges included.
|
|
41
|
+
polygon: an ``(M, 2)`` array of ``(x, y)`` vertices forming a single ring (closed or open).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``(N,)`` boolean mask over the cloud's points.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: when neither or both of ``bbox`` and ``polygon`` are given, or when ``polygon`` is not an
|
|
48
|
+
``(M, 2)`` array of at least three vertices.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
>>> import numpy as np
|
|
52
|
+
>>> from pointclouds import PointCloud
|
|
53
|
+
>>> cloud = PointCloud(x=np.array([0.0, 2.0]), y=np.array([0.0, 2.0]), z=np.zeros(2))
|
|
54
|
+
>>> clip_mask(cloud, bbox=(1.0, 1.0, 3.0, 3.0)).tolist()
|
|
55
|
+
[False, True]
|
|
56
|
+
>>> ring = np.array([[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [3.0, 1.0]])
|
|
57
|
+
>>> clip_mask(cloud, polygon=ring).tolist()
|
|
58
|
+
[False, True]
|
|
59
|
+
"""
|
|
60
|
+
if (bbox is None) == (polygon is None):
|
|
61
|
+
raise ValueError("clip needs exactly one of bbox or polygon")
|
|
62
|
+
if bbox is not None:
|
|
63
|
+
return bbox_mask(cloud.x, cloud.y, bbox)
|
|
64
|
+
return _points_in_polygon(cloud.x, cloud.y, np.asarray(polygon, dtype=np.float64))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _points_in_polygon(x: np.ndarray, y: np.ndarray, ring: np.ndarray) -> np.ndarray:
|
|
68
|
+
"""Return the mask of points inside a single polygon ring by even-odd ray casting.
|
|
69
|
+
|
|
70
|
+
The test is vectorised over all points and loops only over the ring's edges, so its cost is
|
|
71
|
+
``O(points * edges)``. A point on the boundary is reported inconsistently, as the even-odd rule always does;
|
|
72
|
+
that is acceptable for clipping a dense cloud.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
x: ``(N,)`` x-coordinates.
|
|
76
|
+
y: ``(N,)`` y-coordinates.
|
|
77
|
+
ring: ``(M, 2)`` vertices; the ring is treated as closed, so the first vertex need not be repeated.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
``(N,)`` boolean mask, ``True`` for points inside the ring.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: when ``ring`` is not an ``(M, 2)`` array of at least three vertices.
|
|
84
|
+
"""
|
|
85
|
+
if ring.ndim != 2 or ring.shape[1] != 2 or ring.shape[0] < 3:
|
|
86
|
+
raise ValueError(f"polygon must be an (M, 2) array of at least 3 vertices; got shape {ring.shape}")
|
|
87
|
+
|
|
88
|
+
px, py = ring[:, 0], ring[:, 1]
|
|
89
|
+
inside = np.zeros(x.shape[0], dtype=bool)
|
|
90
|
+
# Walk each edge (j -> i); a horizontal edge never flips the ray parity, and the (yi > y) != (yj > y)
|
|
91
|
+
# guard makes it harmless, so the divide/invalid warnings it would raise are suppressed rather than hit.
|
|
92
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
93
|
+
j = len(px) - 1
|
|
94
|
+
for i in range(len(px)):
|
|
95
|
+
crosses = (py[i] > y) != (py[j] > y)
|
|
96
|
+
x_at_edge = (px[j] - px[i]) * (y - py[i]) / (py[j] - py[i]) + px[i]
|
|
97
|
+
inside ^= crosses & (x < x_at_edge)
|
|
98
|
+
j = i
|
|
99
|
+
return inside
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Choose which points survive a decimation: random thinning and voxel thinning. (Phase 2 - T-9).
|
|
2
|
+
|
|
3
|
+
Both entry points return an integer index array into the cloud's points; :meth:`PointCloud._take` does the
|
|
4
|
+
actual subsetting, so neither function constructs a cloud. Random thinning is reproducible under a seed; voxel
|
|
5
|
+
thinning keeps one point per cube and is deterministic. Pure numpy - no GDAL, no pyramids.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pointclouds.points import PointCloud
|
|
16
|
+
|
|
17
|
+
__all__ = ["select_indices"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def select_indices(
|
|
21
|
+
cloud: PointCloud,
|
|
22
|
+
*,
|
|
23
|
+
target: int | None = None,
|
|
24
|
+
fraction: float | None = None,
|
|
25
|
+
method: str = "random",
|
|
26
|
+
voxel_size: float | None = None,
|
|
27
|
+
seed: int | None = None,
|
|
28
|
+
) -> np.ndarray:
|
|
29
|
+
"""Return the indices of the points to keep under the requested decimation.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cloud: the cloud to thin; its records are loaded if they are not in memory yet.
|
|
33
|
+
target: for ``method="random"``, the number of points to keep. Never more than the cloud holds.
|
|
34
|
+
fraction: for ``method="random"``, the share of points to keep, in ``(0, 1]``. A positive fraction over a
|
|
35
|
+
non-empty cloud keeps at least one point (it never rounds down to zero).
|
|
36
|
+
method: ``"random"`` for a uniform sample without replacement, ``"voxel"`` for one point per cube.
|
|
37
|
+
voxel_size: for ``method="voxel"``, the cube edge length in CRS units.
|
|
38
|
+
seed: seeds the random sample so a run is reproducible; ignored by ``"voxel"``.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A sorted ``(k,)`` int array of indices into the cloud's points, so ``_take`` preserves point order.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: when the arguments for the chosen method are missing, ambiguous, or out of range, or when
|
|
45
|
+
``method`` is neither ``"random"`` nor ``"voxel"``.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> import numpy as np
|
|
49
|
+
>>> from pointclouds import PointCloud
|
|
50
|
+
>>> cloud = PointCloud(x=np.arange(10.0), y=np.zeros(10), z=np.zeros(10))
|
|
51
|
+
>>> len(select_indices(cloud, target=3, seed=0))
|
|
52
|
+
3
|
|
53
|
+
>>> line = PointCloud(x=np.array([0.0, 0.5, 1.0, 1.5]), y=np.zeros(4), z=np.zeros(4))
|
|
54
|
+
>>> select_indices(line, method="voxel", voxel_size=1.0).tolist()
|
|
55
|
+
[0, 2]
|
|
56
|
+
"""
|
|
57
|
+
n = cloud.x.shape[0] # forces a lazy cloud to load
|
|
58
|
+
|
|
59
|
+
if method == "random":
|
|
60
|
+
return _random_indices(n, target=target, fraction=fraction, seed=seed)
|
|
61
|
+
if method == "voxel":
|
|
62
|
+
return _voxel_indices(cloud, voxel_size=voxel_size)
|
|
63
|
+
raise ValueError(f"unknown decimation method {method!r}; expected 'random' or 'voxel'")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _random_indices(n: int, *, target: int | None, fraction: float | None, seed: int | None) -> np.ndarray:
|
|
67
|
+
"""Return a sorted uniform sample of ``n`` indices, sized by ``target`` or ``fraction``."""
|
|
68
|
+
if (target is None) == (fraction is None):
|
|
69
|
+
raise ValueError("random decimation needs exactly one of target or fraction")
|
|
70
|
+
if target is not None:
|
|
71
|
+
if target < 0:
|
|
72
|
+
raise ValueError(f"target must be non-negative; got {target}")
|
|
73
|
+
keep = min(int(target), n)
|
|
74
|
+
else:
|
|
75
|
+
assert fraction is not None # noqa: S101 - guaranteed by the exactly-one check above
|
|
76
|
+
if not 0.0 < fraction <= 1.0:
|
|
77
|
+
raise ValueError(f"fraction must be in (0, 1]; got {fraction}")
|
|
78
|
+
# A positive fraction over a non-empty cloud keeps at least one point: rounding a small share down to
|
|
79
|
+
# zero would turn "keep some" into "keep none", which no caller asking for a fraction means.
|
|
80
|
+
keep = min(n, max(1, int(round(fraction * n)))) if n else 0
|
|
81
|
+
|
|
82
|
+
rng = np.random.default_rng(seed)
|
|
83
|
+
chosen = rng.choice(n, size=keep, replace=False)
|
|
84
|
+
chosen.sort()
|
|
85
|
+
return chosen
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _voxel_indices(cloud: PointCloud, *, voxel_size: float | None) -> np.ndarray:
|
|
89
|
+
"""Return the index of the first point in each occupied ``voxel_size`` cube, in point order."""
|
|
90
|
+
if voxel_size is None or voxel_size <= 0:
|
|
91
|
+
raise ValueError(f"voxel decimation needs a positive voxel_size; got {voxel_size}")
|
|
92
|
+
|
|
93
|
+
x, y, z = cloud.x, cloud.y, cloud.z
|
|
94
|
+
if x.size == 0:
|
|
95
|
+
return np.empty(0, dtype=np.intp)
|
|
96
|
+
|
|
97
|
+
keys = np.stack(
|
|
98
|
+
[
|
|
99
|
+
np.floor((x - x.min()) / voxel_size).astype(np.int64),
|
|
100
|
+
np.floor((y - y.min()) / voxel_size).astype(np.int64),
|
|
101
|
+
np.floor((z - z.min()) / voxel_size).astype(np.int64),
|
|
102
|
+
],
|
|
103
|
+
axis=1,
|
|
104
|
+
)
|
|
105
|
+
_, first = np.unique(keys, axis=0, return_index=True)
|
|
106
|
+
first.sort()
|
|
107
|
+
return first
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Build the boolean masks that subset a cloud by attribute or extent. (Phase 2 - T-9).
|
|
2
|
+
|
|
3
|
+
This is the one mask-building helper the four public operations share. :func:`build_mask` turns the
|
|
4
|
+
``filter`` criteria into a boolean mask over the points; :func:`bbox_mask` is the extent test that both
|
|
5
|
+
:meth:`~pointclouds.points.PointCloud.filter` and :mod:`pointclouds.ops.clip` reuse. Nothing here constructs a
|
|
6
|
+
cloud or indexes its arrays - that is :meth:`PointCloud._take`'s job, so the alignment invariant lives in one
|
|
7
|
+
place. Everything imports with the core dependency set alone (numpy); no GDAL, no pyramids, no shapely.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pointclouds.points import PointCloud
|
|
19
|
+
|
|
20
|
+
__all__ = ["bbox_mask", "build_mask"]
|
|
21
|
+
|
|
22
|
+
#: The attribute dimensions :func:`build_mask` can filter on, each a membership test against the given value(s).
|
|
23
|
+
#: Ordered as everywhere else in the package (:data:`pointclouds.points._ATTRIBUTE_DTYPES`, ``ops.merge``).
|
|
24
|
+
_ATTRIBUTE_CRITERIA = ("intensity", "classification", "return_number")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bbox_mask(x: np.ndarray, y: np.ndarray, bbox: tuple[float, float, float, float]) -> np.ndarray:
|
|
28
|
+
"""Return the mask of points inside an axis-aligned bounding box, edges included.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
x: ``(N,)`` x-coordinates.
|
|
32
|
+
y: ``(N,)`` y-coordinates.
|
|
33
|
+
bbox: ``(xmin, ymin, xmax, ymax)``; a point on an edge counts as inside.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
``(N,)`` boolean mask, ``True`` where the point falls within ``bbox``.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: when ``bbox`` is not four numbers with ``xmin <= xmax`` and ``ymin <= ymax``.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
>>> import numpy as np
|
|
43
|
+
>>> x = np.array([0.0, 5.0, 9.0])
|
|
44
|
+
>>> y = np.array([0.0, 0.0, 0.0])
|
|
45
|
+
>>> bbox_mask(x, y, (4.0, -1.0, 10.0, 1.0)).tolist()
|
|
46
|
+
[False, True, True]
|
|
47
|
+
"""
|
|
48
|
+
xmin, ymin, xmax, ymax = bbox
|
|
49
|
+
if xmin > xmax or ymin > ymax:
|
|
50
|
+
raise ValueError(f"bbox must be (xmin, ymin, xmax, ymax) with min <= max; got {bbox}")
|
|
51
|
+
return (x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_mask(
|
|
55
|
+
cloud: PointCloud,
|
|
56
|
+
*,
|
|
57
|
+
classification: int | Iterable[int] | None = None,
|
|
58
|
+
return_number: int | Iterable[int] | None = None,
|
|
59
|
+
intensity: int | Iterable[int] | None = None,
|
|
60
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
61
|
+
) -> np.ndarray:
|
|
62
|
+
"""Combine the given criteria into one boolean mask, ANDing every criterion together.
|
|
63
|
+
|
|
64
|
+
Each attribute criterion is a membership test: a scalar keeps points equal to it, an iterable keeps points
|
|
65
|
+
whose value is in the set. ``bbox`` keeps points inside the extent. A criterion left ``None`` is ignored; with
|
|
66
|
+
no criteria at all the mask keeps everything.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
cloud: the cloud to test; its record arrays are loaded if they are not in memory yet.
|
|
70
|
+
classification: an ASPRS class code, or an iterable of them, to keep.
|
|
71
|
+
return_number: a return number, or an iterable of them, to keep.
|
|
72
|
+
intensity: an intensity value, or an iterable of them, to keep.
|
|
73
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` extent to keep, edges included.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
``(N,)`` boolean mask over the cloud's points.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: when a criterion names an attribute the cloud does not carry (an empty array), or when
|
|
80
|
+
``bbox`` is malformed.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> import numpy as np
|
|
84
|
+
>>> from pointclouds import PointCloud
|
|
85
|
+
>>> cloud = PointCloud(
|
|
86
|
+
... x=np.arange(4.0), y=np.zeros(4), z=np.zeros(4),
|
|
87
|
+
... classification=np.array([2, 5, 2, 6], dtype=np.uint8),
|
|
88
|
+
... )
|
|
89
|
+
>>> build_mask(cloud, classification=2).tolist()
|
|
90
|
+
[True, False, True, False]
|
|
91
|
+
>>> build_mask(cloud, classification=[2, 6], bbox=(1.0, -1.0, 3.0, 1.0)).tolist()
|
|
92
|
+
[False, False, True, True]
|
|
93
|
+
"""
|
|
94
|
+
n = cloud.x.shape[0] # also forces a lazy cloud to load, so every array below is populated
|
|
95
|
+
mask = np.ones(n, dtype=bool)
|
|
96
|
+
|
|
97
|
+
for name, criterion in zip(_ATTRIBUTE_CRITERIA, (intensity, classification, return_number), strict=True):
|
|
98
|
+
if criterion is None:
|
|
99
|
+
continue
|
|
100
|
+
values = getattr(cloud, name)
|
|
101
|
+
if values.size == 0:
|
|
102
|
+
raise ValueError(f"cannot filter on {name!r}: this cloud carries no {name} data")
|
|
103
|
+
wanted = np.atleast_1d(np.asarray(criterion))
|
|
104
|
+
mask &= np.isin(values, wanted)
|
|
105
|
+
|
|
106
|
+
if bbox is not None:
|
|
107
|
+
mask &= bbox_mask(cloud.x, cloud.y, bbox)
|
|
108
|
+
|
|
109
|
+
return mask
|
pointclouds/ops/grid.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Grid a cloud to a raster surface (``to_grid``). (Phase 2 - T-11).
|
|
2
|
+
|
|
3
|
+
NumPy binning plus a SciPy KD-tree for the radius window, no GDAL. The aggregation names follow PDAL's
|
|
4
|
+
``writers.gdal`` vocabulary exactly - ``min``, ``max``, ``mean``, ``count``, ``stdev``, ``idw`` and ``p<n>`` -
|
|
5
|
+
so a reader arriving from PDAL meets the same words.
|
|
6
|
+
|
|
7
|
+
Decision - the default window is ``mode="bin"`` (the §6/§9 open question, settled here).
|
|
8
|
+
PDAL defaults to a radius window with a ``binmode`` opt-in; this package does the opposite, because a DSM/DTM
|
|
9
|
+
caller almost always means "the points that fall in this pixel", and digital-rivers' bare-earth path only
|
|
10
|
+
ever uses bin mode. The choice is made explicit in the signature rather than implied: pass ``mode="radius"``
|
|
11
|
+
for PDAL's behaviour, where every point within ``radius`` of a cell centre contributes.
|
|
12
|
+
|
|
13
|
+
Cost: bin mode is a single vectorised pass over the points. Radius mode issues one KD-tree ball query per cell,
|
|
14
|
+
so its cost grows with the number of cells - prefer bin mode for a large grid unless the radius window is needed.
|
|
15
|
+
|
|
16
|
+
The result is ``(array, transform, crs)``: a plain NumPy array, a GDAL-style affine 6-tuple
|
|
17
|
+
``(x_origin, cell_size, 0, y_origin, 0, -cell_size)``, and the cloud's :class:`pyproj.CRS` (or ``None``) - never a
|
|
18
|
+
GDAL object. Turning that into a pyramids ``Dataset`` is the separate ``[gis]`` step in
|
|
19
|
+
:mod:`pointclouds.convert` (T-12).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import re
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from scipy.spatial import cKDTree
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from pyproj import CRS
|
|
33
|
+
|
|
34
|
+
from pointclouds.points import PointCloud
|
|
35
|
+
|
|
36
|
+
__all__ = ["to_grid"]
|
|
37
|
+
|
|
38
|
+
#: Matches a percentile aggregate such as ``p95`` or ``p99.5`` and captures the percentile.
|
|
39
|
+
_PERCENTILE = re.compile(r"^p(\d{1,3}(?:\.\d+)?)$")
|
|
40
|
+
|
|
41
|
+
#: Aggregates that need no per-point distance, so the plain reducers serve them.
|
|
42
|
+
_SIMPLE = frozenset({"min", "max", "mean", "count", "stdev"})
|
|
43
|
+
|
|
44
|
+
#: Floor on the point-to-centre distance used by ``idw``, so a point sitting on a cell centre does not blow up.
|
|
45
|
+
_IDW_EPS = 1e-12
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_grid(
|
|
49
|
+
cloud: PointCloud,
|
|
50
|
+
*,
|
|
51
|
+
cell_size: float,
|
|
52
|
+
aggregate: str = "mean",
|
|
53
|
+
mode: str = "bin",
|
|
54
|
+
radius: float | None = None,
|
|
55
|
+
bounds: tuple[float, float, float, float] | None = None,
|
|
56
|
+
nodata: float = np.nan,
|
|
57
|
+
) -> tuple[np.ndarray, tuple[float, float, float, float, float, float], CRS | None]:
|
|
58
|
+
"""Bin or interpolate the cloud onto a regular grid.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
cloud: the cloud to grid; its records are loaded if they are not in memory yet.
|
|
62
|
+
cell_size: the pixel edge length in CRS units; must be positive.
|
|
63
|
+
aggregate: how the z-values in (or near) a cell are reduced - one of ``min``, ``max``, ``mean``,
|
|
64
|
+
``count``, ``stdev``, ``idw``, or a percentile ``p<n>`` such as ``p95`` (PDAL ``writers.gdal`` names).
|
|
65
|
+
mode: ``"bin"`` (the default) counts only points inside the pixel; ``"radius"`` counts every point within
|
|
66
|
+
``radius`` of the pixel centre.
|
|
67
|
+
radius: the search radius for ``mode="radius"``; defaults to ``cell_size * sqrt(2)`` (PDAL's default)
|
|
68
|
+
when ``None``. Ignored in bin mode.
|
|
69
|
+
bounds: ``(xmin, ymin, xmax, ymax)`` extent to grid; defaults to the cloud's own bounds. When its width
|
|
70
|
+
or height is not a whole multiple of ``cell_size``, the grid is rounded up to whole cells and so
|
|
71
|
+
extends past ``bounds``; points within that physical extent are kept, not dropped.
|
|
72
|
+
nodata: the value written to empty cells. The default ``NaN`` forces a float grid. ``count`` is the one
|
|
73
|
+
exception - an empty cell counts ``0``, not ``nodata``.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
``(array, transform, crs)``: an ``(nrows, ncols)`` float64 array (row 0 is the northernmost), a GDAL-style
|
|
77
|
+
affine 6-tuple ``(xmin, cell_size, 0, ymax, 0, -cell_size)``, and the cloud's CRS.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: when ``cell_size`` is not positive; when the extent has a non-positive area - either a
|
|
81
|
+
``bounds`` with ``xmax <= xmin`` or ``ymax <= ymin``, or the default extent of a flat cloud whose
|
|
82
|
+
points share a single x or a single y; when ``mode`` is neither ``"bin"`` nor ``"radius"``; or when
|
|
83
|
+
``aggregate`` is not a recognised name.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
>>> import numpy as np
|
|
87
|
+
>>> from pointclouds import PointCloud
|
|
88
|
+
>>> cloud = PointCloud(x=np.array([0.5, 1.5]), y=np.array([0.5, 0.5]), z=np.array([10.0, 20.0]))
|
|
89
|
+
>>> array, transform, _ = cloud.to_grid(cell_size=1.0, aggregate="min", bounds=(0, 0, 2, 1))
|
|
90
|
+
>>> array.tolist()
|
|
91
|
+
[[10.0, 20.0]]
|
|
92
|
+
>>> transform
|
|
93
|
+
(0.0, 1.0, 0.0, 1.0, 0.0, -1.0)
|
|
94
|
+
"""
|
|
95
|
+
if cell_size <= 0:
|
|
96
|
+
raise ValueError(f"cell_size must be positive; got {cell_size}")
|
|
97
|
+
if mode not in ("bin", "radius"):
|
|
98
|
+
raise ValueError(f"unknown mode {mode!r}; expected 'bin' or 'radius'")
|
|
99
|
+
reducer = _reducer(aggregate) # validate the aggregate before any heavy work
|
|
100
|
+
|
|
101
|
+
xmin, ymin, xmax, ymax = cloud.bounds if bounds is None else bounds
|
|
102
|
+
if xmax <= xmin or ymax <= ymin:
|
|
103
|
+
raise ValueError(f"to_grid needs a positive-area bounds; got {(xmin, ymin, xmax, ymax)}")
|
|
104
|
+
ncols = math.ceil((xmax - xmin) / cell_size)
|
|
105
|
+
nrows = math.ceil((ymax - ymin) / cell_size)
|
|
106
|
+
|
|
107
|
+
x, y, z = cloud.x, cloud.y, cloud.z
|
|
108
|
+
if mode == "bin":
|
|
109
|
+
cell, values, distance = _bin_contributions(x, y, z, xmin, ymax, cell_size, ncols, nrows)
|
|
110
|
+
else:
|
|
111
|
+
cell, values, distance = _radius_contributions(
|
|
112
|
+
x, y, z, xmin, ymax, cell_size, ncols, nrows, radius if radius is not None else cell_size * math.sqrt(2)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
flat = reducer(cell, values, distance, ncols * nrows, nodata)
|
|
116
|
+
transform = (float(xmin), float(cell_size), 0.0, float(ymax), 0.0, -float(cell_size))
|
|
117
|
+
return flat.reshape(nrows, ncols), transform, cloud.crs
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bin_contributions(x, y, z, xmin, ymax, cell_size, ncols, nrows):
|
|
121
|
+
"""Assign each point to the one cell it falls in, dropping points outside the grid.
|
|
122
|
+
|
|
123
|
+
The grid is anchored at the top-left ``(xmin, ymax)`` and spans whole cells, so its physical extent reaches
|
|
124
|
+
``xmin + ncols*cell_size`` / ``ymax - nrows*cell_size`` - past a ``bounds`` that is not a whole multiple of
|
|
125
|
+
``cell_size``. Membership is that physical box, and the cell index is then clamped into the grid, so a point
|
|
126
|
+
on any edge - the ceil-rounded overhang as well as the max-x / min-y edge of the default extent (where
|
|
127
|
+
``bounds`` is the data's own min/max) - folds into the cell that visually covers it (the closed-last-edge rule
|
|
128
|
+
``np.histogram2d`` uses) instead of being discarded.
|
|
129
|
+
|
|
130
|
+
Returns the flat cell index, the z-value and the distance to that cell's centre, one entry per kept point.
|
|
131
|
+
"""
|
|
132
|
+
x_max = xmin + ncols * cell_size
|
|
133
|
+
y_min = ymax - nrows * cell_size
|
|
134
|
+
inside = (x >= xmin) & (x <= x_max) & (y >= y_min) & (y <= ymax)
|
|
135
|
+
xi, yi, zi = x[inside], y[inside], z[inside]
|
|
136
|
+
col = np.clip(np.floor((xi - xmin) / cell_size).astype(np.int64), 0, ncols - 1)
|
|
137
|
+
row = np.clip(np.floor((ymax - yi) / cell_size).astype(np.int64), 0, nrows - 1)
|
|
138
|
+
|
|
139
|
+
centre_x = xmin + (col + 0.5) * cell_size
|
|
140
|
+
centre_y = ymax - (row + 0.5) * cell_size
|
|
141
|
+
distance = np.hypot(xi - centre_x, yi - centre_y)
|
|
142
|
+
return row * ncols + col, zi, distance
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _radius_contributions(x, y, z, xmin, ymax, cell_size, ncols, nrows, radius):
|
|
146
|
+
"""Let every point within ``radius`` of a cell centre contribute to that cell.
|
|
147
|
+
|
|
148
|
+
A point can land in several cells. Returns the flat cell index, the z-value and the point-to-centre distance,
|
|
149
|
+
one entry per (cell, point) pair.
|
|
150
|
+
"""
|
|
151
|
+
ncells = ncols * nrows
|
|
152
|
+
rows, cols = np.divmod(np.arange(ncells), ncols)
|
|
153
|
+
centre_x = xmin + (cols + 0.5) * cell_size
|
|
154
|
+
centre_y = ymax - (rows + 0.5) * cell_size
|
|
155
|
+
|
|
156
|
+
tree = cKDTree(np.column_stack([x, y]))
|
|
157
|
+
neighbours = tree.query_ball_point(np.column_stack([centre_x, centre_y]), r=radius)
|
|
158
|
+
|
|
159
|
+
counts = np.fromiter((len(n) for n in neighbours), dtype=np.int64, count=ncells)
|
|
160
|
+
cell = np.repeat(np.arange(ncells), counts)
|
|
161
|
+
points = np.fromiter((p for n in neighbours for p in n), dtype=np.int64, count=int(counts.sum()))
|
|
162
|
+
distance = np.hypot(x[points] - centre_x[cell], y[points] - centre_y[cell])
|
|
163
|
+
return cell, z[points], distance
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _reducer(aggregate: str):
|
|
167
|
+
"""Return the reducer for an aggregate name, or raise when the name is not recognised."""
|
|
168
|
+
if aggregate in _SIMPLE:
|
|
169
|
+
return lambda cell, values, distance, ncells, nodata: _reduce_simple(aggregate, cell, values, ncells, nodata)
|
|
170
|
+
if aggregate == "idw":
|
|
171
|
+
return _reduce_idw
|
|
172
|
+
match = _PERCENTILE.match(aggregate)
|
|
173
|
+
if match:
|
|
174
|
+
percentile = float(match.group(1))
|
|
175
|
+
if not 0.0 <= percentile <= 100.0:
|
|
176
|
+
raise ValueError(f"percentile out of range in {aggregate!r}; expected p0..p100")
|
|
177
|
+
return lambda cell, values, distance, ncells, nodata: _reduce_percentile(
|
|
178
|
+
percentile, cell, values, ncells, nodata
|
|
179
|
+
)
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"unknown aggregate {aggregate!r}; expected one of min, max, mean, count, stdev, idw, or a percentile like p95"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _reduce_simple(aggregate: str, cell: np.ndarray, values: np.ndarray, ncells: int, nodata: float) -> np.ndarray:
|
|
186
|
+
"""Reduce with one of the count-based aggregates (``count``/``mean``/``min``/``max``/``stdev``)."""
|
|
187
|
+
count = np.bincount(cell, minlength=ncells)
|
|
188
|
+
if aggregate == "count":
|
|
189
|
+
return count.astype(np.float64) # an empty cell honestly holds zero points, not nodata
|
|
190
|
+
|
|
191
|
+
grid = np.full(ncells, float(nodata), dtype=np.float64)
|
|
192
|
+
occupied = count > 0
|
|
193
|
+
if aggregate in ("mean", "stdev"):
|
|
194
|
+
total = np.bincount(cell, weights=values, minlength=ncells)
|
|
195
|
+
mean = np.zeros(ncells)
|
|
196
|
+
mean[occupied] = total[occupied] / count[occupied]
|
|
197
|
+
if aggregate == "mean":
|
|
198
|
+
grid[occupied] = mean[occupied]
|
|
199
|
+
else:
|
|
200
|
+
# Two-pass variance about the per-cell mean. Stable where the one-pass sum-of-squares minus
|
|
201
|
+
# mean-squared catastrophically cancels for large-magnitude z (e.g. projected elevations near 1e9),
|
|
202
|
+
# and non-negative by construction, so no clip-to-zero is needed.
|
|
203
|
+
deviation = values - mean[cell]
|
|
204
|
+
sum_sq = np.bincount(cell, weights=deviation * deviation, minlength=ncells)
|
|
205
|
+
grid[occupied] = np.sqrt(sum_sq[occupied] / count[occupied])
|
|
206
|
+
elif aggregate == "min":
|
|
207
|
+
reduced = np.full(ncells, np.inf)
|
|
208
|
+
np.minimum.at(reduced, cell, values)
|
|
209
|
+
grid[occupied] = reduced[occupied]
|
|
210
|
+
else: # max
|
|
211
|
+
reduced = np.full(ncells, -np.inf)
|
|
212
|
+
np.maximum.at(reduced, cell, values)
|
|
213
|
+
grid[occupied] = reduced[occupied]
|
|
214
|
+
return grid
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reduce_idw(cell: np.ndarray, values: np.ndarray, distance: np.ndarray, ncells: int, nodata: float) -> np.ndarray:
|
|
218
|
+
"""Reduce by inverse-distance weighting, each contribution weighted by ``1 / distance`` to the cell centre."""
|
|
219
|
+
grid = np.full(ncells, float(nodata), dtype=np.float64)
|
|
220
|
+
weight = 1.0 / np.maximum(distance, _IDW_EPS)
|
|
221
|
+
weight_sum = np.bincount(cell, weights=weight, minlength=ncells)
|
|
222
|
+
weighted = np.bincount(cell, weights=weight * values, minlength=ncells)
|
|
223
|
+
occupied = weight_sum > 0
|
|
224
|
+
grid[occupied] = weighted[occupied] / weight_sum[occupied]
|
|
225
|
+
return grid
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _reduce_percentile(
|
|
229
|
+
percentile: float, cell: np.ndarray, values: np.ndarray, ncells: int, nodata: float
|
|
230
|
+
) -> np.ndarray:
|
|
231
|
+
"""Reduce to a per-cell percentile, looping over occupied cells only."""
|
|
232
|
+
grid = np.full(ncells, float(nodata), dtype=np.float64)
|
|
233
|
+
if cell.size == 0:
|
|
234
|
+
return grid
|
|
235
|
+
order = np.argsort(cell, kind="stable")
|
|
236
|
+
sorted_cells, sorted_values = cell[order], values[order]
|
|
237
|
+
occupied, starts = np.unique(sorted_cells, return_index=True)
|
|
238
|
+
bounds = np.append(starts, sorted_cells.size)
|
|
239
|
+
for k, index in enumerate(occupied):
|
|
240
|
+
grid[index] = np.percentile(sorted_values[bounds[k] : bounds[k + 1]], percentile)
|
|
241
|
+
return grid
|