bdck commited on
Commit
03b9058
·
verified ·
1 Parent(s): 2b1915f

add I/O utilities and remeshing helpers

Browse files
Files changed (1) hide show
  1. point2mesh/io_utils.py +443 -0
point2mesh/io_utils.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ I/O utilities — load point clouds (PCD, PLY, XYZ, OBJ), export meshes,
3
+ build initial meshes (convex hull / Poisson), and remesh between levels.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import struct
10
+ import numpy as np
11
+ from typing import Tuple, Optional
12
+ from pathlib import Path
13
+
14
+
15
+ # ──────────────────────────────────────────────────────────────────────
16
+ # Point cloud loaders (no Open3D dependency — pure Python/numpy)
17
+ # ──────────────────────────────────────────────────────────────────────
18
+ def load_pointcloud(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
19
+ """
20
+ Load a point cloud from .ply, .pcd, .xyz, or .obj.
21
+
22
+ Returns
23
+ -------
24
+ points : (N, 3) float64
25
+ normals : (N, 3) float64 or None
26
+ """
27
+ ext = Path(path).suffix.lower()
28
+ if ext == ".ply":
29
+ return _load_ply(path)
30
+ elif ext == ".pcd":
31
+ return _load_pcd(path)
32
+ elif ext == ".xyz":
33
+ data = np.loadtxt(path)
34
+ if data.shape[1] >= 6:
35
+ return data[:, :3], data[:, 3:6]
36
+ return data[:, :3], None
37
+ elif ext == ".obj":
38
+ return _load_obj_points(path)
39
+ else:
40
+ raise ValueError(f"Unsupported format: {ext}")
41
+
42
+
43
+ # ── PLY loader ────────────────────────────────────────────────────────
44
+ def _load_ply(path: str):
45
+ with open(path, "rb") as f:
46
+ header = b""
47
+ while True:
48
+ line = f.readline()
49
+ header += line
50
+ if b"end_header" in line:
51
+ break
52
+ header_str = header.decode("ascii", errors="ignore")
53
+
54
+ lines = header_str.split("\n")
55
+ n_verts = 0
56
+ props = []
57
+ is_binary_le = False
58
+ is_binary_be = False
59
+ for line in lines:
60
+ line = line.strip()
61
+ if line.startswith("element vertex"):
62
+ n_verts = int(line.split()[-1])
63
+ elif line.startswith("property"):
64
+ parts = line.split()
65
+ props.append((parts[1], parts[2]))
66
+ elif line.startswith("format binary_little_endian"):
67
+ is_binary_le = True
68
+ elif line.startswith("format binary_big_endian"):
69
+ is_binary_be = True
70
+
71
+ # Get column indices for x y z nx ny nz
72
+ prop_names = [p[1] for p in props]
73
+ x_i = prop_names.index("x") if "x" in prop_names else 0
74
+ y_i = prop_names.index("y") if "y" in prop_names else 1
75
+ z_i = prop_names.index("z") if "z" in prop_names else 2
76
+ has_normals = "nx" in prop_names
77
+ if has_normals:
78
+ nx_i = prop_names.index("nx")
79
+ ny_i = prop_names.index("ny")
80
+ nz_i = prop_names.index("nz")
81
+
82
+ if is_binary_le or is_binary_be:
83
+ endian = "<" if is_binary_le else ">"
84
+ dtype_map = {
85
+ "float": f"{endian}f4", "double": f"{endian}f8",
86
+ "uchar": "u1", "uint8": "u1",
87
+ "int": f"{endian}i4", "uint": f"{endian}u4",
88
+ "short": f"{endian}i2", "ushort": f"{endian}u2",
89
+ }
90
+ dt = np.dtype([(p[1], dtype_map.get(p[0], f"{endian}f4")) for p in props])
91
+ header_end = header_str.index("end_header") + len("end_header") + 1
92
+ with open(path, "rb") as f:
93
+ f.seek(len(header))
94
+ raw = np.frombuffer(f.read(n_verts * dt.itemsize), dtype=dt, count=n_verts)
95
+ pts = np.column_stack([raw[prop_names[x_i]].astype(np.float64),
96
+ raw[prop_names[y_i]].astype(np.float64),
97
+ raw[prop_names[z_i]].astype(np.float64)])
98
+ normals = None
99
+ if has_normals:
100
+ normals = np.column_stack([raw[prop_names[nx_i]].astype(np.float64),
101
+ raw[prop_names[ny_i]].astype(np.float64),
102
+ raw[prop_names[nz_i]].astype(np.float64)])
103
+ return pts, normals
104
+ else:
105
+ # ASCII
106
+ data = np.loadtxt(path, skiprows=len(lines), max_rows=n_verts)
107
+ pts = data[:, [x_i, y_i, z_i]]
108
+ normals = None
109
+ if has_normals:
110
+ normals = data[:, [nx_i, ny_i, nz_i]]
111
+ return pts, normals
112
+
113
+
114
+ # ── PCD loader ────────────────────────────────────────────────────────
115
+ def _load_pcd(path: str):
116
+ with open(path, "rb") as f:
117
+ meta = {}
118
+ while True:
119
+ line = f.readline().decode("ascii", errors="ignore").strip()
120
+ if line.startswith("DATA"):
121
+ meta["data"] = line.split()[-1]
122
+ break
123
+ if " " in line:
124
+ key = line.split()[0]
125
+ val = line[len(key):].strip()
126
+ meta[key] = val
127
+ fields = meta.get("FIELDS", "x y z").split()
128
+ n_pts = int(meta.get("POINTS", meta.get("WIDTH", "0")))
129
+ sizes = list(map(int, meta.get("SIZE", "4 4 4").split()))
130
+ types = meta.get("TYPE", "F F F").split()
131
+
132
+ x_i = fields.index("x") if "x" in fields else 0
133
+ y_i = fields.index("y") if "y" in fields else 1
134
+ z_i = fields.index("z") if "z" in fields else 2
135
+ has_n = "normal_x" in fields
136
+ if has_n:
137
+ nx_i = fields.index("normal_x")
138
+ ny_i = fields.index("normal_y")
139
+ nz_i = fields.index("normal_z")
140
+
141
+ if meta["data"].lower() == "ascii":
142
+ data = np.loadtxt(f, max_rows=n_pts)
143
+ pts = data[:, [x_i, y_i, z_i]]
144
+ normals = data[:, [nx_i, ny_i, nz_i]] if has_n else None
145
+ return pts, normals
146
+ else:
147
+ # binary
148
+ row_size = sum(sizes)
149
+ raw = f.read(n_pts * row_size)
150
+ dt_map = {"F": "f", "U": "u", "I": "i"}
151
+ dtype = np.dtype(
152
+ [(fn, f"<{dt_map.get(t, 'f')}{s}") for fn, t, s in zip(fields, types, sizes)]
153
+ )
154
+ arr = np.frombuffer(raw, dtype=dtype, count=n_pts)
155
+ pts = np.column_stack([arr[fields[x_i]].astype(np.float64),
156
+ arr[fields[y_i]].astype(np.float64),
157
+ arr[fields[z_i]].astype(np.float64)])
158
+ normals = None
159
+ if has_n:
160
+ normals = np.column_stack([arr[fields[nx_i]].astype(np.float64),
161
+ arr[fields[ny_i]].astype(np.float64),
162
+ arr[fields[nz_i]].astype(np.float64)])
163
+ return pts, normals
164
+
165
+
166
+ # ── OBJ point loader ──────────────────────────────────────────────────
167
+ def _load_obj_points(path: str):
168
+ pts = []
169
+ normals = []
170
+ with open(path) as f:
171
+ for line in f:
172
+ if line.startswith("v "):
173
+ pts.append([float(x) for x in line.split()[1:4]])
174
+ elif line.startswith("vn "):
175
+ normals.append([float(x) for x in line.split()[1:4]])
176
+ pts = np.array(pts, dtype=np.float64)
177
+ normals = np.array(normals, dtype=np.float64) if normals else None
178
+ return pts, normals
179
+
180
+
181
+ # ──────────────────────────────────────────────────────────────────────
182
+ # Mesh exporters
183
+ # ──────────────────────────────────────────────────────────────────────
184
+ def save_mesh_obj(path: str, vertices: np.ndarray, faces: np.ndarray):
185
+ """Write an OBJ file."""
186
+ with open(path, "w") as f:
187
+ for v in vertices:
188
+ f.write(f"v {v[0]:.8f} {v[1]:.8f} {v[2]:.8f}\n")
189
+ for face in faces:
190
+ f.write(f"f {face[0]+1} {face[1]+1} {face[2]+1}\n")
191
+
192
+
193
+ def save_mesh_ply(path: str, vertices: np.ndarray, faces: np.ndarray):
194
+ """Write a binary-little-endian PLY mesh."""
195
+ nv, nf = len(vertices), len(faces)
196
+ header = (
197
+ "ply\n"
198
+ "format binary_little_endian 1.0\n"
199
+ f"element vertex {nv}\n"
200
+ "property float x\nproperty float y\nproperty float z\n"
201
+ f"element face {nf}\n"
202
+ "property list uchar int vertex_indices\n"
203
+ "end_header\n"
204
+ )
205
+ with open(path, "wb") as f:
206
+ f.write(header.encode("ascii"))
207
+ f.write(vertices.astype(np.float32).tobytes())
208
+ for face in faces:
209
+ f.write(struct.pack("<B3i", 3, *face.astype(np.int32)))
210
+
211
+
212
+ def save_mesh(path: str, vertices: np.ndarray, faces: np.ndarray):
213
+ ext = Path(path).suffix.lower()
214
+ if ext == ".obj":
215
+ save_mesh_obj(path, vertices, faces)
216
+ elif ext == ".ply":
217
+ save_mesh_ply(path, vertices, faces)
218
+ elif ext == ".stl":
219
+ _save_stl(path, vertices, faces)
220
+ else:
221
+ save_mesh_obj(path, vertices, faces)
222
+
223
+
224
+ def _save_stl(path: str, vertices: np.ndarray, faces: np.ndarray):
225
+ """Write a binary STL."""
226
+ nf = len(faces)
227
+ with open(path, "wb") as f:
228
+ f.write(b"\0" * 80) # header
229
+ f.write(struct.pack("<I", nf))
230
+ for face in faces:
231
+ v0, v1, v2 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
232
+ n = np.cross(v1 - v0, v2 - v0)
233
+ nl = np.linalg.norm(n)
234
+ if nl > 0:
235
+ n /= nl
236
+ f.write(struct.pack("<3f", *n.astype(np.float32)))
237
+ f.write(struct.pack("<3f", *v0.astype(np.float32)))
238
+ f.write(struct.pack("<3f", *v1.astype(np.float32)))
239
+ f.write(struct.pack("<3f", *v2.astype(np.float32)))
240
+ f.write(struct.pack("<H", 0))
241
+
242
+
243
+ # ─────────────────���────────────────────────────────────────────────────
244
+ # Initial mesh construction
245
+ # ──────────────────────────────────────────────────────────────────────
246
+ def build_initial_mesh(
247
+ points: np.ndarray,
248
+ target_faces: int = 2000,
249
+ ) -> Tuple[np.ndarray, np.ndarray]:
250
+ """
251
+ Build a coarse initial mesh from a point cloud.
252
+
253
+ Strategy: convex hull → subdivide to reach target face count →
254
+ Laplacian smooth → decimate back to target.
255
+
256
+ Returns (vertices, faces).
257
+ """
258
+ from scipy.spatial import ConvexHull
259
+
260
+ hull = ConvexHull(points)
261
+ verts = hull.points[hull.vertices].copy()
262
+
263
+ # Re-index faces so they reference the hull-vertex subset
264
+ full_to_hull = {v: i for i, v in enumerate(hull.vertices)}
265
+ faces = np.array(
266
+ [[full_to_hull[v] for v in f] for f in hull.simplices], dtype=np.int64
267
+ )
268
+
269
+ # Subdivide until we have at least target_faces
270
+ verts, faces = _subdivide_to_target(verts, faces, target_faces)
271
+
272
+ # Laplacian smoothing (5 iterations)
273
+ verts = _laplacian_smooth(verts, faces, iterations=5, lam=0.5)
274
+
275
+ # Decimate if we overshot
276
+ if len(faces) > target_faces * 1.5:
277
+ verts, faces = _decimate(verts, faces, target_faces)
278
+
279
+ return verts.astype(np.float64), faces.astype(np.int64)
280
+
281
+
282
+ # ──────────────────────────────────────────────────────────────────────
283
+ # Remeshing between coarse-to-fine levels
284
+ # ──────────────────────────────────────────────────────────────────────
285
+ def remesh(
286
+ vertices: np.ndarray,
287
+ faces: np.ndarray,
288
+ target_faces: int,
289
+ ) -> Tuple[np.ndarray, np.ndarray]:
290
+ """
291
+ Subdivide the mesh then decimate to *target_faces*.
292
+ Applies one pass of Loop-like midpoint subdivision, Laplacian smooth,
293
+ and greedy edge-collapse decimation.
294
+ """
295
+ # 1. Subdivide (midpoint)
296
+ verts, faces_new = _subdivide_midpoint(vertices, faces)
297
+ # 2. Smooth
298
+ verts = _laplacian_smooth(verts, faces_new, iterations=3, lam=0.3)
299
+ # 3. Decimate to target
300
+ if len(faces_new) > target_faces:
301
+ verts, faces_new = _decimate(verts, faces_new, target_faces)
302
+ return verts.astype(np.float64), faces_new.astype(np.int64)
303
+
304
+
305
+ # ──────────────────────────────────────────────────────────────────────
306
+ # Internal helpers
307
+ # ──────────────────────────────────────────────────────────────────────
308
+ def _subdivide_to_target(verts, faces, target):
309
+ while len(faces) < target:
310
+ verts, faces = _subdivide_midpoint(verts, faces)
311
+ return verts, faces
312
+
313
+
314
+ def _subdivide_midpoint(verts, faces):
315
+ """One pass of midpoint subdivision (each tri → 4 tris)."""
316
+ edge_mid = {}
317
+ new_verts = list(verts)
318
+
319
+ def get_mid(a, b):
320
+ key = (min(a, b), max(a, b))
321
+ if key not in edge_mid:
322
+ mid = (verts[a] + verts[b]) / 2.0
323
+ idx = len(new_verts)
324
+ new_verts.append(mid)
325
+ edge_mid[key] = idx
326
+ return edge_mid[key]
327
+
328
+ new_faces = []
329
+ for f in faces:
330
+ a, b, c = int(f[0]), int(f[1]), int(f[2])
331
+ ab = get_mid(a, b)
332
+ bc = get_mid(b, c)
333
+ ca = get_mid(c, a)
334
+ new_faces.extend([
335
+ [a, ab, ca],
336
+ [ab, b, bc],
337
+ [ca, bc, c],
338
+ [ab, bc, ca],
339
+ ])
340
+ return np.array(new_verts), np.array(new_faces, dtype=np.int64)
341
+
342
+
343
+ def _laplacian_smooth(verts, faces, iterations=3, lam=0.5):
344
+ from collections import defaultdict
345
+ n = len(verts)
346
+ adj = defaultdict(set)
347
+ for f in faces:
348
+ for i in range(3):
349
+ a, b = int(f[i]), int(f[(i + 1) % 3])
350
+ adj[a].add(b)
351
+ adj[b].add(a)
352
+ verts = verts.copy()
353
+ for _ in range(iterations):
354
+ new_v = verts.copy()
355
+ for i in range(n):
356
+ if adj[i]:
357
+ nbrs = np.array(list(adj[i]))
358
+ centroid = verts[nbrs].mean(axis=0)
359
+ new_v[i] = verts[i] + lam * (centroid - verts[i])
360
+ verts = new_v
361
+ return verts
362
+
363
+
364
+ def _decimate(verts, faces, target):
365
+ """
366
+ Greedy edge-collapse decimation down to *target* faces.
367
+ Uses quadric error metric (simplified).
368
+ """
369
+ from collections import defaultdict
370
+ verts = verts.copy().astype(np.float64)
371
+ faces = faces.copy().astype(np.int64)
372
+
373
+ while len(faces) > target:
374
+ # Build edges and compute collapse cost (edge length)
375
+ edge_cost = {}
376
+ for fi, f in enumerate(faces):
377
+ for k in range(3):
378
+ a, b = int(f[k]), int(f[(k + 1) % 3])
379
+ key = (min(a, b), max(a, b))
380
+ if key not in edge_cost:
381
+ edge_cost[key] = np.linalg.norm(verts[a] - verts[b])
382
+
383
+ if not edge_cost:
384
+ break
385
+
386
+ # Collapse cheapest edge
387
+ best_edge = min(edge_cost, key=edge_cost.get)
388
+ va, vb = best_edge
389
+
390
+ # Move va to midpoint
391
+ verts[va] = (verts[va] + verts[vb]) / 2.0
392
+
393
+ # Redirect vb → va in all faces
394
+ for fi in range(len(faces)):
395
+ for k in range(3):
396
+ if faces[fi][k] == vb:
397
+ faces[fi][k] = va
398
+
399
+ # Remove degenerate faces (two or more identical vertex indices)
400
+ keep = []
401
+ for f in faces:
402
+ if f[0] != f[1] and f[1] != f[2] and f[0] != f[2]:
403
+ keep.append(f)
404
+ faces = np.array(keep, dtype=np.int64) if keep else np.zeros((0, 3), dtype=np.int64)
405
+
406
+ if len(faces) == 0:
407
+ break
408
+
409
+ # Compact vertex array (remove unreferenced verts)
410
+ used = np.unique(faces.ravel())
411
+ remap = np.full(len(verts), -1, dtype=np.int64)
412
+ remap[used] = np.arange(len(used))
413
+ verts = verts[used]
414
+ faces = remap[faces]
415
+
416
+ return verts, faces
417
+
418
+
419
+ # ──────────────────────────────────────────────────────────────────────
420
+ # Normal estimation for point clouds without normals
421
+ # ──────────────────────────────────────────────────────────────────────
422
+ def estimate_normals(
423
+ points: np.ndarray, k: int = 20
424
+ ) -> np.ndarray:
425
+ """
426
+ PCA-based normal estimation using k nearest neighbors.
427
+ """
428
+ from scipy.spatial import cKDTree
429
+ tree = cKDTree(points)
430
+ _, idx = tree.query(points, k=k)
431
+ normals = np.zeros_like(points)
432
+ for i in range(len(points)):
433
+ nbrs = points[idx[i]]
434
+ centroid = nbrs.mean(axis=0)
435
+ cov = (nbrs - centroid).T @ (nbrs - centroid) / k
436
+ _, _, Vt = np.linalg.svd(cov)
437
+ normals[i] = Vt[-1]
438
+ # Orient normals consistently (towards centroid-outward)
439
+ centroid = points.mean(axis=0)
440
+ for i in range(len(normals)):
441
+ if np.dot(normals[i], points[i] - centroid) < 0:
442
+ normals[i] *= -1
443
+ return normals