pointcloudkit 0.1.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.
- pointcloudkit/__init__.py +17 -0
- pointcloudkit/io/__init__.py +62 -0
- pointcloudkit/io/bin.py +30 -0
- pointcloudkit/io/las.py +92 -0
- pointcloudkit/io/pcd.py +280 -0
- pointcloudkit/io/ply.py +196 -0
- pointcloudkit/point_cloud.py +120 -0
- pointcloudkit-0.1.0.dist-info/METADATA +149 -0
- pointcloudkit-0.1.0.dist-info/RECORD +11 -0
- pointcloudkit-0.1.0.dist-info/WHEEL +4 -0
- pointcloudkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def convert(src: str, dst: str, binary: bool = True) -> None:
|
|
7
|
+
"""Convert a point cloud file from one format to another.
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
convert("scan.las", "scan.pcd")
|
|
12
|
+
convert("scan.bin", "scan.ply", binary=False)
|
|
13
|
+
"""
|
|
14
|
+
PointCloud.read(src).write(dst, binary=binary)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ['PointCloud', 'convert']
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
10
|
+
|
|
11
|
+
# Populated lazily on first call to avoid import overhead for unused formats.
|
|
12
|
+
_READERS: dict = {}
|
|
13
|
+
_WRITERS: dict = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_loaded() -> None:
|
|
17
|
+
if _READERS:
|
|
18
|
+
return
|
|
19
|
+
from pointcloudkit.io import pcd, ply, las
|
|
20
|
+
from pointcloudkit.io import bin as bin_
|
|
21
|
+
|
|
22
|
+
_READERS['.pcd'] = pcd.PCDFile.read
|
|
23
|
+
_READERS['.ply'] = ply.PLYFile.read
|
|
24
|
+
_READERS['.las'] = las.LASFile.read
|
|
25
|
+
_READERS['.bin'] = bin_.BINFile.read
|
|
26
|
+
|
|
27
|
+
_WRITERS['.pcd'] = pcd.PCDFile.write
|
|
28
|
+
_WRITERS['.ply'] = ply.PLYFile.write
|
|
29
|
+
_WRITERS['.las'] = las.LASFile.write
|
|
30
|
+
# .bin is read-only; users convert via .pcd or .ply
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read(path: str) -> PointCloud:
|
|
34
|
+
"""Read a point cloud from *path*. Format is inferred from the file suffix.
|
|
35
|
+
|
|
36
|
+
Supported: .pcd, .ply, .las, .bin
|
|
37
|
+
"""
|
|
38
|
+
_ensure_loaded()
|
|
39
|
+
ext = Path(path).suffix.lower()
|
|
40
|
+
reader = _READERS.get(ext)
|
|
41
|
+
if reader is None:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Unsupported format for reading: {ext!r}. "
|
|
44
|
+
f"Supported extensions: {sorted(_READERS)}"
|
|
45
|
+
)
|
|
46
|
+
return reader(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write(pc: PointCloud, path: str, binary: bool = True) -> None:
|
|
50
|
+
"""Write *pc* to *path*. Format is inferred from the file suffix.
|
|
51
|
+
|
|
52
|
+
Supported: .pcd, .ply, .las
|
|
53
|
+
"""
|
|
54
|
+
_ensure_loaded()
|
|
55
|
+
ext = Path(path).suffix.lower()
|
|
56
|
+
writer = _WRITERS.get(ext)
|
|
57
|
+
if writer is None:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Unsupported format for writing: {ext!r}. "
|
|
60
|
+
f"Supported extensions: {sorted(_WRITERS)}"
|
|
61
|
+
)
|
|
62
|
+
writer(pc, path, binary=binary)
|
pointcloudkit/io/bin.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BINFile:
|
|
9
|
+
"""Read Velodyne binary (.bin) point cloud files (read-only).
|
|
10
|
+
|
|
11
|
+
BIN files store points as a flat sequence of float32 values.
|
|
12
|
+
The default layout is KITTI format: x, y, z, intensity (4 values/point).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def read(path: str, num_attrs: int = 4) -> PointCloud:
|
|
17
|
+
"""Read a Velodyne BIN file and return a PointCloud.
|
|
18
|
+
|
|
19
|
+
:param path: path to the .bin file
|
|
20
|
+
:param num_attrs: number of float32 values per point (default 4 = x, y, z, intensity)
|
|
21
|
+
"""
|
|
22
|
+
data = np.fromfile(path, dtype=np.float32).reshape(-1, num_attrs)
|
|
23
|
+
|
|
24
|
+
position = data[:, :3].astype(np.float64)
|
|
25
|
+
pc = PointCloud(position=position)
|
|
26
|
+
|
|
27
|
+
if num_attrs > 3:
|
|
28
|
+
pc.intensity = data[:, 3].copy() # already float32
|
|
29
|
+
|
|
30
|
+
return pc
|
pointcloudkit/io/las.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import laspy
|
|
5
|
+
|
|
6
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _has_rgb(las) -> bool:
|
|
10
|
+
return all(hasattr(las, c) for c in ('red', 'green', 'blue'))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _has_intensity(las) -> bool:
|
|
14
|
+
return hasattr(las, 'intensity')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_to_uint8(arr: np.ndarray) -> np.ndarray:
|
|
18
|
+
"""Convert LAS uint16 colour channel (0-65535) to uint8 (0-255).
|
|
19
|
+
|
|
20
|
+
LAS files produced by different scanners may store 8-bit values in the
|
|
21
|
+
low byte *or* the high byte of a uint16 field. Values that fit in a byte
|
|
22
|
+
(≤ 255) are kept as-is; values that don't are right-shifted by 8.
|
|
23
|
+
"""
|
|
24
|
+
arr = np.asarray(arr, dtype=np.uint32)
|
|
25
|
+
return np.where(arr > 255, arr >> 8, arr).astype(np.uint8)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _should_use_double(position: np.ndarray) -> bool:
|
|
29
|
+
"""Return True if any coordinate magnitude exceeds float32 precision."""
|
|
30
|
+
return float(np.abs(position).max()) > 1e6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LASFile:
|
|
34
|
+
"""Read and write LAS point cloud files."""
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def read(path: str) -> PointCloud:
|
|
38
|
+
"""Read a LAS file and return a PointCloud."""
|
|
39
|
+
las = laspy.read(path)
|
|
40
|
+
|
|
41
|
+
position = np.column_stack([
|
|
42
|
+
np.asarray(las.x, dtype=np.float64),
|
|
43
|
+
np.asarray(las.y, dtype=np.float64),
|
|
44
|
+
np.asarray(las.z, dtype=np.float64),
|
|
45
|
+
])
|
|
46
|
+
pc = PointCloud(position=position)
|
|
47
|
+
|
|
48
|
+
if _has_intensity(las):
|
|
49
|
+
pc.intensity = np.asarray(las.intensity, dtype=np.float32)
|
|
50
|
+
|
|
51
|
+
if _has_rgb(las):
|
|
52
|
+
r = _normalize_to_uint8(np.asarray(las.red))
|
|
53
|
+
g = _normalize_to_uint8(np.asarray(las.green))
|
|
54
|
+
b = _normalize_to_uint8(np.asarray(las.blue))
|
|
55
|
+
pc.rgb = np.column_stack([r, g, b]).astype(np.uint8)
|
|
56
|
+
|
|
57
|
+
return pc
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def write(pc: PointCloud, path: str, binary: bool = True) -> None:
|
|
61
|
+
"""Write a PointCloud to a LAS file.
|
|
62
|
+
|
|
63
|
+
LAS is always a binary format; the *binary* parameter is accepted for
|
|
64
|
+
API consistency but has no effect.
|
|
65
|
+
|
|
66
|
+
Point format 2 is chosen when RGB data is present; format 0 otherwise.
|
|
67
|
+
Intensity (float32) is scaled to uint16 via clipping to [0, 65535].
|
|
68
|
+
RGB (uint8) is scaled to uint16 by shifting left 8 bits.
|
|
69
|
+
"""
|
|
70
|
+
n = len(pc)
|
|
71
|
+
has_rgb_data = len(pc.rgb) == n
|
|
72
|
+
has_intensity_data = len(pc.intensity) == n
|
|
73
|
+
|
|
74
|
+
point_format = 2 if has_rgb_data else 0
|
|
75
|
+
header = laspy.LasHeader(point_format=point_format, version="1.2")
|
|
76
|
+
las = laspy.LasData(header=header)
|
|
77
|
+
|
|
78
|
+
las.x = pc.position[:, 0]
|
|
79
|
+
las.y = pc.position[:, 1]
|
|
80
|
+
las.z = pc.position[:, 2]
|
|
81
|
+
|
|
82
|
+
if has_intensity_data:
|
|
83
|
+
# Intensity is stored as uint16 in LAS; clip to valid range
|
|
84
|
+
las.intensity = np.clip(pc.intensity, 0, 65535).astype(np.uint16)
|
|
85
|
+
|
|
86
|
+
if has_rgb_data:
|
|
87
|
+
# Scale uint8 (0-255) → uint16 (0-65535) by shifting left 8 bits
|
|
88
|
+
las.red = pc.rgb[:, 0].astype(np.uint16) << 8
|
|
89
|
+
las.green = pc.rgb[:, 1].astype(np.uint16) << 8
|
|
90
|
+
las.blue = pc.rgb[:, 2].astype(np.uint16) << 8
|
|
91
|
+
|
|
92
|
+
las.write(path)
|
pointcloudkit/io/pcd.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
9
|
+
|
|
10
|
+
# Points written per iteration during binary streaming writes.
|
|
11
|
+
# Keeps peak extra memory at CHUNK × bytes_per_point (~8 MB for 128 B/pt).
|
|
12
|
+
_CHUNK = 65_536
|
|
13
|
+
|
|
14
|
+
# Beyond this absolute coordinate value float32 loses sub-millimetre precision
|
|
15
|
+
# (~7 significant digits → 1 mm resolution at 10 000 m).
|
|
16
|
+
_F32_POSITION_THRESHOLD = 1e4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PCDFile:
|
|
20
|
+
"""Read and write PCD (Point Cloud Data) files."""
|
|
21
|
+
|
|
22
|
+
_KNOWN_FIELDS = {'x', 'y', 'z', 'intensity', 'rgb'}
|
|
23
|
+
|
|
24
|
+
_STRUCT_FMT = {
|
|
25
|
+
('F', 4): 'f', ('F', 8): 'd',
|
|
26
|
+
('U', 1): 'B', ('U', 2): 'H', ('U', 4): 'I', ('U', 8): 'Q',
|
|
27
|
+
('I', 1): 'b', ('I', 2): 'h', ('I', 4): 'i', ('I', 8): 'q',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# ── Reader ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def read(cls, path: str) -> PointCloud:
|
|
34
|
+
"""Read a PCD file (ASCII or binary) and return a PointCloud."""
|
|
35
|
+
pc = PointCloud()
|
|
36
|
+
|
|
37
|
+
# Temporary list buffers; replaced by numpy arrays after all points are read
|
|
38
|
+
pc._buf_x = []
|
|
39
|
+
pc._buf_y = []
|
|
40
|
+
pc._buf_z = []
|
|
41
|
+
pc._buf_intensity = []
|
|
42
|
+
pc._buf_rgb = []
|
|
43
|
+
|
|
44
|
+
with open(path, 'rb') as f:
|
|
45
|
+
hdr = cls._parse_header(f)
|
|
46
|
+
fields = hdr['fields']
|
|
47
|
+
types = hdr['types']
|
|
48
|
+
sizes = hdr['sizes']
|
|
49
|
+
counts = hdr['counts']
|
|
50
|
+
n_pts = hdr.get('points', 0)
|
|
51
|
+
fmt = hdr.get('data', 'ascii')
|
|
52
|
+
|
|
53
|
+
for field in fields:
|
|
54
|
+
if field not in cls._KNOWN_FIELDS:
|
|
55
|
+
pc.extra[field] = []
|
|
56
|
+
|
|
57
|
+
field_type = dict(zip(fields, types))
|
|
58
|
+
|
|
59
|
+
if fmt == 'ascii':
|
|
60
|
+
cls._read_ascii(f, pc, fields, field_type, counts)
|
|
61
|
+
elif fmt == 'binary':
|
|
62
|
+
cls._read_binary(f, pc, fields, field_type, types, sizes, counts, n_pts)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(f"Unsupported PCD data format: {fmt!r}")
|
|
65
|
+
|
|
66
|
+
if pc._buf_x:
|
|
67
|
+
pc.position = np.column_stack([pc._buf_x, pc._buf_y, pc._buf_z]).astype(np.float64)
|
|
68
|
+
pc.intensity = np.array(pc._buf_intensity, dtype=np.float32)
|
|
69
|
+
pc.rgb = np.array(pc._buf_rgb, dtype=np.uint8).reshape(-1, 3)
|
|
70
|
+
pc.extra = {k: np.array(v) for k, v in pc.extra.items()}
|
|
71
|
+
|
|
72
|
+
del pc._buf_x, pc._buf_y, pc._buf_z, pc._buf_intensity, pc._buf_rgb
|
|
73
|
+
|
|
74
|
+
return pc
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _parse_header(f) -> dict:
|
|
78
|
+
hdr = {'fields': [], 'types': [], 'sizes': [], 'counts': []}
|
|
79
|
+
while True:
|
|
80
|
+
line = f.readline()
|
|
81
|
+
if isinstance(line, bytes):
|
|
82
|
+
line = line.decode('ascii', errors='ignore')
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith('#'):
|
|
85
|
+
continue
|
|
86
|
+
parts = line.split()
|
|
87
|
+
key, vals = parts[0].upper(), parts[1:]
|
|
88
|
+
if key == 'FIELDS': hdr['fields'] = vals
|
|
89
|
+
elif key == 'TYPE': hdr['types'] = vals
|
|
90
|
+
elif key == 'SIZE': hdr['sizes'] = [int(v) for v in vals]
|
|
91
|
+
elif key == 'COUNT': hdr['counts'] = [int(v) for v in vals]
|
|
92
|
+
elif key == 'POINTS': hdr['points'] = int(vals[0])
|
|
93
|
+
elif key == 'DATA':
|
|
94
|
+
hdr['data'] = vals[0].lower()
|
|
95
|
+
break
|
|
96
|
+
return hdr
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _read_ascii(cls, f, pc, fields, field_type, counts):
|
|
100
|
+
expected_tokens = sum(counts)
|
|
101
|
+
for line in f:
|
|
102
|
+
if isinstance(line, bytes):
|
|
103
|
+
line = line.decode('ascii', errors='ignore')
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
tokens = line.split()
|
|
109
|
+
if len(tokens) != expected_tokens:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
parsed = {}
|
|
113
|
+
valid = True
|
|
114
|
+
tok_i = 0
|
|
115
|
+
|
|
116
|
+
for field, count in zip(fields, counts):
|
|
117
|
+
typ = field_type[field]
|
|
118
|
+
raw = tokens[tok_i:tok_i + count]
|
|
119
|
+
tok_i += count
|
|
120
|
+
try:
|
|
121
|
+
if typ == 'F':
|
|
122
|
+
v = [float(t) for t in raw]
|
|
123
|
+
if any(not math.isfinite(x) for x in v):
|
|
124
|
+
if field in ('x', 'y', 'z'):
|
|
125
|
+
valid = False
|
|
126
|
+
break
|
|
127
|
+
v = [0.0] * count # zero out NaN/Inf in non-position fields
|
|
128
|
+
v = v[0] if count == 1 else v
|
|
129
|
+
else:
|
|
130
|
+
v = [int(float(t)) for t in raw]
|
|
131
|
+
v = v[0] if count == 1 else v
|
|
132
|
+
except (ValueError, OverflowError):
|
|
133
|
+
valid = False
|
|
134
|
+
break
|
|
135
|
+
parsed[field] = v
|
|
136
|
+
|
|
137
|
+
if valid:
|
|
138
|
+
cls._append_point(pc, fields, field_type, parsed)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def _read_binary(cls, f, pc, fields, field_type, types, sizes, counts, n_pts):
|
|
142
|
+
fmt_str = '<' + ''.join(
|
|
143
|
+
cls._STRUCT_FMT[(typ, size)] * count
|
|
144
|
+
for typ, size, count in zip(types, sizes, counts)
|
|
145
|
+
)
|
|
146
|
+
packer = struct.Struct(fmt_str)
|
|
147
|
+
point_size = packer.size
|
|
148
|
+
|
|
149
|
+
for _ in range(n_pts):
|
|
150
|
+
raw = f.read(point_size)
|
|
151
|
+
if len(raw) < point_size:
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
unpacked = packer.unpack(raw)
|
|
155
|
+
parsed = {}
|
|
156
|
+
valid = True
|
|
157
|
+
idx = 0
|
|
158
|
+
|
|
159
|
+
for field, count in zip(fields, counts):
|
|
160
|
+
typ = field_type[field]
|
|
161
|
+
v = unpacked[idx] if count == 1 else list(unpacked[idx:idx + count])
|
|
162
|
+
idx += count
|
|
163
|
+
|
|
164
|
+
if typ == 'F':
|
|
165
|
+
check = [v] if count == 1 else v
|
|
166
|
+
if any(not math.isfinite(x) for x in check):
|
|
167
|
+
if field in ('x', 'y', 'z'):
|
|
168
|
+
valid = False
|
|
169
|
+
break
|
|
170
|
+
v = 0.0 if count == 1 else [0.0] * count # zero out NaN/Inf in non-position fields
|
|
171
|
+
|
|
172
|
+
parsed[field] = v
|
|
173
|
+
|
|
174
|
+
if valid:
|
|
175
|
+
cls._append_point(pc, fields, field_type, parsed)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def _append_point(cls, pc, fields, field_type, parsed):
|
|
179
|
+
if 'x' in parsed: pc._buf_x.append(parsed['x'])
|
|
180
|
+
if 'y' in parsed: pc._buf_y.append(parsed['y'])
|
|
181
|
+
if 'z' in parsed: pc._buf_z.append(parsed['z'])
|
|
182
|
+
if 'intensity' in parsed: pc._buf_intensity.append(parsed['intensity'])
|
|
183
|
+
if 'rgb' in parsed:
|
|
184
|
+
pc._buf_rgb.append(cls._decode_rgb(parsed['rgb'], field_type['rgb']))
|
|
185
|
+
for field, val in parsed.items():
|
|
186
|
+
if field in pc.extra:
|
|
187
|
+
pc.extra[field].append(val)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _decode_rgb(val, typ: str) -> tuple:
|
|
191
|
+
"""Decode a packed RGB value (integer or reinterpreted float) into (r, g, b)."""
|
|
192
|
+
if typ == 'F':
|
|
193
|
+
# PCL packs RGB into a float's bit pattern
|
|
194
|
+
packed = struct.unpack('I', struct.pack('f', float(val)))[0]
|
|
195
|
+
else:
|
|
196
|
+
packed = int(val)
|
|
197
|
+
return ((packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF)
|
|
198
|
+
|
|
199
|
+
# ── Writer ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
_KIND_TO_PCD_TYPE = {'f': 'F', 'u': 'U', 'i': 'I'}
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def write(cls, pc: PointCloud, output_path: str, binary: bool = True) -> None:
|
|
205
|
+
"""Write a PointCloud to a PCD file.
|
|
206
|
+
|
|
207
|
+
All non-empty fields are written: position (x, y, z), intensity, rgb,
|
|
208
|
+
and any 1-D arrays in pc.extra.
|
|
209
|
+
|
|
210
|
+
Binary mode uses chunked writes so that peak extra memory stays at
|
|
211
|
+
_CHUNK × bytes_per_point regardless of total cloud size.
|
|
212
|
+
"""
|
|
213
|
+
n = len(pc)
|
|
214
|
+
|
|
215
|
+
# Each entry: (field_name, little-endian np.dtype, PCD type char, PCD size in bytes, 1-D array)
|
|
216
|
+
fields_info = []
|
|
217
|
+
|
|
218
|
+
use_f64 = n > 0 and np.max(np.abs(pc.position)) > _F32_POSITION_THRESHOLD
|
|
219
|
+
xyz_dt = np.dtype('<f8') if use_f64 else np.dtype('<f4')
|
|
220
|
+
fields_info.append(('x', xyz_dt, 'F', xyz_dt.itemsize, pc.position[:, 0].astype(xyz_dt)))
|
|
221
|
+
fields_info.append(('y', xyz_dt, 'F', xyz_dt.itemsize, pc.position[:, 1].astype(xyz_dt)))
|
|
222
|
+
fields_info.append(('z', xyz_dt, 'F', xyz_dt.itemsize, pc.position[:, 2].astype(xyz_dt)))
|
|
223
|
+
|
|
224
|
+
if len(pc.intensity) == n:
|
|
225
|
+
idt = pc.intensity.dtype.newbyteorder('<')
|
|
226
|
+
fields_info.append(('intensity', idt, 'F', pc.intensity.dtype.itemsize, pc.intensity))
|
|
227
|
+
|
|
228
|
+
if len(pc.rgb) == n:
|
|
229
|
+
r = pc.rgb[:, 0].astype(np.uint32)
|
|
230
|
+
g = pc.rgb[:, 1].astype(np.uint32)
|
|
231
|
+
b = pc.rgb[:, 2].astype(np.uint32)
|
|
232
|
+
packed_rgb = (r << 16) | (g << 8) | b
|
|
233
|
+
fields_info.append(('rgb', np.dtype('<u4'), 'U', 4, packed_rgb))
|
|
234
|
+
|
|
235
|
+
for name, arr in pc.extra.items():
|
|
236
|
+
if arr.ndim == 1 and len(arr) == n:
|
|
237
|
+
pcd_type = cls._KIND_TO_PCD_TYPE.get(arr.dtype.kind, 'F')
|
|
238
|
+
fields_info.append((name, arr.dtype.newbyteorder('<'), pcd_type, arr.dtype.itemsize, arr))
|
|
239
|
+
|
|
240
|
+
field_names = [fi[0] for fi in fields_info]
|
|
241
|
+
field_types = [fi[2] for fi in fields_info]
|
|
242
|
+
field_sizes = [str(fi[3]) for fi in fields_info]
|
|
243
|
+
|
|
244
|
+
header = "\n".join([
|
|
245
|
+
"# .PCD v0.7 - Point Cloud Data file format",
|
|
246
|
+
"VERSION 0.7",
|
|
247
|
+
f"FIELDS {' '.join(field_names)}",
|
|
248
|
+
f"SIZE {' '.join(field_sizes)}",
|
|
249
|
+
f"TYPE {' '.join(field_types)}",
|
|
250
|
+
f"COUNT {' '.join(['1'] * len(field_names))}",
|
|
251
|
+
f"WIDTH {n}",
|
|
252
|
+
"HEIGHT 1",
|
|
253
|
+
"VIEWPOINT 0 0 0 1 0 0 0",
|
|
254
|
+
f"POINTS {n}",
|
|
255
|
+
"DATA binary" if binary else "DATA ascii",
|
|
256
|
+
]) + "\n"
|
|
257
|
+
|
|
258
|
+
with open(output_path, 'wb' if binary else 'w') as out:
|
|
259
|
+
out.write(header.encode('ascii') if binary else header)
|
|
260
|
+
|
|
261
|
+
if n == 0:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
if binary:
|
|
265
|
+
cls._write_binary(out, fields_info, n)
|
|
266
|
+
else:
|
|
267
|
+
arrays = [fi[4] for fi in fields_info]
|
|
268
|
+
for i in range(n):
|
|
269
|
+
out.write(" ".join(str(arr[i]) for arr in arrays) + "\n")
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _write_binary(out, fields_info, n: int) -> None:
|
|
273
|
+
struct_dt = np.dtype([(fi[0], fi[1]) for fi in fields_info])
|
|
274
|
+
buf = np.empty(_CHUNK, dtype=struct_dt)
|
|
275
|
+
for start in range(0, n, _CHUNK):
|
|
276
|
+
end = min(start + _CHUNK, n)
|
|
277
|
+
chunk_size = end - start
|
|
278
|
+
for fi in fields_info:
|
|
279
|
+
buf[fi[0]][:chunk_size] = fi[4][start:end]
|
|
280
|
+
out.write(buf[:chunk_size].tobytes())
|
pointcloudkit/io/ply.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from pointcloudkit.point_cloud import PointCloud
|
|
6
|
+
|
|
7
|
+
_CHUNK = 65_536
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PLYFile:
|
|
11
|
+
"""Read and write PLY (Polygon File Format) point cloud files."""
|
|
12
|
+
|
|
13
|
+
_KNOWN_PROPS = {'x', 'y', 'z', 'intensity', 'red', 'green', 'blue'}
|
|
14
|
+
|
|
15
|
+
# PLY type name → numpy dtype char (endian-neutral)
|
|
16
|
+
_PLY_TO_NUMPY = {
|
|
17
|
+
'char': 'i1', 'int8': 'i1',
|
|
18
|
+
'uchar': 'u1', 'uint8': 'u1',
|
|
19
|
+
'short': 'i2', 'int16': 'i2',
|
|
20
|
+
'ushort': 'u2', 'uint16': 'u2',
|
|
21
|
+
'int': 'i4', 'int32': 'i4',
|
|
22
|
+
'uint': 'u4', 'uint32': 'u4',
|
|
23
|
+
'float': 'f4', 'float32': 'f4',
|
|
24
|
+
'double': 'f8', 'float64': 'f8',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# numpy (kind, itemsize) → PLY type string
|
|
28
|
+
_NUMPY_TO_PLY = {
|
|
29
|
+
('f', 4): 'float', ('f', 8): 'double',
|
|
30
|
+
('u', 1): 'uchar', ('u', 2): 'ushort', ('u', 4): 'uint', ('u', 8): 'uint',
|
|
31
|
+
('i', 1): 'char', ('i', 2): 'short', ('i', 4): 'int', ('i', 8): 'int',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ── Reader ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def read(cls, path: str) -> PointCloud:
|
|
38
|
+
"""Read a PLY file (ASCII or binary) and return a PointCloud."""
|
|
39
|
+
with open(path, 'rb') as f:
|
|
40
|
+
hdr = cls._parse_header(f)
|
|
41
|
+
fmt = hdr['format'] # 'ascii' | 'binary_little_endian' | 'binary_big_endian'
|
|
42
|
+
props = hdr['properties'] # list of (name, ply_type_str)
|
|
43
|
+
n_pts = hdr['count']
|
|
44
|
+
|
|
45
|
+
if fmt == 'ascii':
|
|
46
|
+
raw = cls._read_ascii(f, props, n_pts)
|
|
47
|
+
else:
|
|
48
|
+
endian = '<' if 'little' in fmt else '>'
|
|
49
|
+
raw = cls._read_binary(f, props, n_pts, endian)
|
|
50
|
+
|
|
51
|
+
pc = PointCloud()
|
|
52
|
+
|
|
53
|
+
if 'x' in raw:
|
|
54
|
+
pc.position = np.column_stack([raw['x'], raw['y'], raw['z']]).astype(np.float64)
|
|
55
|
+
if 'intensity' in raw:
|
|
56
|
+
pc.intensity = raw['intensity'].astype(np.float32)
|
|
57
|
+
if 'red' in raw:
|
|
58
|
+
pc.rgb = np.column_stack([raw['red'], raw['green'], raw['blue']]).astype(np.uint8)
|
|
59
|
+
|
|
60
|
+
for name, arr in raw.items():
|
|
61
|
+
if name not in cls._KNOWN_PROPS:
|
|
62
|
+
pc.extra[name] = arr
|
|
63
|
+
|
|
64
|
+
return pc
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _parse_header(f) -> dict:
|
|
68
|
+
"""Parse PLY header; leaves f positioned at the first data byte."""
|
|
69
|
+
magic = f.readline().decode('ascii', errors='ignore').strip()
|
|
70
|
+
if magic != 'ply':
|
|
71
|
+
raise ValueError("Not a PLY file (missing 'ply' magic line)")
|
|
72
|
+
|
|
73
|
+
hdr = {'format': 'ascii', 'count': 0, 'properties': []}
|
|
74
|
+
in_vertex = False
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
line = f.readline().decode('ascii', errors='ignore').strip()
|
|
78
|
+
if not line or line.startswith('comment'):
|
|
79
|
+
continue
|
|
80
|
+
parts = line.split()
|
|
81
|
+
kw = parts[0]
|
|
82
|
+
|
|
83
|
+
if kw == 'format':
|
|
84
|
+
hdr['format'] = parts[1]
|
|
85
|
+
elif kw == 'element':
|
|
86
|
+
in_vertex = (parts[1] == 'vertex')
|
|
87
|
+
if in_vertex:
|
|
88
|
+
hdr['count'] = int(parts[2])
|
|
89
|
+
elif kw == 'property' and in_vertex:
|
|
90
|
+
if parts[1] == 'list':
|
|
91
|
+
continue # skip face index lists
|
|
92
|
+
hdr['properties'].append((parts[2], parts[1]))
|
|
93
|
+
elif kw == 'end_header':
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
return hdr
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _read_ascii(cls, f, props, n_pts) -> dict:
|
|
100
|
+
bufs = {name: [] for name, _ in props}
|
|
101
|
+
|
|
102
|
+
for _ in range(n_pts):
|
|
103
|
+
raw = f.readline()
|
|
104
|
+
line = raw.decode('ascii', errors='ignore').strip() if isinstance(raw, bytes) else raw.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
tokens = line.split()
|
|
108
|
+
|
|
109
|
+
for i, (name, ply_type) in enumerate(props):
|
|
110
|
+
nc = cls._PLY_TO_NUMPY[ply_type]
|
|
111
|
+
bufs[name].append(float(tokens[i]) if 'f' in nc else int(float(tokens[i])))
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name: np.array(bufs[name], dtype=np.dtype(cls._PLY_TO_NUMPY[ply_type]))
|
|
115
|
+
for name, ply_type in props
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def _read_binary(cls, f, props, n_pts, endian) -> dict:
|
|
120
|
+
dt = np.dtype([(name, endian + cls._PLY_TO_NUMPY[ply_type]) for name, ply_type in props])
|
|
121
|
+
data = np.frombuffer(f.read(n_pts * dt.itemsize), dtype=dt)
|
|
122
|
+
return {name: data[name].copy() for name, _ in props}
|
|
123
|
+
|
|
124
|
+
# ── Writer ─────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def write(cls, pc: PointCloud, output_path: str, binary: bool = True) -> None:
|
|
128
|
+
"""Write a PointCloud to a PLY file.
|
|
129
|
+
|
|
130
|
+
All non-empty fields are written: position (x, y, z), intensity, rgb
|
|
131
|
+
(as separate red/green/blue uchar properties), and any 1-D arrays in
|
|
132
|
+
pc.extra.
|
|
133
|
+
|
|
134
|
+
Binary mode uses chunked writes so that peak extra memory stays at
|
|
135
|
+
_CHUNK × bytes_per_point regardless of total cloud size.
|
|
136
|
+
"""
|
|
137
|
+
n = len(pc)
|
|
138
|
+
|
|
139
|
+
# Each entry: (ply_prop_name, ply_type_str, little-endian np.dtype, 1-D array)
|
|
140
|
+
props_info = []
|
|
141
|
+
|
|
142
|
+
xyz_ply = cls._NUMPY_TO_PLY.get((pc.position.dtype.kind, pc.position.dtype.itemsize), 'double')
|
|
143
|
+
xyz_ndt = np.dtype('<' + cls._PLY_TO_NUMPY[xyz_ply])
|
|
144
|
+
props_info.append(('x', xyz_ply, xyz_ndt, pc.position[:, 0]))
|
|
145
|
+
props_info.append(('y', xyz_ply, xyz_ndt, pc.position[:, 1]))
|
|
146
|
+
props_info.append(('z', xyz_ply, xyz_ndt, pc.position[:, 2]))
|
|
147
|
+
|
|
148
|
+
if len(pc.intensity) == n:
|
|
149
|
+
ply_t = cls._NUMPY_TO_PLY.get((pc.intensity.dtype.kind, pc.intensity.dtype.itemsize), 'float')
|
|
150
|
+
props_info.append(('intensity', ply_t, np.dtype('<' + cls._PLY_TO_NUMPY[ply_t]), pc.intensity))
|
|
151
|
+
|
|
152
|
+
if len(pc.rgb) == n:
|
|
153
|
+
u1 = np.dtype('<u1')
|
|
154
|
+
props_info.append(('red', 'uchar', u1, pc.rgb[:, 0]))
|
|
155
|
+
props_info.append(('green', 'uchar', u1, pc.rgb[:, 1]))
|
|
156
|
+
props_info.append(('blue', 'uchar', u1, pc.rgb[:, 2]))
|
|
157
|
+
|
|
158
|
+
for name, arr in pc.extra.items():
|
|
159
|
+
if arr.ndim == 1 and len(arr) == n:
|
|
160
|
+
ply_t = cls._NUMPY_TO_PLY.get((arr.dtype.kind, arr.dtype.itemsize), 'float')
|
|
161
|
+
props_info.append((name, ply_t, np.dtype('<' + cls._PLY_TO_NUMPY[ply_t]), arr))
|
|
162
|
+
|
|
163
|
+
fmt_str = 'binary_little_endian' if binary else 'ascii'
|
|
164
|
+
prop_lines = "\n".join(f"property {ply_t} {name}" for name, ply_t, _, _ in props_info)
|
|
165
|
+
header = (
|
|
166
|
+
"ply\n"
|
|
167
|
+
f"format {fmt_str} 1.0\n"
|
|
168
|
+
"comment Mindkosh point cloud\n"
|
|
169
|
+
f"element vertex {n}\n"
|
|
170
|
+
f"{prop_lines}\n"
|
|
171
|
+
"end_header\n"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
with open(output_path, 'wb' if binary else 'w') as out:
|
|
175
|
+
out.write(header.encode('ascii') if binary else header)
|
|
176
|
+
|
|
177
|
+
if n == 0:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if binary:
|
|
181
|
+
cls._write_binary(out, props_info, n)
|
|
182
|
+
else:
|
|
183
|
+
arrays = [pi[3] for pi in props_info]
|
|
184
|
+
for i in range(n):
|
|
185
|
+
out.write(" ".join(str(arr[i]) for arr in arrays) + "\n")
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _write_binary(out, props_info, n: int) -> None:
|
|
189
|
+
struct_dt = np.dtype([(pi[0], pi[2]) for pi in props_info])
|
|
190
|
+
buf = np.empty(_CHUNK, dtype=struct_dt)
|
|
191
|
+
for start in range(0, n, _CHUNK):
|
|
192
|
+
end = min(start + _CHUNK, n)
|
|
193
|
+
chunk_size = end - start
|
|
194
|
+
for name, _, _, arr in props_info:
|
|
195
|
+
buf[name][:chunk_size] = arr[start:end]
|
|
196
|
+
out.write(buf[:chunk_size].tobytes())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Copyright (C) 2026 Mindkosh technologies private limited
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PointCloud:
|
|
11
|
+
"""Central data model for a 3-D point cloud.
|
|
12
|
+
|
|
13
|
+
Attributes
|
|
14
|
+
----------
|
|
15
|
+
position : (N, 3) float64 – required
|
|
16
|
+
intensity : (N,) float32 – empty array if absent
|
|
17
|
+
rgb : (N, 3) uint8 – empty array if absent
|
|
18
|
+
extra : name → (N,) ndarray for any additional per-point fields
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
position: Optional[np.ndarray] = None,
|
|
24
|
+
intensity: Optional[np.ndarray] = None,
|
|
25
|
+
rgb: Optional[np.ndarray] = None,
|
|
26
|
+
extra: Optional[Dict[str, np.ndarray]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.position = position if position is not None else np.empty((0, 3), dtype=np.float64)
|
|
29
|
+
self.intensity = intensity if intensity is not None else np.empty(0, dtype=np.float32)
|
|
30
|
+
self.rgb = rgb if rgb is not None else np.empty((0, 3), dtype=np.uint8)
|
|
31
|
+
self.extra: Dict[str, np.ndarray] = extra if extra is not None else {}
|
|
32
|
+
|
|
33
|
+
def __len__(self) -> int:
|
|
34
|
+
return len(self.position)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def read(cls, path: str, drop_nan: bool = True) -> PointCloud:
|
|
38
|
+
"""Load a point cloud from *path*; format is inferred from the file suffix.
|
|
39
|
+
|
|
40
|
+
By default, points with NaN/Inf x/y/z are removed and NaN in intensity
|
|
41
|
+
or extra float fields is zeroed out. Pass ``drop_nan=False`` to keep
|
|
42
|
+
raw values as-is.
|
|
43
|
+
"""
|
|
44
|
+
from pointcloudkit.io import read as _read
|
|
45
|
+
pc = _read(path)
|
|
46
|
+
if drop_nan:
|
|
47
|
+
pc.drop_nan()
|
|
48
|
+
return pc
|
|
49
|
+
|
|
50
|
+
def write(self, path: str, binary: bool = True) -> None:
|
|
51
|
+
"""Save this cloud to *path*; format is inferred from the file suffix."""
|
|
52
|
+
from pointcloudkit.io import write as _write
|
|
53
|
+
_write(self, path, binary=binary)
|
|
54
|
+
|
|
55
|
+
def drop_nan(self) -> PointCloud:
|
|
56
|
+
"""Remove points where any of x, y, z is NaN or Inf; zero out NaN in intensity and extra float fields."""
|
|
57
|
+
n = len(self.position)
|
|
58
|
+
valid = np.isfinite(self.position).all(axis=1)
|
|
59
|
+
|
|
60
|
+
self.position = self.position[valid]
|
|
61
|
+
|
|
62
|
+
if len(self.intensity) == n:
|
|
63
|
+
self.intensity = self.intensity[valid]
|
|
64
|
+
nan_mask = ~np.isfinite(self.intensity)
|
|
65
|
+
self.intensity[nan_mask] = 0.0
|
|
66
|
+
|
|
67
|
+
if len(self.rgb) == n:
|
|
68
|
+
self.rgb = self.rgb[valid]
|
|
69
|
+
# rgb is uint8; NaN cannot occur in integer arrays
|
|
70
|
+
|
|
71
|
+
for key, arr in self.extra.items():
|
|
72
|
+
if len(arr) == n:
|
|
73
|
+
self.extra[key] = arr[valid]
|
|
74
|
+
if np.issubdtype(arr.dtype, np.floating):
|
|
75
|
+
nan_mask = ~np.isfinite(self.extra[key])
|
|
76
|
+
self.extra[key][nan_mask] = 0
|
|
77
|
+
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def center(self) -> PointCloud:
|
|
81
|
+
"""Subtract the centroid from all positions in-place. Returns self."""
|
|
82
|
+
self.position -= np.mean(self.position, axis=0)
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def make_upright(self) -> PointCloud:
|
|
86
|
+
"""Align the cloud so its dominant plane is parallel to XY (Z points up).
|
|
87
|
+
|
|
88
|
+
Uses PCA: the eigenvector corresponding to the *smallest* eigenvalue of
|
|
89
|
+
the covariance matrix is the plane normal. That normal is rotated onto
|
|
90
|
+
[0, 0, 1] using the Rodrigues rotation formula. The cloud is also
|
|
91
|
+
centered as a side-effect.
|
|
92
|
+
|
|
93
|
+
Returns self.
|
|
94
|
+
"""
|
|
95
|
+
centroid = self.position.mean(axis=0)
|
|
96
|
+
Pc = self.position - centroid # centered copy
|
|
97
|
+
|
|
98
|
+
C = (Pc.T @ Pc) / len(Pc) # 3×3 covariance
|
|
99
|
+
_, vecs = np.linalg.eigh(C)
|
|
100
|
+
normal = vecs[:, 0] # smallest eigenvalue → plane normal
|
|
101
|
+
|
|
102
|
+
z = np.array([0.0, 0.0, 1.0])
|
|
103
|
+
v = np.cross(normal, z)
|
|
104
|
+
s = np.linalg.norm(v)
|
|
105
|
+
c = np.dot(normal, z)
|
|
106
|
+
|
|
107
|
+
if s == 0.0:
|
|
108
|
+
# normal already aligned with Z (or anti-aligned); no rotation needed
|
|
109
|
+
R = np.eye(3)
|
|
110
|
+
else:
|
|
111
|
+
# Rodrigues / cross-product matrix formula
|
|
112
|
+
vx = np.array([
|
|
113
|
+
[ 0.0, -v[2], v[1]],
|
|
114
|
+
[ v[2], 0.0, -v[0]],
|
|
115
|
+
[-v[1], v[0], 0.0],
|
|
116
|
+
])
|
|
117
|
+
R = np.eye(3) + vx + vx @ vx * ((1.0 - c) / (s ** 2))
|
|
118
|
+
|
|
119
|
+
self.position = (R @ Pc.T).T # Pc is already centered
|
|
120
|
+
return self
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pointcloudkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight point cloud I/O and processing library. Pure numpy — no open3d dependency.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mindkosh/pointcloudkit
|
|
6
|
+
Project-URL: Repository, https://github.com/mindkosh/pointcloudkit
|
|
7
|
+
Project-URL: Issues, https://github.com/mindkosh/pointcloudkit/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 3d,bin,las,lidar,pcd,ply,point cloud
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: laspy>=2.0
|
|
24
|
+
Requires-Dist: numpy
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pointcloudkit
|
|
28
|
+
|
|
29
|
+
Lightweight point cloud I/O and processing library. Pure numpy — no open3d dependency.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
numpy
|
|
35
|
+
laspy # LAS format only
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python ≥ 3.9.
|
|
39
|
+
|
|
40
|
+
## Supported Formats
|
|
41
|
+
|
|
42
|
+
| Extension | Read | Write |
|
|
43
|
+
|-----------|------|-------|
|
|
44
|
+
| `.pcd` | ✓ | ✓ |
|
|
45
|
+
| `.ply` | ✓ | ✓ |
|
|
46
|
+
| `.las` | ✓ | ✓ |
|
|
47
|
+
| `.bin` | ✓ | — |
|
|
48
|
+
|
|
49
|
+
PCD and PLY support both ASCII and binary modes. Binary writes are chunked and memory-efficient regardless of cloud size.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Operations
|
|
54
|
+
|
|
55
|
+
### Reading and writing
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pointcloudkit import PointCloud
|
|
59
|
+
|
|
60
|
+
pc = PointCloud.read("scan.pcd")
|
|
61
|
+
pc.write("scan.ply") # binary by default
|
|
62
|
+
pc.write("scan_ascii.pcd", binary=False)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Format conversion
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pointcloudkit import convert
|
|
69
|
+
|
|
70
|
+
convert("scan.las", "scan.pcd")
|
|
71
|
+
convert("scan.bin", "scan.ply")
|
|
72
|
+
convert("scan.pcd", "scan_ascii.ply", binary=False)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Center
|
|
76
|
+
|
|
77
|
+
Subtracts the centroid from all positions in-place.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
pc = PointCloud.read("scan.pcd")
|
|
81
|
+
pc.center()
|
|
82
|
+
pc.write("centered.pcd")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Make upright
|
|
86
|
+
|
|
87
|
+
Rotates the cloud so its dominant plane is parallel to XY (Z axis points up).
|
|
88
|
+
Uses PCA: the eigenvector with the smallest eigenvalue is the plane normal,
|
|
89
|
+
which is then aligned to `[0, 0, 1]` via the Rodrigues formula.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
pc = PointCloud.read("scan.pcd")
|
|
93
|
+
pc.make_upright()
|
|
94
|
+
pc.write("upright.pcd")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Operations return `self` and can be chained:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
PointCloud.read("scan.las").center().make_upright().write("processed.pcd")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## PointCloud data model
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
pc.position # (N, 3) float64 — always present
|
|
109
|
+
pc.intensity # (N,) float32 — empty array if absent
|
|
110
|
+
pc.rgb # (N, 3) uint8 — empty array if absent
|
|
111
|
+
pc.extra # dict: name → (N,) ndarray for any additional fields
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Constructing manually:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import numpy as np
|
|
118
|
+
from pointcloudkit import PointCloud
|
|
119
|
+
|
|
120
|
+
pc = PointCloud(
|
|
121
|
+
position=np.random.rand(1000, 3),
|
|
122
|
+
intensity=np.random.rand(1000).astype(np.float32),
|
|
123
|
+
rgb=np.random.randint(0, 255, (1000, 3), dtype=np.uint8),
|
|
124
|
+
)
|
|
125
|
+
print(len(pc)) # 1000
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Reading BIN files
|
|
129
|
+
|
|
130
|
+
Velodyne BIN files default to KITTI layout (x, y, z, intensity). Pass `num_attrs`
|
|
131
|
+
to override the number of float32 values per point.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from pointcloudkit.io.bin import BINFile
|
|
135
|
+
|
|
136
|
+
pc = BINFile.read("velodyne.bin") # 4 attrs (default)
|
|
137
|
+
pc = BINFile.read("custom.bin", num_attrs=6) # 6 attrs; first 3 → position
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Accessing format-specific classes directly
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from pointcloudkit.io.pcd import PCDFile
|
|
144
|
+
from pointcloudkit.io.ply import PLYFile
|
|
145
|
+
from pointcloudkit.io.las import LASFile
|
|
146
|
+
|
|
147
|
+
pc = PCDFile.read("scan.pcd")
|
|
148
|
+
PLYFile.write(pc, "scan.ply", binary=True)
|
|
149
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pointcloudkit/__init__.py,sha256=b_qxTrxKIRmNfiD_AZiC8Jm4poORf0rR8y8jNy19GSk,442
|
|
2
|
+
pointcloudkit/point_cloud.py,sha256=tKOovb0JHK8EFQJbEEsuqwEyrYUaFRNEySIlcxCNlew,4423
|
|
3
|
+
pointcloudkit/io/__init__.py,sha256=GCDDab4-dN-pTdJUH6w73hmAEAdG1iHsTHXQqmQD7m4,1773
|
|
4
|
+
pointcloudkit/io/bin.py,sha256=Vra4gfRB7tokUZ_PlWV3ARhxJCT8IKfyXJMfm-MPKgs,936
|
|
5
|
+
pointcloudkit/io/las.py,sha256=SkWCLw49tNppudDwCcLGWr8GGAjK09bs_Cb7KUjRF2g,3171
|
|
6
|
+
pointcloudkit/io/pcd.py,sha256=cMLptCQsjGWC6spQFVrgmWgQhcEiRV1M208PhjygXKI,10991
|
|
7
|
+
pointcloudkit/io/ply.py,sha256=AXbDDBD8mYM22-WxS3onDEg3otYnlhG8TpmkOtINrCo,7846
|
|
8
|
+
pointcloudkit-0.1.0.dist-info/METADATA,sha256=azJQeBdokGJdxgRMejOQTUwZAMYaxpP7Q6b__E3l6cI,3853
|
|
9
|
+
pointcloudkit-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
pointcloudkit-0.1.0.dist-info/licenses/LICENSE,sha256=jrKn3K3bCORjb2TxtYbBt8YNpyBhjo-1ik2mpCzuIJA,1068
|
|
11
|
+
pointcloudkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mindkosh AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|