pointclouds 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pointclouds/__init__.py +20 -0
- pointclouds/classify/__init__.py +1 -0
- pointclouds/classify/ground.py +1 -0
- pointclouds/classify/trees.py +1 -0
- pointclouds/convert.py +35 -0
- pointclouds/io/__init__.py +1 -0
- pointclouds/io/_records.py +196 -0
- pointclouds/io/copc.py +570 -0
- pointclouds/io/ept.py +724 -0
- pointclouds/io/las.py +119 -0
- pointclouds/ops/__init__.py +1 -0
- pointclouds/ops/clip.py +99 -0
- pointclouds/ops/decimate.py +107 -0
- pointclouds/ops/filter.py +109 -0
- pointclouds/ops/grid.py +241 -0
- pointclouds/ops/merge.py +107 -0
- pointclouds/points.py +801 -0
- pointclouds-0.0.0.dist-info/METADATA +95 -0
- pointclouds-0.0.0.dist-info/RECORD +21 -0
- pointclouds-0.0.0.dist-info/WHEEL +4 -0
- pointclouds-0.0.0.dist-info/licenses/LICENSE.md +675 -0
pointclouds/io/copc.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""COPC spatially-indexed windowed reads: pull back a bounding box, or a coarser level of detail. (T-16).
|
|
2
|
+
|
|
3
|
+
A COPC file is a LAZ 1.4 whose chunks are laid out as an octree, with the tree's index stored in the file. That
|
|
4
|
+
layout is what makes a *windowed* read possible: given a bounding box, only the chunks whose octree voxels
|
|
5
|
+
overlap it are fetched and decompressed, and given a target resolution, only the octree levels coarse enough to
|
|
6
|
+
satisfy it are visited. The alternative - :func:`pointclouds.io.las.read_points` followed by a mask - reads and
|
|
7
|
+
decompresses every record first, which is the cost COPC exists to avoid.
|
|
8
|
+
|
|
9
|
+
Two entry points, mirroring :mod:`pointclouds.io.las`:
|
|
10
|
+
|
|
11
|
+
- :func:`read_header` answers ``count`` / ``bounds`` / ``crs`` plus the octree's root ``spacing``, touching no
|
|
12
|
+
point record - the same header-first contract the LAS engine keeps.
|
|
13
|
+
- :func:`read_window` runs the query and returns record arrays, under the record contract in
|
|
14
|
+
:mod:`pointclouds.io._records`, so a window maps onto :class:`~pointclouds.points.PointCloud` exactly as a
|
|
15
|
+
full read does.
|
|
16
|
+
|
|
17
|
+
``laspy`` provides the octree traversal (:class:`laspy.CopcReader`) and ``lazrs`` the per-chunk decompression;
|
|
18
|
+
both are already core dependencies, so nothing here needs an extra. Everything imports with the core set
|
|
19
|
+
(numpy, laspy, pyproj) and never touches GDAL.
|
|
20
|
+
|
|
21
|
+
A COPC source may be a local path or a file object exposing ``read`` / ``seek`` / ``tell`` (the
|
|
22
|
+
:class:`FileObject` protocol), which is how a remote cloud is read without this package taking on an HTTP
|
|
23
|
+
client: open the object with ``fsspec`` (the ``[cloud]`` extra) and pass it in. The handle must be seekable -
|
|
24
|
+
COPC is a random-access format - and it stays the caller's to close.
|
|
25
|
+
|
|
26
|
+
**EPT is not implemented here yet.** An Entwine tileset is a directory of files behind an ``ept.json`` rather
|
|
27
|
+
than a single indexed file, and ``laspy`` has no reader for it, so it needs its own traversal and fetch path -
|
|
28
|
+
the second half of the issue this module closes the first half of.
|
|
29
|
+
|
|
30
|
+
**A corrupt octree reads short, silently.** laspy skips hierarchy entries it cannot parse, so a truncated or
|
|
31
|
+
partially-transferred COPC - one whose hierarchy EVLR is damaged but whose header still parses - reads back as a
|
|
32
|
+
smaller, plausible-looking cloud rather than raising. There is no integrity check here that compares the summed
|
|
33
|
+
hierarchy point counts against the header's count; a caller that needs that guarantee should verify the
|
|
34
|
+
transfer out of band.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
from collections.abc import Iterator
|
|
42
|
+
from contextlib import contextmanager
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Protocol, runtime_checkable
|
|
46
|
+
|
|
47
|
+
import laspy
|
|
48
|
+
import numpy as np
|
|
49
|
+
from laspy.copc import Bounds
|
|
50
|
+
from laspy.errors import LaspyException, LazError
|
|
51
|
+
from pyproj import CRS
|
|
52
|
+
|
|
53
|
+
from pointclouds.io import _records
|
|
54
|
+
from pointclouds.io.las import LasHeaderInfo
|
|
55
|
+
|
|
56
|
+
__all__ = ["CopcHeaderInfo", "FileObject", "Source", "is_copc", "read_header", "read_window"]
|
|
57
|
+
|
|
58
|
+
#: A COPC file is LAZ-compressed by definition (the octree is a chunk layout, and chunks only exist in LAZ), so
|
|
59
|
+
#: an uncompressed ``.las`` can never be one.
|
|
60
|
+
_SUFFIXES = {".laz"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class FileObject(Protocol):
|
|
65
|
+
"""The file-object contract a COPC source may satisfy: ``read`` / ``seek`` / ``tell``.
|
|
66
|
+
|
|
67
|
+
Deliberately structural rather than ``io.IOBase``: laspy's own remote reader (``HttpRangeStream``) and an
|
|
68
|
+
``fsspec`` handle are not ``io.IOBase`` subclasses, so an ``isinstance(io.IOBase)`` test would reject
|
|
69
|
+
exactly the remote sources this package exists to support. These three methods are what ``laspy.CopcReader``
|
|
70
|
+
documents as sufficient.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def read(self, size: int = ..., /) -> bytes:
|
|
74
|
+
"""Read up to ``size`` bytes (all remaining when ``size`` is negative or omitted)."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def seek(self, offset: int, whence: int = ..., /) -> int:
|
|
78
|
+
"""Move to byte ``offset`` relative to ``whence`` and return the new absolute position."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def tell(self) -> int:
|
|
82
|
+
"""Return the current absolute byte position."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: What a source may be: a path, or an already-open file object (an ``fsspec`` handle, a ``BytesIO``, ...).
|
|
87
|
+
Source = str | os.PathLike[str] | FileObject
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_file_object(source: Source) -> bool:
|
|
91
|
+
"""Whether ``source`` is a file object (has ``read``/``seek``/``tell``) rather than a path.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
source: the value to classify.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
``True`` for a read/seek/tell object, ``False`` for a path-like. A ``str`` / ``Path`` has none of the
|
|
98
|
+
three, so it never matches.
|
|
99
|
+
"""
|
|
100
|
+
return all(callable(getattr(source, name, None)) for name in ("read", "seek", "tell"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class CopcHeaderInfo(LasHeaderInfo):
|
|
105
|
+
"""A COPC file's header, plus the one octree property a caller needs to choose a ``resolution``.
|
|
106
|
+
|
|
107
|
+
Everything :class:`~pointclouds.io.las.LasHeaderInfo` carries is answered the same way here, from the
|
|
108
|
+
header alone. This subclass adds:
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
spacing: the root node's point spacing, in the data's own units (metres for a projected CRS). The octree
|
|
112
|
+
halves it at every level, so a level ``n`` node has spacing ``spacing / 2**n``. This is the number
|
|
113
|
+
``resolution=`` is measured against - ask for ``resolution=spacing`` and only the root is read.
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
``spacing`` is what turns a target resolution into an octree depth, so a caller can work out how deep a
|
|
117
|
+
view will have to go before paying for the read:
|
|
118
|
+
|
|
119
|
+
>>> info = CopcHeaderInfo(
|
|
120
|
+
... count=1600,
|
|
121
|
+
... bounds=(155000.0, 463000.0, 155078.0, 463078.0),
|
|
122
|
+
... crs=None,
|
|
123
|
+
... dimensions=("X", "Y", "Z"),
|
|
124
|
+
... version="1.4",
|
|
125
|
+
... point_format=6,
|
|
126
|
+
... spacing=7.8,
|
|
127
|
+
... )
|
|
128
|
+
>>> info.spacing / 2**2 # the spacing two levels down
|
|
129
|
+
1.95
|
|
130
|
+
|
|
131
|
+
Everything the LAS header answers is here too, so the file's extent and size are known before any
|
|
132
|
+
record is read:
|
|
133
|
+
|
|
134
|
+
>>> info.count, info.bounds[:2]
|
|
135
|
+
(1600, (155000.0, 463000.0))
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
spacing: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve(source: Source) -> Path | FileObject:
|
|
142
|
+
"""Validate ``source`` if it is a path, and pass a file object through untouched.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
source: the COPC file to open, as a path or an open file object.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The resolved :class:`~pathlib.Path`, or ``source`` itself when it is a file object.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
FileNotFoundError: when a path does not exist, naming the path rather than surfacing laspy's error.
|
|
152
|
+
ValueError: when a path's suffix is not ``.laz``.
|
|
153
|
+
TypeError: when ``source`` is neither a path nor a read/seek/tell file object.
|
|
154
|
+
"""
|
|
155
|
+
if not isinstance(source, (str, os.PathLike)) and _is_file_object(source):
|
|
156
|
+
return source
|
|
157
|
+
if not isinstance(source, (str, os.PathLike)):
|
|
158
|
+
raise TypeError(f"source must be a path or a file object with read/seek/tell; got {type(source).__name__}")
|
|
159
|
+
resolved = Path(source)
|
|
160
|
+
if resolved.suffix.lower() not in _SUFFIXES:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"{resolved.name!r} is not a COPC file; COPC is always LAZ-compressed, so the suffix must be "
|
|
163
|
+
f"'.laz' (conventionally '.copc.laz')"
|
|
164
|
+
)
|
|
165
|
+
if not resolved.is_file():
|
|
166
|
+
raise FileNotFoundError(f"no such point cloud file: {resolved}")
|
|
167
|
+
return resolved
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def _reader(source: Source) -> Iterator[laspy.CopcReader]:
|
|
172
|
+
"""Open ``source`` as a COPC reader, translating laspy's "not a COPC" error into a ``ValueError``.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
source: the COPC file to open, as a path or an open file object.
|
|
176
|
+
|
|
177
|
+
Yields:
|
|
178
|
+
An open :class:`laspy.CopcReader`.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
FileNotFoundError: when a path does not exist.
|
|
182
|
+
ValueError: when the file carries no COPC octree - a plain LAS/LAZ, which :func:`read_window` cannot
|
|
183
|
+
window and which :func:`pointclouds.io.las.read_points` should read instead.
|
|
184
|
+
laspy.errors.LazError: when the ``lazrs`` backend is missing. Re-raised unchanged rather than relabelled
|
|
185
|
+
"no octree", because the file is COPC - the environment cannot decompress it, and the whole-file
|
|
186
|
+
read the "no octree" message points at would fail for the same reason.
|
|
187
|
+
laspy.errors.LaspyException: for any other structural read failure (a truncated or corrupt file). Also
|
|
188
|
+
re-raised unchanged, for the same reason.
|
|
189
|
+
ValueError: when the source is a non-seekable file object - COPC needs random access.
|
|
190
|
+
"""
|
|
191
|
+
resolved = _resolve(source)
|
|
192
|
+
try:
|
|
193
|
+
if isinstance(resolved, Path):
|
|
194
|
+
reader = laspy.CopcReader.open(resolved)
|
|
195
|
+
else:
|
|
196
|
+
# COPC is random-access: laspy seeks all over the file. A non-seekable handle (an uncached remote
|
|
197
|
+
# stream) cannot serve a window - say so rather than let seek() raise an opaque
|
|
198
|
+
# UnsupportedOperation. seekable() is optional on the FileObject protocol (laspy's own
|
|
199
|
+
# HttpRangeStream lacks it), so a missing one is taken as "seekable".
|
|
200
|
+
seekable = getattr(resolved, "seekable", None)
|
|
201
|
+
if seekable is not None and not seekable():
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"a COPC window needs a seekable file object (laspy seeks across the octree); open the "
|
|
204
|
+
"handle with caching enabled, or pass a path"
|
|
205
|
+
)
|
|
206
|
+
# Rewind: laspy reads the header from wherever the handle happens to sit, so a second read of the
|
|
207
|
+
# same handle - a header read followed by a window read - would otherwise start mid-file.
|
|
208
|
+
resolved.seek(0)
|
|
209
|
+
# close_fd=False: the caller opened this handle, so the caller closes it. laspy's own default would
|
|
210
|
+
# close it on exit, which would break a caller reading several windows out of one fsspec handle.
|
|
211
|
+
reader = laspy.CopcReader(resolved, close_fd=False)
|
|
212
|
+
except LazError:
|
|
213
|
+
# A missing lazrs backend is not "no octree" - the file is COPC, the environment just cannot read LAZ.
|
|
214
|
+
raise
|
|
215
|
+
except LaspyException as exc:
|
|
216
|
+
# Only laspy's "not a valid COPC" means the file genuinely has no octree. Any other LaspyException is a
|
|
217
|
+
# structural read failure (e.g. a truncated file) and must not be relabelled into a misdirecting
|
|
218
|
+
# "read it whole instead" - that path would fail too.
|
|
219
|
+
#
|
|
220
|
+
# This matches an unversioned laspy-internal string (observed on laspy 2.7.0:
|
|
221
|
+
# "This file is not a valid COPC, it does not have a COPC VLR"). The coupling is deliberate and
|
|
222
|
+
# test-guarded: test_a_plain_laz_is_not / test_a_genuine_non_copc_still_says_so run against a real
|
|
223
|
+
# plain LAZ, so a laspy rewording fails the suite loudly rather than silently leaking a raw exception.
|
|
224
|
+
if "not a valid COPC" not in str(exc):
|
|
225
|
+
raise
|
|
226
|
+
label = resolved.name if isinstance(resolved, Path) else "the given file object"
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"{label} carries no COPC octree, so it cannot be read through a window; read it whole with "
|
|
229
|
+
f"PointCloud.read_file() instead ({exc})"
|
|
230
|
+
) from exc
|
|
231
|
+
with reader:
|
|
232
|
+
yield reader
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_copc(source: Source) -> bool:
|
|
236
|
+
"""Report whether ``source`` carries a COPC octree.
|
|
237
|
+
|
|
238
|
+
This is the cheap question: it reads the header, the VLRs and the octree's root hierarchy page, never a
|
|
239
|
+
point record. On a remote source that is two range requests, not one - the root page sits past the point
|
|
240
|
+
data.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
source: the file to inspect, as a path or an open file object.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
``True`` when the file is a valid COPC, ``False`` when it is a plain LAS/LAZ with no octree.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
FileNotFoundError: when a path does not exist.
|
|
250
|
+
ValueError: when a path's suffix is not ``.laz`` - an uncompressed ``.las`` is never COPC.
|
|
251
|
+
laspy.errors.LazError: when the ``lazrs`` backend is missing. A predicate must not answer ``False`` to a
|
|
252
|
+
question it could not evaluate, so a backend failure surfaces rather than being swallowed.
|
|
253
|
+
laspy.errors.LaspyException: when the file is structurally broken (truncated or corrupt) - distinct from
|
|
254
|
+
"a valid file with no octree", which is the only case that returns ``False``.
|
|
255
|
+
|
|
256
|
+
Examples:
|
|
257
|
+
An indexed cloud answers yes, and a plain LAZ answers no rather than raising - that is the question:
|
|
258
|
+
|
|
259
|
+
>>> is_copc("examples/data/sample.copc.laz") # doctest: +SKIP
|
|
260
|
+
True
|
|
261
|
+
>>> is_copc("examples/data/sample.laz") # doctest: +SKIP
|
|
262
|
+
False
|
|
263
|
+
|
|
264
|
+
Which makes it the way to choose a read path for a file of unknown provenance:
|
|
265
|
+
|
|
266
|
+
>>> path = "examples/data/sample.laz" # doctest: +SKIP
|
|
267
|
+
>>> read_window(path, bbox=(0, 0, 1, 1)) if is_copc(path) else "read it whole instead" # doctest: +SKIP
|
|
268
|
+
'read it whole instead'
|
|
269
|
+
"""
|
|
270
|
+
# Resolve FIRST, outside the try: a bad suffix or a missing file is an error, not a "no" answer, and both
|
|
271
|
+
# raise ValueError - which the `except ValueError` below would otherwise swallow into a wrong False. This
|
|
272
|
+
# call is load-bearing, not redundant with the one inside _reader; do not remove it.
|
|
273
|
+
resolved = _resolve(source)
|
|
274
|
+
try:
|
|
275
|
+
with _reader(resolved):
|
|
276
|
+
return True
|
|
277
|
+
except ValueError:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def read_header(source: Source) -> CopcHeaderInfo:
|
|
282
|
+
"""Read a COPC file's header and octree root properties, touching no point records.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
source: the COPC file to open, as a path or an open file object.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
A :class:`CopcHeaderInfo` with count, bounds, CRS, dimensions, format metadata and the root ``spacing``.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
FileNotFoundError: when a path does not exist.
|
|
292
|
+
ValueError: when a path's suffix is not ``.laz``, or the file carries no COPC octree.
|
|
293
|
+
|
|
294
|
+
Examples:
|
|
295
|
+
The size and extent of a cloud are answerable before deciding whether to read any of it:
|
|
296
|
+
|
|
297
|
+
>>> info = read_header("examples/data/sample.copc.laz") # doctest: +SKIP
|
|
298
|
+
>>> info.count, info.spacing # header only - no records touched # doctest: +SKIP
|
|
299
|
+
(1600, 7.8)
|
|
300
|
+
|
|
301
|
+
``spacing`` says what a coarse read will cost: asking for it reads the root node alone:
|
|
302
|
+
|
|
303
|
+
>>> info = read_header("examples/data/sample.copc.laz") # doctest: +SKIP
|
|
304
|
+
>>> read_window("examples/data/sample.copc.laz", resolution=info.spacing)["x"].size # doctest: +SKIP
|
|
305
|
+
100
|
|
306
|
+
"""
|
|
307
|
+
with _reader(source) as reader:
|
|
308
|
+
header = reader.header
|
|
309
|
+
return CopcHeaderInfo(
|
|
310
|
+
count=int(header.point_count),
|
|
311
|
+
bounds=(float(header.x_min), float(header.y_min), float(header.x_max), float(header.y_max)),
|
|
312
|
+
crs=header.parse_crs(),
|
|
313
|
+
dimensions=tuple(str(name) for name in header.point_format.dimension_names),
|
|
314
|
+
version=str(header.version),
|
|
315
|
+
point_format=int(header.point_format.id),
|
|
316
|
+
spacing=float(reader.copc_info.spacing),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _check_bbox(bbox: tuple[float, float, float, float]) -> None:
|
|
321
|
+
"""Validate a bounding box's shape, finiteness and orientation. Needs no file, so it runs before any open.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the file's CRS.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ValueError: when ``bbox`` is not four numbers, holds a non-finite value, or either axis is inverted.
|
|
328
|
+
"""
|
|
329
|
+
if len(bbox) != 4:
|
|
330
|
+
raise ValueError(f"bbox must be (xmin, ymin, xmax, ymax); got {len(bbox)} value(s): {bbox!r}")
|
|
331
|
+
xmin, ymin, xmax, ymax = (float(value) for value in bbox)
|
|
332
|
+
# nan slips the inversion test below (nan > nan is False), so reject non-finite corners explicitly first.
|
|
333
|
+
if not all(math.isfinite(value) for value in (xmin, ymin, xmax, ymax)):
|
|
334
|
+
raise ValueError(f"bbox corners must be finite; got (xmin, ymin, xmax, ymax) = {(xmin, ymin, xmax, ymax)!r}")
|
|
335
|
+
if xmin > xmax or ymin > ymax:
|
|
336
|
+
raise ValueError(f"bbox is inverted: (xmin, ymin, xmax, ymax) = {(xmin, ymin, xmax, ymax)!r}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _as_bounds(bbox: tuple[float, float, float, float], header: laspy.LasHeader) -> Bounds:
|
|
340
|
+
"""Turn a validated 2-D bounding box into the laspy ``Bounds`` the octree query takes, clamped to the file.
|
|
341
|
+
|
|
342
|
+
The box stays 2-D on purpose: laspy extends it over the file's full z range before querying, so no point is
|
|
343
|
+
ever dropped for its elevation. Filtering in z is a cloud operation, not a read window.
|
|
344
|
+
|
|
345
|
+
The box is clamped to the file's own x/y extent. laspy clips in scaled ``int32`` space
|
|
346
|
+
(``np.round((mins - offset) / scale)``), which overflows for coordinates past roughly ``±2.1e7`` at a
|
|
347
|
+
centimetre scale and silently returns zero points. Clamping first means a generously large or sentinel box
|
|
348
|
+
reads the whole cloud, which is what "give me everything in this area" has to mean.
|
|
349
|
+
|
|
350
|
+
``bbox`` is assumed already validated by :func:`_check_bbox` (that check needs no header, so it runs before
|
|
351
|
+
the file is opened); this one needs the header's extent, so it runs after.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the file's CRS, already checked.
|
|
355
|
+
header: the open file's header, read for its ``x_min`` / ``y_min`` / ``x_max`` / ``y_max`` extent.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
The equivalent :class:`laspy.copc.Bounds`, clamped to the file's extent.
|
|
359
|
+
"""
|
|
360
|
+
xmin, ymin, xmax, ymax = (float(value) for value in bbox)
|
|
361
|
+
return Bounds(
|
|
362
|
+
mins=np.array([max(xmin, header.x_min), max(ymin, header.y_min)]),
|
|
363
|
+
maxs=np.array([min(xmax, header.x_max), min(ymax, header.y_max)]),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_window(
|
|
368
|
+
source: Source,
|
|
369
|
+
*,
|
|
370
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
371
|
+
resolution: float | None = None,
|
|
372
|
+
level: int | range | None = None,
|
|
373
|
+
dimensions: tuple[str, ...] | None = None,
|
|
374
|
+
) -> dict[str, np.ndarray]:
|
|
375
|
+
"""Read only the points a window selects, using the file's octree rather than scanning every record.
|
|
376
|
+
|
|
377
|
+
With no argument at all this reads the whole cloud, which is the honest default but also the expensive one -
|
|
378
|
+
the point of the function is to pass a window.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
source: the COPC file to open, as a path or an open file object.
|
|
382
|
+
bbox: ``(xmin, ymin, xmax, ymax)`` in the file's CRS. Only chunks whose octree voxel overlaps the box are
|
|
383
|
+
fetched, and the points are then clipped exactly to the box, to the float edge. 2-D: the full z
|
|
384
|
+
range is always kept. A box larger than the file is clamped to the file's extent; a non-finite
|
|
385
|
+
corner is rejected.
|
|
386
|
+
resolution: target point spacing in the data's units. The octree is visited only down to the level that
|
|
387
|
+
satisfies it, measured against :attr:`CopcHeaderInfo.spacing`, so a wide view stays cheap. Mutually
|
|
388
|
+
exclusive with ``level``.
|
|
389
|
+
level: an exact octree level, or a ``range`` of them, when a caller would rather name levels than a
|
|
390
|
+
spacing. Mutually exclusive with ``resolution``.
|
|
391
|
+
dimensions: dimension names to return, case-insensitive. ``None`` returns every dimension the package
|
|
392
|
+
maps onto a :class:`~pointclouds.points.PointCloud` field. This selects what comes back, not what is
|
|
393
|
+
decompressed: laspy decompresses every selected point in full, and the dimensions not asked for are
|
|
394
|
+
dropped afterwards. It trims the result, not the read.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
A mapping of lower-case dimension name to array, under the same contract as
|
|
398
|
+
:func:`pointclouds.io.las.read_points`: a dimension the point format does not declare comes back as a
|
|
399
|
+
zero-length array, never as a missing key. A window that selects nothing gives zero-length arrays
|
|
400
|
+
throughout.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
FileNotFoundError: when a path does not exist.
|
|
404
|
+
ValueError: when a path's suffix is not ``.laz``; when the file carries no COPC octree; when ``bbox`` is
|
|
405
|
+
malformed, non-finite or inverted; when ``resolution`` is not a positive finite number; when
|
|
406
|
+
``level`` is not a non-negative ``int`` or ``range`` of octree levels (a ``bool``, a float, a
|
|
407
|
+
negative level, or an empty range are all rejected); or when ``resolution`` and ``level`` are both
|
|
408
|
+
given.
|
|
409
|
+
KeyError: when a requested dimension is not one this package reads.
|
|
410
|
+
|
|
411
|
+
Examples:
|
|
412
|
+
A bounding box reads a corner of the cloud, fetching only the chunks that reach into it:
|
|
413
|
+
|
|
414
|
+
>>> arrays = read_window( # doctest: +SKIP
|
|
415
|
+
... "examples/data/sample.copc.laz", bbox=(155000, 463000, 155020, 463020)
|
|
416
|
+
... )
|
|
417
|
+
>>> arrays["x"].size # 121 of the file's 1600 points # doctest: +SKIP
|
|
418
|
+
121
|
|
419
|
+
|
|
420
|
+
A resolution reads the whole extent sparsely instead, which is what a zoomed-out view wants:
|
|
421
|
+
|
|
422
|
+
>>> coarse = read_window("examples/data/sample.copc.laz", resolution=7.8) # doctest: +SKIP
|
|
423
|
+
>>> coarse["x"].size, coarse["classification"].size # doctest: +SKIP
|
|
424
|
+
(100, 100)
|
|
425
|
+
|
|
426
|
+
The two compose, and a dimension list selects which arrays come back:
|
|
427
|
+
|
|
428
|
+
>>> corner = read_window( # doctest: +SKIP
|
|
429
|
+
... "examples/data/sample.copc.laz",
|
|
430
|
+
... bbox=(155000, 463000, 155020, 463020),
|
|
431
|
+
... resolution=7.8,
|
|
432
|
+
... dimensions=("X", "Y"),
|
|
433
|
+
... )
|
|
434
|
+
>>> sorted(corner) # doctest: +SKIP
|
|
435
|
+
['x', 'y']
|
|
436
|
+
"""
|
|
437
|
+
requested = _validate_window(bbox, resolution, level, dimensions) # before the open: no I/O on bad input
|
|
438
|
+
with _reader(source) as reader:
|
|
439
|
+
return _query_window(reader, bbox=bbox, resolution=resolution, level=level, requested=requested)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _validate_level(level: int | range | None) -> None:
|
|
443
|
+
"""Reject a ``level`` laspy would mishandle or silently misread, before it reaches the query.
|
|
444
|
+
|
|
445
|
+
laspy does ``level.stop`` for a non-``int`` and turns a bare ``True`` into ``range(1, 2)``, so a float leaks
|
|
446
|
+
an ``AttributeError`` and a bool passes unnoticed. An inverted/empty ``range`` selects nothing, the same
|
|
447
|
+
mistake the inverted-``bbox`` guard rejects.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
level: the ``level`` argument to :func:`read_window`, before use.
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
ValueError: when ``level`` is a ``bool``, a non-``int``/``range`` type, a negative ``int``, a ``range``
|
|
454
|
+
with a negative start, or an empty ``range``.
|
|
455
|
+
"""
|
|
456
|
+
if level is None:
|
|
457
|
+
return
|
|
458
|
+
# bool is an int subclass; True would silently mean range(1, 2), so exclude it explicitly.
|
|
459
|
+
if isinstance(level, bool) or not isinstance(level, (int, range)):
|
|
460
|
+
raise ValueError(f"level must be a non-negative int or a range of octree levels; got {level!r}")
|
|
461
|
+
if isinstance(level, int):
|
|
462
|
+
if level < 0:
|
|
463
|
+
raise ValueError(f"level must be a non-negative octree level; got {level!r}")
|
|
464
|
+
return
|
|
465
|
+
if level.start < 0:
|
|
466
|
+
raise ValueError(f"level range must start at a non-negative octree level; got {level!r}")
|
|
467
|
+
if len(level) == 0:
|
|
468
|
+
raise ValueError(f"level range is empty, so it selects nothing; got {level!r}")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _validate_window(
|
|
472
|
+
bbox: tuple[float, float, float, float] | None,
|
|
473
|
+
resolution: float | None,
|
|
474
|
+
level: int | range | None,
|
|
475
|
+
dimensions: tuple[str, ...] | None,
|
|
476
|
+
) -> tuple[str, ...]:
|
|
477
|
+
"""Run every header-independent window check and resolve the dimension list, before any file is opened.
|
|
478
|
+
|
|
479
|
+
None of these needs the file, so running them here lets a malformed window be rejected before paying the
|
|
480
|
+
open - on a remote source, the open is a header parse plus a hierarchy fetch worth avoiding.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
bbox: see :func:`read_window`.
|
|
484
|
+
resolution: see :func:`read_window`.
|
|
485
|
+
level: see :func:`read_window`.
|
|
486
|
+
dimensions: see :func:`read_window`.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
The resolved, lower-cased, de-duplicated dimension names (see :func:`pointclouds.io._records.resolve`).
|
|
490
|
+
|
|
491
|
+
Raises:
|
|
492
|
+
ValueError: on a bad bbox, resolution, level, or both resolution and level.
|
|
493
|
+
KeyError: when a requested dimension is not one this package reads.
|
|
494
|
+
"""
|
|
495
|
+
if resolution is not None and level is not None:
|
|
496
|
+
raise ValueError("pass either resolution= or level=, not both; they are two ways to say the same thing")
|
|
497
|
+
if resolution is not None and not (math.isfinite(resolution) and resolution > 0):
|
|
498
|
+
# nan and inf both slip a bare `resolution <= 0` and then leak a laspy/lazrs conversion error.
|
|
499
|
+
raise ValueError(f"resolution must be a positive, finite number; got {resolution!r}")
|
|
500
|
+
_validate_level(level)
|
|
501
|
+
if bbox is not None:
|
|
502
|
+
_check_bbox(bbox)
|
|
503
|
+
return _records.resolve(dimensions)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _query_window(
|
|
507
|
+
reader: laspy.CopcReader,
|
|
508
|
+
*,
|
|
509
|
+
bbox: tuple[float, float, float, float] | None,
|
|
510
|
+
resolution: float | None,
|
|
511
|
+
level: int | range | None,
|
|
512
|
+
requested: tuple[str, ...],
|
|
513
|
+
) -> dict[str, np.ndarray]:
|
|
514
|
+
"""Run one octree query on an already-open reader and return the record arrays.
|
|
515
|
+
|
|
516
|
+
Split out of :func:`read_window` so the one open can be shared: :func:`_read_window_with_crs` reads the CRS
|
|
517
|
+
and the window from the same reader rather than opening the source twice. The arguments are assumed already
|
|
518
|
+
validated by :func:`_validate_window`, which runs before the open.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
reader: an open :class:`laspy.CopcReader`.
|
|
522
|
+
bbox: see :func:`read_window`.
|
|
523
|
+
resolution: see :func:`read_window`.
|
|
524
|
+
level: see :func:`read_window`.
|
|
525
|
+
requested: the resolved dimension names from :func:`_validate_window`.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
The record-array mapping, as :func:`read_window` documents.
|
|
529
|
+
"""
|
|
530
|
+
# An exact bbox clip needs x and y even when the caller did not ask for them; read them, then drop them
|
|
531
|
+
# again if they were not requested.
|
|
532
|
+
needed = requested if bbox is None else tuple(dict.fromkeys(("x", "y", *requested)))
|
|
533
|
+
bounds = None if bbox is None else _as_bounds(bbox, reader.header)
|
|
534
|
+
points = reader.query(bounds=bounds, resolution=resolution, level=level)
|
|
535
|
+
arrays = _records.extract(points, needed)
|
|
536
|
+
if bbox is not None:
|
|
537
|
+
arrays = _records.clip_to_box(arrays, bbox)
|
|
538
|
+
arrays = {name: arrays[name] for name in requested}
|
|
539
|
+
return arrays
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _read_window_with_crs(
|
|
543
|
+
source: Source,
|
|
544
|
+
*,
|
|
545
|
+
bbox: tuple[float, float, float, float] | None = None,
|
|
546
|
+
resolution: float | None = None,
|
|
547
|
+
level: int | range | None = None,
|
|
548
|
+
dimensions: tuple[str, ...] | None = None,
|
|
549
|
+
) -> tuple[CRS | None, dict[str, np.ndarray]]:
|
|
550
|
+
"""Read a window and the file's CRS from a single open of ``source``.
|
|
551
|
+
|
|
552
|
+
:class:`~pointclouds.points.PointCloud` needs both to build a windowed cloud. Doing it in one open avoids a
|
|
553
|
+
second header parse and hierarchy fetch - a doubled round-trip on the remote source this feature targets,
|
|
554
|
+
and a TOCTOU window on a path that is rewritten between the two reads.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
source: the COPC file, as a path or an open file object.
|
|
558
|
+
bbox: see :func:`read_window`.
|
|
559
|
+
resolution: see :func:`read_window`.
|
|
560
|
+
level: see :func:`read_window`.
|
|
561
|
+
dimensions: see :func:`read_window`.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
``(crs, arrays)`` - the file's CRS (or ``None``) and the record-array mapping.
|
|
565
|
+
"""
|
|
566
|
+
requested = _validate_window(bbox, resolution, level, dimensions) # before the open: no I/O on bad input
|
|
567
|
+
with _reader(source) as reader:
|
|
568
|
+
crs = reader.header.parse_crs()
|
|
569
|
+
arrays = _query_window(reader, bbox=bbox, resolution=resolution, level=level, requested=requested)
|
|
570
|
+
return crs, arrays
|