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