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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""pointclouds - point cloud (LAS/LAZ/COPC/EPT) I/O, filtering, decimation and gridding.
|
|
2
|
+
|
|
3
|
+
The public entry point is :class:`~pointclouds.points.PointCloud`. The GIS converters in
|
|
4
|
+
:mod:`pointclouds.convert` require the optional ``[gis]`` extra; importing the package needs the core
|
|
5
|
+
dependency set only (numpy / laspy / pyproj / scipy) and never GDAL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version as _version
|
|
12
|
+
|
|
13
|
+
from pointclouds.points import PointCloud
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = _version("pointclouds")
|
|
17
|
+
except PackageNotFoundError: # pragma: no cover
|
|
18
|
+
__version__ = "unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = ["PointCloud", "__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Classification engine: ground extraction, tree detection."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ground-point classification (bare-earth extraction). (Phase 4 - migrated from digital-rivers)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Individual tree-top detection on a canopy height model. (Phase 4 - migrated from digital-rivers)."""
|
pointclouds/convert.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The pyramids boundary: importing pyramids happens here and nowhere else.
|
|
2
|
+
|
|
3
|
+
This is the ONLY module in the package permitted to import ``pyramids``, and it does so lazily inside
|
|
4
|
+
:func:`require_pyramids` behind a guarded import. Importing this module with the core dependency set alone must
|
|
5
|
+
succeed; only *calling into* the GIS stack requires the ``[gis]`` extra.
|
|
6
|
+
|
|
7
|
+
The converters themselves - ``to_dataset`` (grid a cloud to a pyramids ``Dataset``) and ``to_feature_collection``
|
|
8
|
+
(a decimated cloud to a point ``FeatureCollection``) - land in Phase 2 (plan task T-10), on top of the core
|
|
9
|
+
``ops.grid.to_grid``. They are deliberately absent here rather than shipped as raising stubs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["require_pyramids"]
|
|
17
|
+
|
|
18
|
+
_GIS_HINT = "This needs the optional GIS stack (pyramids-gis). Install it with: pip install pointclouds[gis]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_pyramids() -> tuple[Any, Any]:
|
|
22
|
+
"""Return ``(Dataset, GeoReference)`` from pyramids, or raise a hint naming the extra.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The ``pyramids.dataset.Dataset`` and ``GeoReference`` classes.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ModuleNotFoundError: when pyramids is not installed, with a message that names
|
|
29
|
+
``pip install pointclouds[gis]`` rather than surfacing the bare import error.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
from pyramids.dataset import Dataset, GeoReference
|
|
33
|
+
except ModuleNotFoundError as exc: # pragma: no cover - environment-specific
|
|
34
|
+
raise ModuleNotFoundError(_GIS_HINT) from exc
|
|
35
|
+
return Dataset, GeoReference
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LAS/LAZ and COPC/EPT readers and writers."""
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The record-array contract shared by every reader in :mod:`pointclouds.io`.
|
|
2
|
+
|
|
3
|
+
Every engine - the whole-file one in :mod:`pointclouds.io.las` and the windowed ones in
|
|
4
|
+
:mod:`pointclouds.io.copc` (COPC) and :mod:`pointclouds.io.ept` (EPT) - hands
|
|
5
|
+
:class:`~pointclouds.points.PointCloud` the same thing: a mapping of lower-case dimension name to array, with
|
|
6
|
+
one entry per requested dimension and no missing keys. That contract lives here rather than in any engine, so
|
|
7
|
+
they cannot drift: a dimension added below is readable through every path, and a window read maps onto
|
|
8
|
+
``PointCloud`` exactly as a full read does. The exact-box clip the two windowed engines share lives here too,
|
|
9
|
+
for the same reason.
|
|
10
|
+
|
|
11
|
+
Private to the package. Callers use the engines, not this module.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
__all__ = ["CANONICAL", "clip_to_box", "declared_names", "extract", "resolve"]
|
|
21
|
+
|
|
22
|
+
#: Record dimensions the engines map onto :class:`~pointclouds.points.PointCloud` fields, with the dtype each
|
|
23
|
+
#: array is cast to. Keys are lower-case; callers may pass any case (``"X"`` and ``"x"`` are the same dimension).
|
|
24
|
+
CANONICAL: dict[str, type] = {
|
|
25
|
+
"x": np.float64,
|
|
26
|
+
"y": np.float64,
|
|
27
|
+
"z": np.float64,
|
|
28
|
+
"intensity": np.uint16,
|
|
29
|
+
"classification": np.uint8,
|
|
30
|
+
"return_number": np.uint8,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#: The coordinate dimensions. laspy always exposes these as scaled doubles, whatever the point format declares,
|
|
34
|
+
#: so they are never treated as absent.
|
|
35
|
+
_COORDINATES = ("x", "y", "z")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _RecordSource(Protocol):
|
|
39
|
+
"""What :func:`extract` needs of a record source.
|
|
40
|
+
|
|
41
|
+
Both ``laspy.LasData`` (a whole-file read) and ``laspy.ScaleAwarePointRecord`` (a COPC query result) satisfy
|
|
42
|
+
it: a ``point_format`` that can list its dimension names, plus attribute access per dimension.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
point_format: Any
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve(dimensions: tuple[str, ...] | None) -> tuple[str, ...]:
|
|
49
|
+
"""Normalise a caller's dimension list to lower-case names this package reads.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
dimensions: dimension names, case-insensitive, or ``None`` for every canonical dimension.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The requested names, lower-cased, in the order given.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
KeyError: when a requested dimension is not one the engines know, listing what they do read.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
LAS spells dimensions inconsistently, so the request is normalised and the caller's order kept:
|
|
62
|
+
|
|
63
|
+
>>> resolve(("Z", "Intensity"))
|
|
64
|
+
('z', 'intensity')
|
|
65
|
+
|
|
66
|
+
``None`` is the "everything this package maps" request, which is what both engines pass by default:
|
|
67
|
+
|
|
68
|
+
>>> resolve(None)
|
|
69
|
+
('x', 'y', 'z', 'intensity', 'classification', 'return_number')
|
|
70
|
+
|
|
71
|
+
A dimension the package does not map is refused rather than quietly returned empty:
|
|
72
|
+
|
|
73
|
+
>>> resolve(("x", "gps_time"))
|
|
74
|
+
Traceback (most recent call last):
|
|
75
|
+
...
|
|
76
|
+
KeyError: "unknown dimension(s) ['gps_time']; this engine reads ['classification', 'intensity', \
|
|
77
|
+
'return_number', 'x', 'y', 'z']"
|
|
78
|
+
"""
|
|
79
|
+
if dimensions is None:
|
|
80
|
+
return tuple(CANONICAL)
|
|
81
|
+
# Lower-case and de-duplicate, preserving first-seen order, so ("Z", "z") - or an upstream forced x/y/z that
|
|
82
|
+
# overlaps the request - does not carry a repeated name into the record contract.
|
|
83
|
+
requested = tuple(dict.fromkeys(name.lower() for name in dimensions))
|
|
84
|
+
unknown = [name for name in requested if name not in CANONICAL]
|
|
85
|
+
if unknown:
|
|
86
|
+
raise KeyError(f"unknown dimension(s) {unknown}; this engine reads {sorted(CANONICAL)}")
|
|
87
|
+
return requested
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def declared_names(source: _RecordSource) -> set[str]:
|
|
91
|
+
"""Return the lower-case dimension names ``source``'s point format declares.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
source: a laspy record source (``LasData`` or ``ScaleAwarePointRecord``).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The declared dimension names, lower-cased.
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
Point format 6 declares the attributes this package reads, whatever a given file happens to populate:
|
|
101
|
+
|
|
102
|
+
>>> import laspy
|
|
103
|
+
>>> cloud = laspy.LasData(laspy.LasHeader(version="1.4", point_format=6))
|
|
104
|
+
>>> names = declared_names(cloud)
|
|
105
|
+
>>> {"x", "y", "z", "intensity", "classification", "return_number"} <= names
|
|
106
|
+
True
|
|
107
|
+
|
|
108
|
+
The names come back lower-cased, so membership tests work against the canonical keys:
|
|
109
|
+
|
|
110
|
+
>>> "X" in declared_names(cloud), "x" in declared_names(cloud)
|
|
111
|
+
(False, True)
|
|
112
|
+
"""
|
|
113
|
+
return {str(name).lower() for name in source.point_format.dimension_names}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract(source: _RecordSource, requested: tuple[str, ...]) -> dict[str, np.ndarray]:
|
|
117
|
+
"""Pull ``requested`` out of a laspy record source as typed arrays.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
source: a laspy record source (``LasData`` or ``ScaleAwarePointRecord``).
|
|
121
|
+
requested: lower-case dimension names, as returned by :func:`resolve`.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A mapping of dimension name to array. A dimension the source's point format does not declare comes back
|
|
125
|
+
as a zero-length array of the right dtype, never as a missing key - so every caller can index the result
|
|
126
|
+
by any requested name.
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
Only the requested dimensions come back in the mapping (laspy still decompresses the whole point; this
|
|
130
|
+
selects the result, not the read):
|
|
131
|
+
|
|
132
|
+
>>> import laspy
|
|
133
|
+
>>> import numpy as np
|
|
134
|
+
>>> cloud = laspy.LasData(laspy.LasHeader(version="1.4", point_format=6))
|
|
135
|
+
>>> cloud.x = np.array([10.0, 20.0])
|
|
136
|
+
>>> cloud.y = np.array([0.0, 5.0])
|
|
137
|
+
>>> cloud.z = np.array([1.0, 2.0])
|
|
138
|
+
>>> arrays = extract(cloud, ("x", "z"))
|
|
139
|
+
>>> sorted(arrays)
|
|
140
|
+
['x', 'z']
|
|
141
|
+
>>> arrays["z"].tolist()
|
|
142
|
+
[1.0, 2.0]
|
|
143
|
+
|
|
144
|
+
Each array is cast to its canonical dtype, so a caller never has to check what the file used:
|
|
145
|
+
|
|
146
|
+
>>> cloud = laspy.LasData(laspy.LasHeader(version="1.4", point_format=6))
|
|
147
|
+
>>> cloud.x = np.array([1.0])
|
|
148
|
+
>>> cloud.y = np.array([2.0])
|
|
149
|
+
>>> cloud.z = np.array([3.0])
|
|
150
|
+
>>> cloud.classification = np.array([5])
|
|
151
|
+
>>> arrays = extract(cloud, ("classification", "x"))
|
|
152
|
+
>>> arrays["classification"].dtype.name, arrays["x"].dtype.name
|
|
153
|
+
('uint8', 'float64')
|
|
154
|
+
>>> arrays["classification"].tolist()
|
|
155
|
+
[5]
|
|
156
|
+
"""
|
|
157
|
+
declared = declared_names(source)
|
|
158
|
+
arrays: dict[str, np.ndarray] = {}
|
|
159
|
+
for name in requested:
|
|
160
|
+
dtype = CANONICAL[name]
|
|
161
|
+
if name in _COORDINATES or name in declared:
|
|
162
|
+
# Always a copy, never a view. np.asarray would alias when the dtype already matches, and on the
|
|
163
|
+
# COPC path the source is one packed record buffer - a view would keep the whole buffer alive behind
|
|
164
|
+
# a single-column result and let a caller mutate the source through it. The two engines must agree,
|
|
165
|
+
# and the LAS path copies, so this one does too.
|
|
166
|
+
arrays[name] = np.array(getattr(source, name), dtype=dtype, copy=True)
|
|
167
|
+
else:
|
|
168
|
+
arrays[name] = np.empty(0, dtype=dtype)
|
|
169
|
+
return arrays
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def clip_to_box(arrays: dict[str, np.ndarray], bbox: tuple[float, float, float, float]) -> dict[str, np.ndarray]:
|
|
173
|
+
"""Drop points outside ``bbox`` exactly, to the float edge. Shared by the COPC and EPT windowed engines.
|
|
174
|
+
|
|
175
|
+
Both windowed engines fetch at chunk / node granularity, so their raw result over-includes: COPC because
|
|
176
|
+
laspy rounds the box edges to the file's coordinate scale, EPT because a node's whole data file is fetched
|
|
177
|
+
when its cube overlaps the box. This re-applies the box as an exact float mask, so the result matches the
|
|
178
|
+
requested box to the float edge. The arrays are already in memory, so the mask is cheap.
|
|
179
|
+
|
|
180
|
+
**Both edges are inclusive** (``xmin <= x <= xmax``), so a point exactly on a boundary is kept - which is
|
|
181
|
+
what a zero-width point-query box needs. The consequence for tiling: two windows that share an edge each
|
|
182
|
+
include the points on that shared edge, so a caller stitching adjacent tiles must make its own boxes
|
|
183
|
+
half-open (or de-duplicate) to avoid counting the boundary twice.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
arrays: a record mapping from :func:`extract`; must contain ``x`` and ``y``.
|
|
187
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the data's CRS.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The same mapping with every array masked to the points inside the box. A zero-length (undeclared)
|
|
191
|
+
dimension is left untouched rather than indexed with a full-length mask.
|
|
192
|
+
"""
|
|
193
|
+
xmin, ymin, xmax, ymax = bbox
|
|
194
|
+
x, y = arrays["x"], arrays["y"]
|
|
195
|
+
keep = (x >= xmin) & (x <= xmax) & (y >= ymin) & (y <= ymax)
|
|
196
|
+
return {name: (values[keep] if values.size == keep.size else values) for name, values in arrays.items()}
|