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.
@@ -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)
@@ -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
@@ -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)
@@ -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())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.