Rawal Khirodkar commited on
Commit
bece70e
Β·
1 Parent(s): 409a27b

Pointmap: switch back to spheres (KHR_materials_unlit injection makes them flat-coloured)

Browse files
Files changed (1) hide show
  1. app.py +18 -59
app.py CHANGED
@@ -236,93 +236,52 @@ 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 = 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: surface-spacing estimate = bbox diagonal / sqrt(N).
276
- # This is geometrically correct for points distributed across a 2D surface;
277
- # raw KD-tree NN distance is biased too small (adjacent pixels often map
278
- # to nearly-coincident 3D points, pulling the median way below the actual
279
- # visual spacing). Factor 1.5 gives clear overlap so the surface reads
280
- # as solid skin rather than a sparse tile pattern.
281
  if len(pts) >= 2:
282
  extent_diag = float(np.linalg.norm(np.ptp(pts, axis=0)))
283
  r = max(extent_diag / np.sqrt(len(pts)) * 1.5, 1e-4)
284
  else:
285
  r = 0.005
286
 
287
- # Tangent basis (u, v) per surfel, perpendicular to that surfel's normal.
288
- up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
289
- u = np.cross(nrm, up) # (N, 3)
290
- u_norm = np.linalg.norm(u, axis=1, keepdims=True)
291
- # Fallback for any normals nearly parallel to "up".
292
- parallel = (u_norm.squeeze(-1) < 1e-4)
293
- if parallel.any():
294
- ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
295
- u[parallel] = np.cross(nrm[parallel], ref)
296
- u_norm[parallel] = np.linalg.norm(u[parallel], axis=1, keepdims=True).clip(min=1e-8)
297
- u = u / u_norm.clip(min=1e-8)
298
- v = np.cross(nrm, u) # (N, 3) β€” already unit
299
-
300
- # 4 corners per surfel (CCW when viewed from +normal):
301
- # 0 = P βˆ’ r*u βˆ’ r*v
302
- # 1 = P + r*u βˆ’ r*v
303
- # 2 = P + r*u + r*v
304
- # 3 = P βˆ’ r*u + r*v
305
- ru, rv = r * u, r * v
306
- c0 = pts - ru - rv
307
- c1 = pts + ru - rv
308
- c2 = pts + ru + rv
309
- c3 = pts - ru + rv
310
- verts = np.stack([c0, c1, c2, c3], axis=1).reshape(-1, 3).astype(np.float32)
311
-
312
- # 2 triangles per surfel: (0,1,2) and (0,2,3), offset by 4 per surfel.
313
  N = len(pts)
314
- base = (np.arange(N, dtype=np.int64) * 4)[:, None, None]
315
- tri = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)[None, :, :]
316
- faces = (base + tri).reshape(-1, 3)
317
 
318
- # Uniform colour across each surfel's 4 verts.
319
- vc = np.empty((N, 4, 4), dtype=np.uint8)
 
 
 
320
  vc[..., :3] = cols[:, None, :]
321
  vc[..., 3] = 255
322
  vertex_colors = vc.reshape(-1, 4)
323
 
324
- # Plain vertex colors only β€” no PBR material customization, no normals,
325
- # nothing that would inject lighting variation between surfels.
326
  mesh = trimesh.Trimesh(
327
  vertices=verts, faces=faces,
328
  vertex_colors=vertex_colors,
@@ -330,7 +289,7 @@ def _make_glb(image_pil_texture: Image.Image, pointmap_hwc: np.ndarray,
330
  )
331
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".glb").name
332
  mesh.export(out_path)
333
- _glb_inject_unlit(out_path) # β†’ render with raw vertex color, no lighting
334
  return out_path
335
 
336
 
 
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 small icospheres β€” one per pixel,
241
+ coloured by the source image. With KHR_materials_unlit injected post-
242
+ export, each ball renders as a flat-coloured 3D primitive (no lighting
243
+ variation), giving a clean "candy ball cloud" surface."""
 
 
 
244
  H, W = pointmap_hwc.shape[:2]
245
  z = pointmap_hwc[:, :, 2]
246
  valid = mask_hw & np.isfinite(pointmap_hwc).all(axis=2) & (z > 0.05) & (z < 25.0)
 
 
 
 
 
 
 
 
 
247
  yy, xx = np.where(valid)
248
  if len(yy) > max_points:
249
  idx = np.random.default_rng(0).choice(len(yy), max_points, replace=False)
250
  yy, xx = yy[idx], xx[idx]
251
 
252
  pts = pointmap_hwc[yy, xx].astype(np.float32)
 
253
  image_native = np.asarray(image_pil_texture.resize((W, H), Image.LANCZOS))
254
  cols = image_native[yy, xx].astype(np.uint8)
255
 
256
  flip = np.array([1.0, -1.0, -1.0], dtype=np.float32)
257
  pts = pts * flip
 
258
  centroid = pts.mean(axis=0).astype(np.float32) if len(pts) else np.zeros(3, np.float32)
259
  pts = pts - centroid
260
 
261
+ # Sphere radius: surface-spacing estimate = bbox diagonal / sqrt(N).
262
+ # Factor 1.0 β†’ balls just kiss neighbours; 1.5 β†’ distinct overlap.
 
 
 
 
263
  if len(pts) >= 2:
264
  extent_diag = float(np.linalg.norm(np.ptp(pts, axis=0)))
265
  r = max(extent_diag / np.sqrt(len(pts)) * 1.5, 1e-4)
266
  else:
267
  r = 0.005
268
 
269
+ # Smooth icosphere template (subdivisions=1 β†’ 42 verts, 80 faces).
270
+ sphere = trimesh.creation.icosphere(subdivisions=1, radius=r)
271
+ sv = sphere.vertices.astype(np.float32)
272
+ sf = sphere.faces.astype(np.int64)
273
+ V, F = len(sv), len(sf)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  N = len(pts)
 
 
 
275
 
276
+ # Vectorized instancing: place a sphere at every point.
277
+ verts = (sv[None, :, :] + pts[:, None, :]).reshape(-1, 3)
278
+ face_off = (np.arange(N, dtype=np.int64) * V)[:, None, None]
279
+ faces = (sf[None, :, :] + face_off).reshape(-1, 3)
280
+ vc = np.empty((N, V, 4), dtype=np.uint8)
281
  vc[..., :3] = cols[:, None, :]
282
  vc[..., 3] = 255
283
  vertex_colors = vc.reshape(-1, 4)
284
 
 
 
285
  mesh = trimesh.Trimesh(
286
  vertices=verts, faces=faces,
287
  vertex_colors=vertex_colors,
 
289
  )
290
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".glb").name
291
  mesh.export(out_path)
292
+ _glb_inject_unlit(out_path) # β†’ flat-coloured balls, no shading
293
  return out_path
294
 
295