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,107 @@
1
+ """Concatenate several clouds into one. (Phase 2 - T-9).
2
+
3
+ :func:`merge_arrays` assembles the combined coordinate and attribute arrays and settles the CRS, then
4
+ :meth:`PointCloud.merge` builds the cloud from them. Keeping the assembly here, separate from construction,
5
+ avoids importing :class:`PointCloud` at module load. Pure numpy - no GDAL, no pyramids.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import numpy as np
14
+
15
+ if TYPE_CHECKING:
16
+ from pyproj import CRS
17
+
18
+ from pointclouds.points import PointCloud
19
+
20
+ __all__ = ["merge_arrays"]
21
+
22
+ #: The attribute dimensions that are carried through a merge, alongside x/y/z.
23
+ _ATTRIBUTES = ("intensity", "classification", "return_number")
24
+
25
+
26
+ def merge_arrays(clouds: Sequence[PointCloud]) -> dict[str, Any]:
27
+ """Concatenate the clouds' arrays and resolve a single CRS.
28
+
29
+ A zero-point cloud is a no-op participant: it contributes no rows and takes no part in resolving the CRS or
30
+ in deciding which attributes are present, so it never turns a valid merge into a conflict.
31
+
32
+ Args:
33
+ clouds: the clouds to merge, in order; each is loaded if its records are not in memory yet.
34
+
35
+ Returns:
36
+ A mapping of ``PointCloud`` constructor keyword to value: ``x``/``y``/``z``, the ``crs``, and each
37
+ attribute that every point-carrying cloud has. An attribute no cloud carries is omitted; the CRS is the
38
+ one shared by the point-carrying clouds, or ``None`` when none carry points.
39
+
40
+ Raises:
41
+ ValueError: when ``clouds`` is empty, when the clouds disagree on CRS, or when an attribute is present
42
+ on some clouds but absent on others (so the concatenation would misalign).
43
+
44
+ Examples:
45
+ >>> import numpy as np
46
+ >>> from pointclouds import PointCloud
47
+ >>> a = PointCloud(x=np.zeros(2), y=np.zeros(2), z=np.zeros(2))
48
+ >>> b = PointCloud(x=np.ones(3), y=np.zeros(3), z=np.zeros(3))
49
+ >>> arrays = merge_arrays([a, b])
50
+ >>> arrays["x"].tolist()
51
+ [0.0, 0.0, 1.0, 1.0, 1.0]
52
+ >>> sorted(arrays)
53
+ ['crs', 'x', 'y', 'z']
54
+ """
55
+ clouds = list(clouds)
56
+ if not clouds:
57
+ raise ValueError("merge needs at least one cloud")
58
+
59
+ point_counts = [c.x.shape[0] for c in clouds] # also forces any lazy cloud to load
60
+ carrying = [c for c, count in zip(clouds, point_counts, strict=True) if count > 0]
61
+ merged: dict[str, Any] = {
62
+ "x": np.concatenate([c.x for c in clouds]),
63
+ "y": np.concatenate([c.y for c in clouds]),
64
+ "z": np.concatenate([c.z for c in clouds]),
65
+ # A zero-point cloud contributes no points, so its CRS does not take part either (matching the attribute
66
+ # scoping below): resolve across the clouds that actually carry points, or None when none do.
67
+ "crs": _resolve_crs(carrying),
68
+ }
69
+
70
+ for name in _ATTRIBUTES:
71
+ arrays = [getattr(c, name) for c in clouds]
72
+ # A zero-point cloud (e.g. one filtered to empty) contributes no rows, so it neither has nor needs the
73
+ # attribute: judge presence only among the clouds that actually carry points. Its size-0 array still
74
+ # concatenates harmlessly when the attribute is kept.
75
+ present = [values.size > 0 for values, count in zip(arrays, point_counts, strict=True) if count > 0]
76
+ if present and all(present):
77
+ merged[name] = np.concatenate(arrays)
78
+ elif any(present):
79
+ raise ValueError(
80
+ f"cannot merge: {name!r} is present on some clouds but not others; "
81
+ "give every cloud the dimension, or none"
82
+ )
83
+ return merged
84
+
85
+
86
+ def _resolve_crs(clouds: Sequence[PointCloud]) -> CRS | None:
87
+ """Return the one CRS the clouds share, or raise when they disagree.
88
+
89
+ ``None`` (no known SRS) is treated as a distinct value, so merging a cloud that has a CRS with one that does
90
+ not is a conflict rather than a silent guess. An empty sequence (e.g. only zero-point clouds) resolves to
91
+ ``None``.
92
+ """
93
+ distinct: list[CRS | None] = []
94
+ for crs in (c.crs for c in clouds):
95
+ if not any(_crs_equal(crs, seen) for seen in distinct):
96
+ distinct.append(crs)
97
+ if len(distinct) > 1:
98
+ shown = ", ".join("None" if c is None else c.to_string() for c in distinct)
99
+ raise ValueError(f"cannot merge clouds with conflicting CRSs: {{{shown}}}")
100
+ return distinct[0] if distinct else None
101
+
102
+
103
+ def _crs_equal(a: CRS | None, b: CRS | None) -> bool:
104
+ """Whether two CRSs are the same, counting ``None`` as equal only to ``None``."""
105
+ if a is None or b is None:
106
+ return a is None and b is None
107
+ return a == b