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/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."""
@@ -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
@@ -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