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/points.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""The :class:`PointCloud` container.
|
|
2
|
+
|
|
3
|
+
A point cloud is held as parallel 1-D arrays - index ``i`` selects the i-th point across every field - so a filter
|
|
4
|
+
or decimation stays aligned across coordinates and attributes. This module is the nucleus of the package and must
|
|
5
|
+
import with the core dependency set alone (numpy / laspy / pyproj); it must never import GDAL or pyramids.
|
|
6
|
+
|
|
7
|
+
Reading is **header-first** (T-8). :meth:`PointCloud.read_file` opens a file and, by default, reads only its
|
|
8
|
+
header: ``crs``, ``bounds``, ``count`` and ``dimensions`` answer immediately, at a cost independent of the file's
|
|
9
|
+
size. The record arrays decompress on first access to ``x`` / ``y`` / ``z`` or to any attribute array, or eagerly
|
|
10
|
+
via :meth:`PointCloud.load`. The distinction that matters, and the reason ``count`` is not an alias for ``len``:
|
|
11
|
+
|
|
12
|
+
- ``cloud.count`` is the header's point count - free, no records read.
|
|
13
|
+
- ``len(cloud)`` is how many points are **in memory**, so on a lazy cloud it forces the read.
|
|
14
|
+
|
|
15
|
+
Reading can also be **windowed** (T-16). :meth:`PointCloud.read_window` - and ``read_file(bbox=, resolution=)``,
|
|
16
|
+
which delegates to it - asks a COPC file's octree for only the points inside a bounding box, or only a coarse
|
|
17
|
+
enough level of detail, instead of decompressing the whole cloud and masking it afterwards. A windowed cloud is
|
|
18
|
+
eager and carries no header, so its ``count`` and ``bounds`` describe the window rather than the file.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Iterable, Sequence
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from pyproj import CRS, Transformer
|
|
31
|
+
from pyproj.transformer import TransformerGroup
|
|
32
|
+
|
|
33
|
+
from pointclouds.io import copc as _copc
|
|
34
|
+
from pointclouds.io import ept as _ept
|
|
35
|
+
from pointclouds.io import las as _las
|
|
36
|
+
from pointclouds.ops import clip as _clip
|
|
37
|
+
from pointclouds.ops import decimate as _decimate
|
|
38
|
+
from pointclouds.ops import filter as _filter
|
|
39
|
+
from pointclouds.ops import grid as _grid
|
|
40
|
+
from pointclouds.ops import merge as _merge
|
|
41
|
+
|
|
42
|
+
__all__ = ["PointCloud"]
|
|
43
|
+
|
|
44
|
+
#: Attribute arrays, in the order :meth:`PointCloud.dimensions` reports them, with the dtype each is held as.
|
|
45
|
+
_ATTRIBUTE_DTYPES: dict[str, type] = {
|
|
46
|
+
"intensity": np.uint16,
|
|
47
|
+
"classification": np.uint8,
|
|
48
|
+
"return_number": np.uint8,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PointCloud:
|
|
53
|
+
"""In-memory point cloud: parallel per-dimension arrays plus a CRS.
|
|
54
|
+
|
|
55
|
+
The constructor builds an eager cloud from arrays already in hand. To read a file, use
|
|
56
|
+
:meth:`read_file`, which is header-first and loads records only when they are asked for, or
|
|
57
|
+
:meth:`read_window` to pull back only part of a COPC cloud.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
x: ``(N,)`` float64 x-coordinates.
|
|
61
|
+
y: ``(N,)`` float64 y-coordinates.
|
|
62
|
+
z: ``(N,)`` float64 z-coordinates.
|
|
63
|
+
intensity: ``(N,)`` uint16 return intensity, or empty when the file/caller carried none.
|
|
64
|
+
classification: ``(N,)`` uint8 ASPRS class codes, or empty.
|
|
65
|
+
return_number: ``(N,)`` uint8 return-number-within-pulse, or empty.
|
|
66
|
+
crs: a :class:`pyproj.CRS`, or ``None`` when no SRS is known.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
>>> import numpy as np
|
|
70
|
+
>>> cloud = PointCloud(x=np.arange(3.0), y=np.zeros(3), z=np.array([1.0, 2.0, 3.0]))
|
|
71
|
+
>>> len(cloud), cloud.count
|
|
72
|
+
(3, 3)
|
|
73
|
+
>>> cloud.to_array(("X", "Z")).shape
|
|
74
|
+
(3, 2)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
x: np.ndarray,
|
|
80
|
+
y: np.ndarray,
|
|
81
|
+
z: np.ndarray,
|
|
82
|
+
intensity: np.ndarray | None = None,
|
|
83
|
+
classification: np.ndarray | None = None,
|
|
84
|
+
return_number: np.ndarray | None = None,
|
|
85
|
+
crs: CRS | str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Build a cloud from arrays, rejecting coordinate arrays that disagree in length.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
x: ``(N,)`` x-coordinates.
|
|
91
|
+
y: ``(N,)`` y-coordinates.
|
|
92
|
+
z: ``(N,)`` z-coordinates.
|
|
93
|
+
intensity: ``(N,)`` return intensity, or ``None`` for an empty array.
|
|
94
|
+
classification: ``(N,)`` ASPRS class codes, or ``None`` for an empty array.
|
|
95
|
+
return_number: ``(N,)`` return numbers, or ``None`` for an empty array.
|
|
96
|
+
crs: anything :class:`pyproj.CRS` accepts, or ``None``.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: if ``x``, ``y`` and ``z`` do not all share one length, or if an attribute array is
|
|
100
|
+
neither empty (absent) nor exactly one value per point.
|
|
101
|
+
"""
|
|
102
|
+
self._x = np.asarray(x, dtype=np.float64)
|
|
103
|
+
self._y = np.asarray(y, dtype=np.float64)
|
|
104
|
+
self._z = np.asarray(z, dtype=np.float64)
|
|
105
|
+
self._attributes = {
|
|
106
|
+
"intensity": _as_attribute(intensity, np.uint16),
|
|
107
|
+
"classification": _as_attribute(classification, np.uint8),
|
|
108
|
+
"return_number": _as_attribute(return_number, np.uint8),
|
|
109
|
+
}
|
|
110
|
+
self._crs = CRS.from_user_input(crs) if isinstance(crs, str) else crs
|
|
111
|
+
self._header: _las.LasHeaderInfo | None = None
|
|
112
|
+
self._source: Path | None = None
|
|
113
|
+
self._loaded = True
|
|
114
|
+
|
|
115
|
+
n = len(self._x)
|
|
116
|
+
if not (len(self._y) == n and len(self._z) == n):
|
|
117
|
+
raise ValueError(f"x/y/z must share a length; got {len(self._x)}, {len(self._y)}, {len(self._z)}")
|
|
118
|
+
for name, values in self._attributes.items():
|
|
119
|
+
if values.size and values.size != n:
|
|
120
|
+
raise ValueError(f"{name} has length {values.size}, expected 0 or {n}")
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def read_file(
|
|
124
|
+
cls,
|
|
125
|
+
path: str | Path,
|
|
126
|
+
*,
|
|
127
|
+
lazy: bool | None = None,
|
|
128
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
129
|
+
resolution: float | None = None,
|
|
130
|
+
level: int | range | None = None,
|
|
131
|
+
) -> PointCloud:
|
|
132
|
+
"""Open a LAS/LAZ file, or a window onto a COPC one.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path: the file to read. A path only - to read a window from an open file object (an ``fsspec``
|
|
136
|
+
handle), call :meth:`read_window` directly; the LAS/LAZ engine behind the non-windowed read
|
|
137
|
+
takes a path.
|
|
138
|
+
lazy: when left unset or ``True`` only the header is read and the record arrays decompress on first
|
|
139
|
+
access; ``False`` reads the records immediately. It has no meaning for a windowed read, where
|
|
140
|
+
the window *is* the read, so passing it together with ``bbox`` / ``resolution`` / ``level`` is
|
|
141
|
+
refused rather than silently ignored.
|
|
142
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the file's CRS. Passing it makes this a windowed read, which
|
|
143
|
+
requires a COPC file - see :meth:`read_window`.
|
|
144
|
+
resolution: target point spacing; makes this a windowed read. Mutually exclusive with ``level``.
|
|
145
|
+
level: an exact octree level or a ``range`` of them; makes this a windowed read. Mutually exclusive
|
|
146
|
+
with ``resolution``.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A cloud carrying the file's header metadata, and - unless ``lazy`` is ``False`` - its points. With a
|
|
150
|
+
window, whatever :meth:`read_window` returns.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
FileNotFoundError: when the path does not exist.
|
|
154
|
+
ValueError: when the suffix is neither ``.las`` nor ``.laz``; when ``lazy`` is passed together with a
|
|
155
|
+
window argument; or, for a windowed read, anything :meth:`read_window` rejects.
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
Without a window the read is header-first: the metadata costs nothing and the records wait:
|
|
159
|
+
|
|
160
|
+
>>> cloud = PointCloud.read_file("examples/data/sample.laz") # doctest: +SKIP
|
|
161
|
+
>>> cloud.count, cloud.crs is not None # header only - no records touched # doctest: +SKIP
|
|
162
|
+
(400, True)
|
|
163
|
+
>>> cloud.loaded # doctest: +SKIP
|
|
164
|
+
False
|
|
165
|
+
|
|
166
|
+
Passing a window makes it a COPC read instead, and the cloud comes back already populated:
|
|
167
|
+
|
|
168
|
+
>>> window = PointCloud.read_file( # doctest: +SKIP
|
|
169
|
+
... "examples/data/sample.copc.laz", bbox=(155000, 463000, 155020, 463020)
|
|
170
|
+
... )
|
|
171
|
+
>>> window.count, window.loaded # doctest: +SKIP
|
|
172
|
+
(121, True)
|
|
173
|
+
"""
|
|
174
|
+
if bbox is not None or resolution is not None or level is not None:
|
|
175
|
+
if lazy is not None:
|
|
176
|
+
raise ValueError("lazy has no meaning with a window; a windowed read is always eager")
|
|
177
|
+
return cls.read_window(path, bbox=bbox, resolution=resolution, level=level)
|
|
178
|
+
header = _las.read_header(path)
|
|
179
|
+
cloud = cls.__new__(cls) # bypass __init__: a lazy cloud has a header but no arrays yet
|
|
180
|
+
cloud._x = _EMPTY_F8
|
|
181
|
+
cloud._y = _EMPTY_F8
|
|
182
|
+
cloud._z = _EMPTY_F8
|
|
183
|
+
cloud._attributes = {name: np.empty(0, dtype=dtype) for name, dtype in _ATTRIBUTE_DTYPES.items()}
|
|
184
|
+
cloud._crs = header.crs
|
|
185
|
+
cloud._header = header
|
|
186
|
+
cloud._source = Path(path)
|
|
187
|
+
cloud._loaded = False
|
|
188
|
+
# lazy is None when unset, which means lazy; only an explicit lazy=False forces an eager load.
|
|
189
|
+
return cloud.load() if lazy is False else cloud
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def read_window(
|
|
193
|
+
cls,
|
|
194
|
+
source: _copc.Source | _ept.Source,
|
|
195
|
+
*,
|
|
196
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
197
|
+
resolution: float | None = None,
|
|
198
|
+
level: int | range | None = None,
|
|
199
|
+
dimensions: tuple[str, ...] | None = None,
|
|
200
|
+
) -> PointCloud:
|
|
201
|
+
"""Read only part of a cloud-optimised source - a COPC file or an EPT dataset - using its octree.
|
|
202
|
+
|
|
203
|
+
This is the path a large cloud is meant to be read through: ``bbox`` prunes the octree to the nodes
|
|
204
|
+
whose cube overlaps the box, and ``resolution`` stops the descent at the coarsest level that still
|
|
205
|
+
satisfies it. Reading the whole thing and masking it afterwards costs the full decode; this does not.
|
|
206
|
+
|
|
207
|
+
The source is dispatched by shape: an EPT dataset (a directory or URL holding an ``ept.json``) goes to
|
|
208
|
+
:func:`pointclouds.io.ept.read_window`, anything else to :func:`pointclouds.io.copc.read_window`.
|
|
209
|
+
|
|
210
|
+
The returned cloud is **eager and header-less**: :attr:`count` and :attr:`bounds` describe the points
|
|
211
|
+
actually read, not the file they came from. Use :func:`pointclouds.io.copc.read_header` /
|
|
212
|
+
:func:`pointclouds.io.ept.read_header` for the source's own count, bounds and resolution.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
source: a COPC file (path or open file object) or an EPT dataset (path or URL to the directory or
|
|
216
|
+
its ``ept.json``). A remote source goes through ``fsspec`` (the ``[cloud]`` extra).
|
|
217
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the source's CRS. 2-D: the full z range is kept.
|
|
218
|
+
resolution: target point spacing, measured against the octree's root spacing (COPC) or ``span``
|
|
219
|
+
(EPT). Mutually exclusive with ``level``.
|
|
220
|
+
level: the level-of-detail knob. For COPC, an exact octree level or a ``range``. For EPT, an ``int``
|
|
221
|
+
read as a maximum depth (``0`` = root), inclusive; a ``range`` is not accepted for EPT. Mutually
|
|
222
|
+
exclusive with ``resolution``.
|
|
223
|
+
dimensions: which attribute arrays to carry, case-insensitive. ``None`` reads every mapped
|
|
224
|
+
dimension; ``x`` / ``y`` / ``z`` are always read whatever is asked for, since a cloud needs its
|
|
225
|
+
coordinates. Selects what is returned, not what is decoded.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
A cloud holding only the selected points, with the source's CRS. A window that selects nothing gives
|
|
229
|
+
an empty cloud, whose :attr:`count` is ``0``.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
FileNotFoundError: when the path does not exist.
|
|
233
|
+
ValueError: when the source is malformed for its kind; when ``bbox`` is malformed; when
|
|
234
|
+
``resolution`` is not positive; when ``resolution`` and ``level`` are both given; or when
|
|
235
|
+
``level`` is a ``range`` against an EPT source.
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
A box reads a corner of the cloud, and the resulting cloud describes the window it holds:
|
|
239
|
+
|
|
240
|
+
>>> window = PointCloud.read_window( # doctest: +SKIP
|
|
241
|
+
... "examples/data/sample.copc.laz", bbox=(155000, 463000, 155020, 463020)
|
|
242
|
+
... )
|
|
243
|
+
>>> window.count # 121 of the file's 1600 points # doctest: +SKIP
|
|
244
|
+
121
|
|
245
|
+
|
|
246
|
+
An EPT dataset reads the same way, by directory:
|
|
247
|
+
|
|
248
|
+
>>> coarse = PointCloud.read_window("examples/data/sample-ept", resolution=50.0) # doctest: +SKIP
|
|
249
|
+
>>> coarse.count, coarse.crs.to_epsg() # doctest: +SKIP
|
|
250
|
+
(100, 28992)
|
|
251
|
+
"""
|
|
252
|
+
# x/y/z are forced into the request: a cloud is built from its coordinates, so a caller asking only for
|
|
253
|
+
# an attribute still needs them. The result is then splatted into the constructor, which is safe because
|
|
254
|
+
# the record contract's keys are exactly the constructor's point parameters (asserted in the tests).
|
|
255
|
+
requested = None if dimensions is None else tuple(dict.fromkeys(("x", "y", "z", *dimensions)))
|
|
256
|
+
# EPT sources are only ever a path or URL (a directory holding ept.json); a file object is always COPC.
|
|
257
|
+
if isinstance(source, (str, os.PathLike)) and _ept.is_ept(source):
|
|
258
|
+
crs, arrays = _ept._read_window_with_crs(
|
|
259
|
+
source, bbox=bbox, resolution=resolution, depth=_ept_depth(level), dimensions=requested
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
crs, arrays = _copc._read_window_with_crs(
|
|
263
|
+
source, bbox=bbox, resolution=resolution, level=level, dimensions=requested
|
|
264
|
+
)
|
|
265
|
+
return cls(crs=crs, **arrays)
|
|
266
|
+
|
|
267
|
+
def load(self) -> PointCloud:
|
|
268
|
+
"""Read this cloud's record arrays if they are not in memory yet.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
``self``, so a read can be chained (``PointCloud.read_file(p).load()``).
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
RuntimeError: when the cloud has no source file to load from, which cannot happen for a cloud built
|
|
275
|
+
by :meth:`read_file` and always holds for one built from arrays (already loaded).
|
|
276
|
+
"""
|
|
277
|
+
if self._loaded:
|
|
278
|
+
return self
|
|
279
|
+
if self._source is None: # pragma: no cover - unreachable via the public API
|
|
280
|
+
raise RuntimeError("cloud has no source file to load records from")
|
|
281
|
+
arrays = _las.read_points(self._source)
|
|
282
|
+
self._x = arrays["x"]
|
|
283
|
+
self._y = arrays["y"]
|
|
284
|
+
self._z = arrays["z"]
|
|
285
|
+
self._attributes = {name: arrays[name] for name in _ATTRIBUTE_DTYPES}
|
|
286
|
+
self._loaded = True
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def loaded(self) -> bool:
|
|
291
|
+
"""Whether the record arrays are in memory. Reading this never triggers a load."""
|
|
292
|
+
return self._loaded
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def source(self) -> Path | None:
|
|
296
|
+
"""The file this cloud was read from, or ``None`` when it was built from arrays."""
|
|
297
|
+
return self._source
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def x(self) -> np.ndarray:
|
|
301
|
+
"""``(N,)`` float64 x-coordinates, loading the records first if needed."""
|
|
302
|
+
return self.load()._x
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def y(self) -> np.ndarray:
|
|
306
|
+
"""``(N,)`` float64 y-coordinates, loading the records first if needed."""
|
|
307
|
+
return self.load()._y
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def z(self) -> np.ndarray:
|
|
311
|
+
"""``(N,)`` float64 z-coordinates, loading the records first if needed."""
|
|
312
|
+
return self.load()._z
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def intensity(self) -> np.ndarray:
|
|
316
|
+
"""``(N,)`` uint16 return intensity (empty when absent), loading the records first if needed."""
|
|
317
|
+
return self.load()._attributes["intensity"]
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def classification(self) -> np.ndarray:
|
|
321
|
+
"""``(N,)`` uint8 ASPRS class codes (empty when absent), loading the records first if needed."""
|
|
322
|
+
return self.load()._attributes["classification"]
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def return_number(self) -> np.ndarray:
|
|
326
|
+
"""``(N,)`` uint8 return numbers (empty when absent), loading the records first if needed."""
|
|
327
|
+
return self.load()._attributes["return_number"]
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def crs(self) -> CRS | None:
|
|
331
|
+
"""The cloud's CRS as a :class:`pyproj.CRS`, or ``None``. Answers from the header; never loads records."""
|
|
332
|
+
return self._crs
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def count(self) -> int:
|
|
336
|
+
"""How many points the cloud holds, from the header when there is one.
|
|
337
|
+
|
|
338
|
+
This is the cheap question: on a lazy cloud it answers without decompressing a single record, which is
|
|
339
|
+
what ``len(cloud)`` cannot do. The two agree once the records are in memory.
|
|
340
|
+
"""
|
|
341
|
+
if self._header is not None:
|
|
342
|
+
return int(self._header.count)
|
|
343
|
+
return int(self._x.shape[0])
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def bounds(self) -> tuple[float, float, float, float]:
|
|
347
|
+
"""``(xmin, ymin, xmax, ymax)``, from the header when there is one, else computed from the arrays.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
ValueError: when the cloud is empty and has no header to take bounds from.
|
|
351
|
+
"""
|
|
352
|
+
if self._header is not None:
|
|
353
|
+
return self._header.bounds
|
|
354
|
+
if self._x.size == 0:
|
|
355
|
+
raise ValueError("an empty cloud with no header has no bounds")
|
|
356
|
+
return (float(self._x.min()), float(self._y.min()), float(self._x.max()), float(self._y.max()))
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def dimensions(self) -> tuple[str, ...]:
|
|
360
|
+
"""The dimension names available on this cloud.
|
|
361
|
+
|
|
362
|
+
For a cloud read from a file this is what the point format declares, straight from the header. For one
|
|
363
|
+
built from arrays it is the coordinates plus whichever attribute arrays are non-empty.
|
|
364
|
+
"""
|
|
365
|
+
if self._header is not None:
|
|
366
|
+
return self._header.dimensions
|
|
367
|
+
present = [name for name, values in self._attributes.items() if values.size]
|
|
368
|
+
return ("X", "Y", "Z", *present)
|
|
369
|
+
|
|
370
|
+
def to_array(self, dimensions: tuple[str, ...] = ("X", "Y", "Z")) -> np.ndarray:
|
|
371
|
+
"""Stack the requested dimensions column-wise into an ``(N, k)`` array.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
dimensions: dimension names, case-insensitive (``"X"``/``"Y"``/``"Z"`` map to the coordinate arrays).
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
``(N, k)`` float64 array, one column per requested dimension.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
KeyError: when a requested dimension is not one this container holds.
|
|
381
|
+
ValueError: when a requested dimension is empty (the file/caller carried none of it) while others
|
|
382
|
+
are populated, which would otherwise surface as an opaque numpy broadcast error.
|
|
383
|
+
"""
|
|
384
|
+
self.load()
|
|
385
|
+
lookup = {"x": self._x, "y": self._y, "z": self._z, **self._attributes}
|
|
386
|
+
try:
|
|
387
|
+
cols = [np.asarray(lookup[name.lower()], dtype=float) for name in dimensions]
|
|
388
|
+
except KeyError as exc:
|
|
389
|
+
raise KeyError(f"unknown dimension {exc.args[0]!r}; this cloud holds {sorted(lookup)}") from None
|
|
390
|
+
width = len(self._x)
|
|
391
|
+
empty = [name for name, col in zip(dimensions, cols, strict=True) if col.size != width]
|
|
392
|
+
if empty:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"cannot stack dimension(s) {empty} - this cloud carries none of them ({width} points, but "
|
|
395
|
+
f"those arrays are empty); request only the dimensions it holds"
|
|
396
|
+
)
|
|
397
|
+
return np.column_stack(cols)
|
|
398
|
+
|
|
399
|
+
def filter(
|
|
400
|
+
self,
|
|
401
|
+
*,
|
|
402
|
+
classification: int | Iterable[int] | None = None,
|
|
403
|
+
return_number: int | Iterable[int] | None = None,
|
|
404
|
+
intensity: int | Iterable[int] | None = None,
|
|
405
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
406
|
+
) -> PointCloud:
|
|
407
|
+
"""Keep the points matching every given criterion, returning a new cloud.
|
|
408
|
+
|
|
409
|
+
Each attribute criterion is a membership test - a scalar keeps points equal to it, an iterable keeps
|
|
410
|
+
points whose value is in the set - and the criteria are ANDed together. Passing no criterion returns a
|
|
411
|
+
copy of the whole cloud.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
classification: an ASPRS class code, or an iterable of them, to keep (e.g. ``2`` for ground).
|
|
415
|
+
return_number: a return number, or an iterable of them, to keep.
|
|
416
|
+
intensity: an intensity value, or an iterable of them, to keep.
|
|
417
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` extent to keep, edges included.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
A new cloud holding only the matching points, every array subset in step. The original is untouched.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
ValueError: when a criterion names an attribute this cloud does not carry, or ``bbox`` is malformed.
|
|
424
|
+
|
|
425
|
+
Examples:
|
|
426
|
+
>>> import numpy as np
|
|
427
|
+
>>> cloud = PointCloud(
|
|
428
|
+
... x=np.arange(4.0), y=np.zeros(4), z=np.zeros(4),
|
|
429
|
+
... classification=np.array([2, 5, 2, 6], dtype=np.uint8),
|
|
430
|
+
... )
|
|
431
|
+
>>> len(cloud.filter(classification=2))
|
|
432
|
+
2
|
|
433
|
+
"""
|
|
434
|
+
mask = _filter.build_mask(
|
|
435
|
+
self, classification=classification, return_number=return_number, intensity=intensity, bbox=bbox
|
|
436
|
+
)
|
|
437
|
+
return self._take(mask)
|
|
438
|
+
|
|
439
|
+
def decimate(
|
|
440
|
+
self,
|
|
441
|
+
*,
|
|
442
|
+
target: int | None = None,
|
|
443
|
+
fraction: float | None = None,
|
|
444
|
+
method: str = "random",
|
|
445
|
+
voxel_size: float | None = None,
|
|
446
|
+
seed: int | None = None,
|
|
447
|
+
) -> PointCloud:
|
|
448
|
+
"""Thin the cloud, returning a new cloud with fewer points.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
target: for ``method="random"``, how many points to keep; never more than the cloud holds.
|
|
452
|
+
fraction: for ``method="random"``, the share of points to keep, in ``(0, 1]``. A positive fraction
|
|
453
|
+
over a non-empty cloud keeps at least one point.
|
|
454
|
+
method: ``"random"`` for a uniform sample without replacement, ``"voxel"`` for one point per cube.
|
|
455
|
+
voxel_size: for ``method="voxel"``, the cube edge length in CRS units.
|
|
456
|
+
seed: seeds the random sample so a run is reproducible; ignored by ``"voxel"``.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
A new, thinned cloud; the original is untouched. Point order is preserved.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
ValueError: when the arguments for the chosen method are missing, ambiguous, or out of range, or when
|
|
463
|
+
``method`` is neither ``"random"`` nor ``"voxel"``.
|
|
464
|
+
|
|
465
|
+
Examples:
|
|
466
|
+
>>> import numpy as np
|
|
467
|
+
>>> cloud = PointCloud(x=np.arange(10.0), y=np.zeros(10), z=np.zeros(10))
|
|
468
|
+
>>> len(cloud.decimate(target=3, seed=0))
|
|
469
|
+
3
|
|
470
|
+
"""
|
|
471
|
+
indices = _decimate.select_indices(
|
|
472
|
+
self, target=target, fraction=fraction, method=method, voxel_size=voxel_size, seed=seed
|
|
473
|
+
)
|
|
474
|
+
return self._take(indices)
|
|
475
|
+
|
|
476
|
+
def clip(
|
|
477
|
+
self,
|
|
478
|
+
*,
|
|
479
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
480
|
+
polygon: np.ndarray | None = None,
|
|
481
|
+
) -> PointCloud:
|
|
482
|
+
"""Keep the points inside an extent or a polygon, returning a new cloud.
|
|
483
|
+
|
|
484
|
+
Exactly one of ``bbox`` or ``polygon`` must be given. Both forms are pure numpy, so clipping needs no GIS
|
|
485
|
+
stack; see :mod:`pointclouds.ops.clip` for the scope of the polygon form (a single ring, no holes).
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` extent, edges included.
|
|
489
|
+
polygon: an ``(M, 2)`` array of ``(x, y)`` vertices forming a single ring (closed or open).
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
A new cloud holding only the points inside the region; the original is untouched.
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
ValueError: when neither or both of ``bbox`` and ``polygon`` are given, or ``polygon`` is malformed.
|
|
496
|
+
|
|
497
|
+
Examples:
|
|
498
|
+
>>> import numpy as np
|
|
499
|
+
>>> cloud = PointCloud(x=np.array([0.0, 5.0, 9.0]), y=np.zeros(3), z=np.zeros(3))
|
|
500
|
+
>>> len(cloud.clip(bbox=(4.0, -1.0, 10.0, 1.0)))
|
|
501
|
+
2
|
|
502
|
+
"""
|
|
503
|
+
mask = _clip.clip_mask(self, bbox=bbox, polygon=polygon)
|
|
504
|
+
return self._take(mask)
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
def merge(cls, clouds: Sequence[PointCloud]) -> PointCloud:
|
|
508
|
+
"""Concatenate several clouds into one, rejecting a mix of CRSs.
|
|
509
|
+
|
|
510
|
+
A zero-point cloud (for example one that filtered to empty) is a no-op participant: it contributes no
|
|
511
|
+
points and takes no part in resolving the CRS or which attributes are present.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
clouds: the clouds to merge, in order. Each is loaded if its records are not in memory yet.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
A new cloud holding every point from every input, in input order.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
ValueError: when ``clouds`` is empty, the clouds disagree on CRS, or an attribute is present on some
|
|
521
|
+
clouds but absent on others.
|
|
522
|
+
|
|
523
|
+
Examples:
|
|
524
|
+
>>> import numpy as np
|
|
525
|
+
>>> a = PointCloud(x=np.zeros(2), y=np.zeros(2), z=np.zeros(2))
|
|
526
|
+
>>> b = PointCloud(x=np.ones(3), y=np.zeros(3), z=np.zeros(3))
|
|
527
|
+
>>> len(PointCloud.merge([a, b]))
|
|
528
|
+
5
|
|
529
|
+
"""
|
|
530
|
+
return cls(**_merge.merge_arrays(clouds))
|
|
531
|
+
|
|
532
|
+
def to_grid(
|
|
533
|
+
self,
|
|
534
|
+
*,
|
|
535
|
+
cell_size: float,
|
|
536
|
+
aggregate: str = "mean",
|
|
537
|
+
mode: str = "bin",
|
|
538
|
+
radius: float | None = None,
|
|
539
|
+
bounds: tuple[float, float, float, float] | None = None,
|
|
540
|
+
nodata: float = np.nan,
|
|
541
|
+
) -> tuple[np.ndarray, tuple[float, float, float, float, float, float], CRS | None]:
|
|
542
|
+
"""Grid the cloud onto a regular raster surface, without GDAL.
|
|
543
|
+
|
|
544
|
+
The aggregation names follow PDAL's ``writers.gdal`` vocabulary, and the window defaults to ``"bin"`` (only
|
|
545
|
+
points inside the pixel contribute); see :mod:`pointclouds.ops.grid` for the full rationale.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
cell_size: the pixel edge length in CRS units; must be positive.
|
|
549
|
+
aggregate: ``min``, ``max``, ``mean``, ``count``, ``stdev``, ``idw``, or a percentile ``p<n>``.
|
|
550
|
+
mode: ``"bin"`` (points inside the pixel) or ``"radius"`` (points within ``radius`` of the centre).
|
|
551
|
+
radius: the search radius for ``mode="radius"``; defaults to ``cell_size * sqrt(2)`` when ``None``.
|
|
552
|
+
bounds: ``(xmin, ymin, xmax, ymax)`` extent to grid; defaults to the cloud's own bounds.
|
|
553
|
+
nodata: the value written to empty cells (``count`` excepted, which writes ``0``).
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
``(array, transform, crs)``: an ``(nrows, ncols)`` float64 array with row 0 northernmost, a GDAL-style
|
|
557
|
+
affine 6-tuple ``(xmin, cell_size, 0, ymax, 0, -cell_size)``, and the cloud's CRS.
|
|
558
|
+
|
|
559
|
+
Raises:
|
|
560
|
+
ValueError: on a non-positive ``cell_size``; a non-positive-area extent (a degenerate ``bounds``, or
|
|
561
|
+
the default extent of a flat cloud whose points share a single x or a single y); an unknown
|
|
562
|
+
``mode``; or an unrecognised ``aggregate``.
|
|
563
|
+
|
|
564
|
+
Examples:
|
|
565
|
+
>>> import numpy as np
|
|
566
|
+
>>> ground = PointCloud(x=np.array([0.5, 1.5]), y=np.array([0.5, 0.5]), z=np.array([10.0, 20.0]))
|
|
567
|
+
>>> array, _, _ = ground.to_grid(cell_size=1.0, aggregate="min", bounds=(0, 0, 2, 1))
|
|
568
|
+
>>> array.tolist()
|
|
569
|
+
[[10.0, 20.0]]
|
|
570
|
+
"""
|
|
571
|
+
return _grid.to_grid(
|
|
572
|
+
self, cell_size=cell_size, aggregate=aggregate, mode=mode, radius=radius, bounds=bounds, nodata=nodata
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def _take(self, selector: np.ndarray) -> PointCloud:
|
|
576
|
+
"""Return a new cloud holding the selected points, every coordinate and attribute subset in step.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
selector: a boolean mask of length ``len(self)``, or an integer index array into the points.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
A new, eager cloud. An attribute the cloud does not carry (an empty array) stays empty rather than
|
|
583
|
+
being indexed, so the empty-means-absent convention is preserved.
|
|
584
|
+
"""
|
|
585
|
+
self.load()
|
|
586
|
+
attributes = {name: values[selector] if values.size else values for name, values in self._attributes.items()}
|
|
587
|
+
return PointCloud(x=self._x[selector], y=self._y[selector], z=self._z[selector], crs=self._crs, **attributes)
|
|
588
|
+
|
|
589
|
+
def to_crs(
|
|
590
|
+
self,
|
|
591
|
+
crs: CRS | str | int,
|
|
592
|
+
*,
|
|
593
|
+
vertical: bool = False,
|
|
594
|
+
on_invalid: Literal["raise", "mask", "drop"] = "raise",
|
|
595
|
+
) -> PointCloud:
|
|
596
|
+
"""Reproject the cloud into another CRS with pyproj, returning a new cloud.
|
|
597
|
+
|
|
598
|
+
The transform is built with ``Transformer.from_crs(..., always_xy=True)``, so coordinates are always
|
|
599
|
+
read and written in ``(x, y)`` / easting-northing / longitude-latitude order regardless of the axis
|
|
600
|
+
order the EPSG registry declares for either CRS (e.g. EPSG:4326's registry order is lat/lon, yet this
|
|
601
|
+
returns lon in ``x`` and lat in ``y``).
|
|
602
|
+
|
|
603
|
+
**Vertical datum.** By default (``vertical=False``) only x and y are transformed; z is carried over
|
|
604
|
+
unchanged, still in the source vertical datum. This is the right choice when the target is a 2-D
|
|
605
|
+
horizontal CRS - there is no vertical transformation to apply - but it means a 3-D reprojection is never
|
|
606
|
+
done implicitly. To shift z as well, pass ``vertical=True``; this requires a compound or 3-D CRS on both
|
|
607
|
+
sides (one that carries a vertical axis) and raises ``ValueError`` otherwise. The most accurate vertical
|
|
608
|
+
operation is then used, and if its grid (e.g. a geoid model) is not installed the transform is refused
|
|
609
|
+
with ``ValueError`` rather than falling back to a less-accurate operation or to one that passes z through
|
|
610
|
+
untouched. The trade-off is strict - a legitimate vertical reprojection is refused on an install lacking
|
|
611
|
+
that grid - but it guarantees z is never silently left in the source datum when a vertical transform was
|
|
612
|
+
asked for, and it does so the same way on every pyproj/PROJ build.
|
|
613
|
+
|
|
614
|
+
**Out-of-domain points** are handled by ``on_invalid``. By default (``"raise"``) a coordinate outside the
|
|
615
|
+
transform's area of use raises ``ProjError``, and because that is all-or-nothing a single non-computable
|
|
616
|
+
point aborts the whole reprojection. A cloud that may carry sentinel or noise coordinates (e.g. stray
|
|
617
|
+
``0, 0``) can instead pass ``on_invalid="mask"`` to set each unreprojectable point to ``NaN`` in x/y/z
|
|
618
|
+
(keeping the point count and attribute alignment), or ``on_invalid="drop"`` to remove those points and
|
|
619
|
+
their attribute rows (shrinking the cloud). Both lenient modes keep every other point.
|
|
620
|
+
|
|
621
|
+
Present attribute arrays (intensity, classification, return number) are carried over by value - the
|
|
622
|
+
returned cloud owns independent copies, so editing it never reaches back into the source - and stay
|
|
623
|
+
aligned with the reprojected coordinates; an absent attribute is carried over as the same empty array. The
|
|
624
|
+
whole cloud is materialised: a lazy source is loaded first, and the cost is O(N) in points, with no
|
|
625
|
+
chunked/streaming path.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
crs: the target CRS, as anything :class:`pyproj.CRS` accepts (an int or string EPSG code, WKT, a
|
|
629
|
+
``CRS``).
|
|
630
|
+
vertical: when ``True``, transform z as well as x/y; requires a vertical/compound CRS on both sides.
|
|
631
|
+
on_invalid: what to do with points outside the transform's domain - ``"raise"`` (the default; one bad
|
|
632
|
+
point raises ``ProjError``), ``"mask"`` (set them to ``NaN``, preserving count and alignment), or
|
|
633
|
+
``"drop"`` (remove them and their attribute rows).
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
A new, loaded :class:`PointCloud` in ``crs``, with x/y (and z when ``vertical``) transformed. With
|
|
637
|
+
``on_invalid="drop"`` it holds only the points that reprojected; otherwise it has the same count as
|
|
638
|
+
``self``.
|
|
639
|
+
|
|
640
|
+
Raises:
|
|
641
|
+
ValueError: when ``on_invalid`` is not one of ``"raise"``/``"mask"``/``"drop"``; when :attr:`crs` is
|
|
642
|
+
``None`` (no source CRS to transform from); when ``vertical`` is ``True`` but the source or target
|
|
643
|
+
CRS carries no vertical axis; or when ``vertical`` is ``True`` and the required vertical-datum
|
|
644
|
+
operation (e.g. a geoid grid) is not installed.
|
|
645
|
+
pyproj.exceptions.ProjError: when ``on_invalid="raise"`` and a point falls outside the transform's
|
|
646
|
+
domain (a single such point aborts the whole reprojection).
|
|
647
|
+
|
|
648
|
+
Examples:
|
|
649
|
+
- Reproject a Dutch RD New cloud into UTM zone 31N; the result reports the target CRS:
|
|
650
|
+
```python
|
|
651
|
+
>>> import numpy as np
|
|
652
|
+
>>> cloud = PointCloud(x=np.array([155000.0]), y=np.array([463000.0]), z=np.zeros(1),
|
|
653
|
+
... crs="EPSG:28992")
|
|
654
|
+
>>> utm = cloud.to_crs("EPSG:32631")
|
|
655
|
+
>>> utm.crs.to_epsg()
|
|
656
|
+
32631
|
|
657
|
+
|
|
658
|
+
```
|
|
659
|
+
- ``always_xy=True`` means longitude lands in ``x`` and latitude in ``y`` even for EPSG:4326,
|
|
660
|
+
whose registry order is lat/lon:
|
|
661
|
+
```python
|
|
662
|
+
>>> import numpy as np
|
|
663
|
+
>>> cloud = PointCloud(x=np.array([155000.0]), y=np.array([463000.0]), z=np.zeros(1),
|
|
664
|
+
... crs="EPSG:28992")
|
|
665
|
+
>>> wgs = cloud.to_crs("EPSG:4326")
|
|
666
|
+
>>> round(float(wgs.x[0]), 2), round(float(wgs.y[0]), 2)
|
|
667
|
+
(5.39, 52.16)
|
|
668
|
+
|
|
669
|
+
```
|
|
670
|
+
- A cloud with no CRS has nothing to transform from, so it refuses:
|
|
671
|
+
```python
|
|
672
|
+
>>> import numpy as np
|
|
673
|
+
>>> cloud = PointCloud(x=np.zeros(1), y=np.zeros(1), z=np.zeros(1))
|
|
674
|
+
>>> cloud.to_crs("EPSG:4326")
|
|
675
|
+
Traceback (most recent call last):
|
|
676
|
+
...
|
|
677
|
+
ValueError: cannot reproject a cloud whose crs is None; set a source CRS first
|
|
678
|
+
|
|
679
|
+
```
|
|
680
|
+
"""
|
|
681
|
+
if on_invalid not in ("raise", "mask", "drop"):
|
|
682
|
+
raise ValueError(f"on_invalid must be 'raise', 'mask' or 'drop'; got {on_invalid!r}")
|
|
683
|
+
if self._crs is None:
|
|
684
|
+
raise ValueError("cannot reproject a cloud whose crs is None; set a source CRS first")
|
|
685
|
+
target = CRS.from_user_input(crs)
|
|
686
|
+
if vertical and not (_has_vertical_axis(self._crs) and _has_vertical_axis(target)):
|
|
687
|
+
raise ValueError(
|
|
688
|
+
"vertical=True needs a compound or 3-D CRS (one with a vertical axis) on both sides; "
|
|
689
|
+
f"got source {self._crs.to_string()!r} and target {target.to_string()!r}. Refusing rather "
|
|
690
|
+
"than leaving z in the source datum."
|
|
691
|
+
)
|
|
692
|
+
errcheck = on_invalid == "raise"
|
|
693
|
+
if vertical:
|
|
694
|
+
with warnings.catch_warnings():
|
|
695
|
+
warnings.simplefilter("ignore") # the "best unavailable" warning is turned into a raise below
|
|
696
|
+
group = TransformerGroup(self._crs, target, always_xy=True)
|
|
697
|
+
if not group.best_available:
|
|
698
|
+
raise ValueError(
|
|
699
|
+
"vertical=True needs the most accurate vertical operation between "
|
|
700
|
+
f"{self._crs.to_string()!r} and {target.to_string()!r}, but its grid (e.g. a geoid model) "
|
|
701
|
+
"is not installed; refusing rather than leaving z in the source datum."
|
|
702
|
+
)
|
|
703
|
+
x, y, z = group.transformers[0].transform(self.x, self.y, self.z, errcheck=errcheck)
|
|
704
|
+
else:
|
|
705
|
+
transformer = Transformer.from_crs(self._crs, target, always_xy=True)
|
|
706
|
+
x, y = transformer.transform(self.x, self.y, errcheck=errcheck)
|
|
707
|
+
z = self.z.copy()
|
|
708
|
+
intensity = self.intensity.copy()
|
|
709
|
+
classification = self.classification.copy()
|
|
710
|
+
return_number = self.return_number.copy()
|
|
711
|
+
if on_invalid != "raise":
|
|
712
|
+
x, y, z = np.asarray(x, np.float64), np.asarray(y, np.float64), np.asarray(z, np.float64)
|
|
713
|
+
valid = np.isfinite(x) & np.isfinite(y)
|
|
714
|
+
if vertical:
|
|
715
|
+
valid &= np.isfinite(z)
|
|
716
|
+
if on_invalid == "mask":
|
|
717
|
+
x[~valid] = y[~valid] = z[~valid] = np.nan
|
|
718
|
+
else: # "drop" - remove the unreprojectable points and their attributes, keeping alignment
|
|
719
|
+
x, y, z = x[valid], y[valid], z[valid]
|
|
720
|
+
intensity = intensity[valid] if intensity.size else intensity
|
|
721
|
+
classification = classification[valid] if classification.size else classification
|
|
722
|
+
return_number = return_number[valid] if return_number.size else return_number
|
|
723
|
+
return PointCloud(
|
|
724
|
+
x=x,
|
|
725
|
+
y=y,
|
|
726
|
+
z=z,
|
|
727
|
+
intensity=intensity,
|
|
728
|
+
classification=classification,
|
|
729
|
+
return_number=return_number,
|
|
730
|
+
crs=target,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def __len__(self) -> int:
|
|
734
|
+
"""Return how many points are in memory, loading the records first if needed.
|
|
735
|
+
|
|
736
|
+
Use :attr:`count` instead when the header's number is enough - that one is free.
|
|
737
|
+
"""
|
|
738
|
+
return int(self.load()._x.shape[0])
|
|
739
|
+
|
|
740
|
+
def __repr__(self) -> str:
|
|
741
|
+
"""Describe the cloud without loading anything."""
|
|
742
|
+
crs = self._crs.to_string() if self._crs is not None else None
|
|
743
|
+
state = "loaded" if self._loaded else "lazy"
|
|
744
|
+
return f"PointCloud(count={self.count}, crs={crs!r}, {state})"
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
_EMPTY_F8 = np.empty(0, dtype=np.float64)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _has_vertical_axis(crs: CRS) -> bool:
|
|
751
|
+
"""Whether ``crs`` carries height, so a vertical datum shift is meaningful.
|
|
752
|
+
|
|
753
|
+
True for a compound CRS (horizontal + vertical), a 3-D CRS (three axes), or a bare vertical CRS; false for a
|
|
754
|
+
plain 2-D horizontal CRS, where there is no z to transform.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
crs: the CRS to inspect.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
``True`` when the CRS has a vertical component.
|
|
761
|
+
"""
|
|
762
|
+
return crs.is_compound or crs.is_vertical or len(crs.axis_info) >= 3
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _as_attribute(values: np.ndarray | None, dtype: type) -> np.ndarray:
|
|
766
|
+
"""Return ``values`` as an array of ``dtype``, mapping ``None`` to a zero-length array.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
values: the array to normalise, or ``None``.
|
|
770
|
+
dtype: the dtype to cast to.
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
An array of ``dtype``; zero-length when ``values`` is ``None``.
|
|
774
|
+
"""
|
|
775
|
+
if values is None:
|
|
776
|
+
return np.empty(0, dtype=dtype)
|
|
777
|
+
return np.asarray(values, dtype=dtype)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _ept_depth(level: int | range | None) -> int | None:
|
|
781
|
+
"""Translate the unified ``level`` argument to the EPT ``depth`` (a maximum octree depth).
|
|
782
|
+
|
|
783
|
+
COPC takes an exact level or a range; EPT takes a maximum depth. An ``int`` level maps straight to that
|
|
784
|
+
maximum depth. A ``range`` has no EPT equivalent, so it is refused rather than guessed.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
level: the ``level`` argument from :meth:`PointCloud.read_window`.
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
The EPT ``depth`` (an ``int``), or ``None`` when ``level`` is ``None``.
|
|
791
|
+
|
|
792
|
+
Raises:
|
|
793
|
+
ValueError: when ``level`` is a ``range`` - EPT reads down to a single maximum depth, so pass an int
|
|
794
|
+
level or a resolution instead.
|
|
795
|
+
"""
|
|
796
|
+
if isinstance(level, range):
|
|
797
|
+
raise ValueError(
|
|
798
|
+
"an EPT source takes a maximum depth, not a level range; pass level=<int> (a max depth) or "
|
|
799
|
+
"resolution=<spacing>"
|
|
800
|
+
)
|
|
801
|
+
return level
|