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/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