Rawal Khirodkar commited on
Commit
bed3d5d
Β·
1 Parent(s): 14f2b6a

Pointmap: switch from spheres to surfels (oriented quads aligned to surface normal)

Browse files
Files changed (1) hide show
  1. app.py +64 -36
app.py CHANGED
@@ -236,62 +236,86 @@ def _triangulate_grid(pointmap_hwc: np.ndarray, mask_hw: np.ndarray,
236
 
237
 
238
  def _make_glb(image_pil_texture: Image.Image, pointmap_hwc: np.ndarray,
239
- mask_hw: np.ndarray, max_points: int = 25_000) -> str:
240
- """Render the pointmap as a cloud of tiny coloured spheres (one per pixel).
 
 
 
241
 
242
- Avoids triangulation artifacts entirely β€” there's no surface mesh between
243
- pixels. Each pixel becomes an icosphere instance colored by its image
244
- pixel, radius scaled to the typical inter-pixel distance in 3D so the cloud
245
- looks coherent without huge overlaps.
246
-
247
- `image_pil_texture` is sampled (downscaled) to native pixel positions for
248
- color picking β€” high-res isn't useful here since we sample point-wise.
249
  """
250
  H, W = pointmap_hwc.shape[:2]
251
  z = pointmap_hwc[:, :, 2]
252
  valid = mask_hw & np.isfinite(pointmap_hwc).all(axis=2) & (z > 0.05) & (z < 25.0)
253
- yy, xx = np.where(valid)
254
 
255
- rng = np.random.default_rng(0)
 
 
 
 
 
 
 
 
256
  if len(yy) > max_points:
257
- idx = rng.choice(len(yy), max_points, replace=False)
258
  yy, xx = yy[idx], xx[idx]
259
 
260
- pts = pointmap_hwc[yy, xx].astype(np.float32) # (N, 3)
261
- image_native = np.asarray(image_pil_texture.resize((W, H), Image.LANCZOS)) # (H, W, 3)
262
- cols = image_native[yy, xx].astype(np.uint8) # (N, 3)
 
263
 
264
- # Y-up flip + recenter on centroid for a tight default view.
265
  flip = np.array([1.0, -1.0, -1.0], dtype=np.float32)
266
  pts = pts * flip
 
267
  centroid = pts.mean(axis=0).astype(np.float32) if len(pts) else np.zeros(3, np.float32)
268
  pts = pts - centroid
269
 
270
- # Sphere radius from MEASURED median nearest-neighbour distance.
271
- # Why measured (vs. extent / cbrt(N)): our points sit on a ~2D surface
272
- # (skin), not in a 3D volume β€” a kd-tree gives the right spacing directly.
273
- # Factor 0.6 β†’ adjacent balls overlap slightly, forming a solid surface
274
- # without big gaps and without obvious overlap blobs.
275
  if len(pts) >= 2:
276
  from scipy.spatial import cKDTree
277
- nn_dist = cKDTree(pts).query(pts, k=2)[0][:, 1] # k=1 is self
278
- radius = max(float(np.median(nn_dist)) * 0.6, 1e-4)
279
  else:
280
- radius = 0.005 # fallback for empty/tiny clouds
281
-
282
- # Icosphere template (subdivisions=1 β†’ 42 verts, 80 faces β€” smoother balls).
283
- sphere = trimesh.creation.icosphere(subdivisions=1, radius=radius)
284
- sv = sphere.vertices.astype(np.float32) # (V=42, 3)
285
- sf = sphere.faces.astype(np.int64) # (F=80, 3)
286
- V, F = len(sv), len(sf)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  N = len(pts)
 
 
 
288
 
289
- # Vectorized instancing.
290
- verts = (sv[None, :, :] + pts[:, None, :]).reshape(-1, 3) # (N*V, 3)
291
- face_off = (np.arange(N, dtype=np.int64) * V)[:, None, None]
292
- faces = (sf[None, :, :] + face_off).reshape(-1, 3) # (N*F, 3)
293
- vc = np.empty((N, V, 4), dtype=np.uint8)
294
- vc[..., :3] = cols[:, None, :] # broadcast color to all 12 verts
295
  vc[..., 3] = 255
296
  vertex_colors = vc.reshape(-1, 4)
297
 
@@ -300,6 +324,10 @@ def _make_glb(image_pil_texture: Image.Image, pointmap_hwc: np.ndarray,
300
  vertex_colors=vertex_colors,
301
  process=True,
302
  )
 
 
 
 
303
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".glb").name
304
  mesh.export(out_path)
305
  return out_path
 
236
 
237
 
238
  def _make_glb(image_pil_texture: Image.Image, pointmap_hwc: np.ndarray,
239
+ mask_hw: np.ndarray, max_points: int = 80_000) -> str:
240
+ """Render the pointmap as **surfels** β€” each pixel becomes a small flat
241
+ quad oriented along the surface normal at that point. No triangulation
242
+ BETWEEN points (so no triangle artifacts), but each surfel still tiles
243
+ the surface, giving a smooth "skin" appearance instead of granular balls.
244
 
245
+ 4 verts + 2 faces per surfel β†’ much lighter than spheres (~1/10 the verts).
 
 
 
 
 
 
246
  """
247
  H, W = pointmap_hwc.shape[:2]
248
  z = pointmap_hwc[:, :, 2]
249
  valid = mask_hw & np.isfinite(pointmap_hwc).all(axis=2) & (z > 0.05) & (z < 25.0)
 
250
 
251
+ # Smooth per-pixel normals from the pointmap's spatial gradient.
252
+ px = np.zeros_like(pointmap_hwc, dtype=np.float32)
253
+ py = np.zeros_like(pointmap_hwc, dtype=np.float32)
254
+ px[:, 1:-1] = (pointmap_hwc[:, 2:] - pointmap_hwc[:, :-2]) * 0.5
255
+ py[1:-1, :] = (pointmap_hwc[2:, :] - pointmap_hwc[:-2, :]) * 0.5
256
+ n_grid = np.cross(px, py)
257
+ n_grid /= np.linalg.norm(n_grid, axis=2, keepdims=True).clip(min=1e-8)
258
+
259
+ yy, xx = np.where(valid)
260
  if len(yy) > max_points:
261
+ idx = np.random.default_rng(0).choice(len(yy), max_points, replace=False)
262
  yy, xx = yy[idx], xx[idx]
263
 
264
+ pts = pointmap_hwc[yy, xx].astype(np.float32)
265
+ nrm = n_grid[yy, xx].astype(np.float32)
266
+ image_native = np.asarray(image_pil_texture.resize((W, H), Image.LANCZOS))
267
+ cols = image_native[yy, xx].astype(np.uint8)
268
 
 
269
  flip = np.array([1.0, -1.0, -1.0], dtype=np.float32)
270
  pts = pts * flip
271
+ nrm = nrm * flip
272
  centroid = pts.mean(axis=0).astype(np.float32) if len(pts) else np.zeros(3, np.float32)
273
  pts = pts - centroid
274
 
275
+ # Surfel size from KD-tree median nearest-neighbour distance.
276
+ # Factor 0.6 β†’ adjacent surfels overlap slightly along the surface so
277
+ # there are no visible gaps between tiles.
 
 
278
  if len(pts) >= 2:
279
  from scipy.spatial import cKDTree
280
+ nn_dist = cKDTree(pts).query(pts, k=2)[0][:, 1]
281
+ r = max(float(np.median(nn_dist)) * 0.6, 1e-4)
282
  else:
283
+ r = 0.005
284
+
285
+ # Tangent basis (u, v) per surfel, perpendicular to that surfel's normal.
286
+ up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
287
+ u = np.cross(nrm, up) # (N, 3)
288
+ u_norm = np.linalg.norm(u, axis=1, keepdims=True)
289
+ # Fallback for any normals nearly parallel to "up".
290
+ parallel = (u_norm.squeeze(-1) < 1e-4)
291
+ if parallel.any():
292
+ ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
293
+ u[parallel] = np.cross(nrm[parallel], ref)
294
+ u_norm[parallel] = np.linalg.norm(u[parallel], axis=1, keepdims=True).clip(min=1e-8)
295
+ u = u / u_norm.clip(min=1e-8)
296
+ v = np.cross(nrm, u) # (N, 3) β€” already unit
297
+
298
+ # 4 corners per surfel (CCW when viewed from +normal):
299
+ # 0 = P βˆ’ r*u βˆ’ r*v
300
+ # 1 = P + r*u βˆ’ r*v
301
+ # 2 = P + r*u + r*v
302
+ # 3 = P βˆ’ r*u + r*v
303
+ ru, rv = r * u, r * v
304
+ c0 = pts - ru - rv
305
+ c1 = pts + ru - rv
306
+ c2 = pts + ru + rv
307
+ c3 = pts - ru + rv
308
+ verts = np.stack([c0, c1, c2, c3], axis=1).reshape(-1, 3).astype(np.float32)
309
+
310
+ # 2 triangles per surfel: (0,1,2) and (0,2,3), offset by 4 per surfel.
311
  N = len(pts)
312
+ base = (np.arange(N, dtype=np.int64) * 4)[:, None, None]
313
+ tri = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)[None, :, :]
314
+ faces = (base + tri).reshape(-1, 3)
315
 
316
+ # Uniform colour across each surfel's 4 verts.
317
+ vc = np.empty((N, 4, 4), dtype=np.uint8)
318
+ vc[..., :3] = cols[:, None, :]
 
 
 
319
  vc[..., 3] = 255
320
  vertex_colors = vc.reshape(-1, 4)
321
 
 
324
  vertex_colors=vertex_colors,
325
  process=True,
326
  )
327
+ # Force double-sided so surfels are visible regardless of normal flip after the Y-flip.
328
+ if hasattr(mesh.visual, "material") and hasattr(mesh.visual.material, "doubleSided"):
329
+ mesh.visual.material.doubleSided = True
330
+
331
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".glb").name
332
  mesh.export(out_path)
333
  return out_path