oualidD4 commited on
Commit
cc6d487
·
verified ·
1 Parent(s): 1f5e38a

Compact flat-point GPU payload → frame cap 128 (denser); GLB built from points, verified == predictions_to_glb

Browse files
Files changed (2) hide show
  1. app.py +13 -14
  2. pipeline.py +138 -68
app.py CHANGED
@@ -37,7 +37,7 @@ CKPT_REPO = "robbyant/lingbot-map"
37
  CKPT_FILE = "lingbot-map-long.pt"
38
  NUM_SCALE_FRAMES = 8 # initial bidirectional "scale" frames
39
  KEYFRAME_INTERVAL = 1 # every frame is a keyframe — best quality for short clips
40
- MAX_FRAMES_HARD = 64 # absolute cap (inference ~10s/48 frames ample headroom under 120s)
41
  GPU_DURATION = 120 # seconds requested per GPU call (tune to your tier)
42
 
43
  # --------------------------------------------------------------------------------------
@@ -63,11 +63,10 @@ def gpu_reconstruct(images_cpu):
63
  if not _AGG_BF16_DONE["done"] and getattr(MODEL, "aggregator", None) is not None:
64
  MODEL.aggregator = MODEL.aggregator.to(dtype=torch.bfloat16)
65
  _AGG_BF16_DONE["done"] = True
66
- return P.reconstruct_predictions(
67
  MODEL, images_cpu,
68
  num_scale_frames=NUM_SCALE_FRAMES,
69
  keyframe_interval=KEYFRAME_INTERVAL,
70
- offload=True,
71
  )
72
 
73
 
@@ -108,26 +107,26 @@ def reconstruct(image_files, video_file, fps, max_frames, conf_thres, show_cam):
108
  images = P.preprocess_paths(paths) # [S, 3, H, W] on CPU
109
  s, _, h, w = images.shape
110
 
111
- vis = gpu_reconstruct(images) # GPU; returns NumPy vis-dict
112
  t_infer = time.time() - t0
113
 
114
  t1 = time.time()
115
- glb = P.export_glb(vis, conf_thres=conf_thres, show_cam=show_cam)
116
  t_export = time.time() - t1
117
 
118
- kept, total = P.count_visible_points(vis, conf_thres)
119
  status = _status_md(source, s, h, w, kept, total, t_infer, t_export, conf_thres)
120
- return glb, vis, status
121
 
122
 
123
- def reexport(vis, conf_thres, show_cam):
124
  """CPU-only re-render when the user nudges the confidence filter / camera toggle.
125
- Uses the cached vis-dict — no GPU, no re-inference."""
126
- if not vis:
127
  return None, "Run a reconstruction first."
128
  t1 = time.time()
129
- glb = P.export_glb(vis, conf_thres=conf_thres, show_cam=show_cam)
130
- kept, total = P.count_visible_points(vis, conf_thres)
131
  pct = (100.0 * kept / total) if total else 0.0
132
  msg = (
133
  f"Re-rendered (CPU). Points kept: **{kept:,} / {total:,}** ({pct:.0f}%) at "
@@ -163,8 +162,8 @@ def build_demo():
163
  with gr.Accordion("Settings", open=True):
164
  fps = gr.Slider(1, 12, value=6, step=1,
165
  label="Video sampling FPS (frames/sec to extract)")
166
- max_frames = gr.Slider(2, MAX_FRAMES_HARD, value=48, step=1,
167
- label="Max frames (spread evenly across the whole video)")
168
  conf_thres = gr.Slider(0, 95, value=60, step=1,
169
  label="Confidence filter — drops the lowest-confidence % of points (raise to declutter)")
170
  show_cam = gr.Checkbox(value=True, label="Show camera trajectory")
 
37
  CKPT_FILE = "lingbot-map-long.pt"
38
  NUM_SCALE_FRAMES = 8 # initial bidirectional "scale" frames
39
  KEYFRAME_INTERVAL = 1 # every frame is a keyframe — best quality for short clips
40
+ MAX_FRAMES_HARD = 128 # absolute cap (~0.2s/frame~25s at 128, well under the 120s budget)
41
  GPU_DURATION = 120 # seconds requested per GPU call (tune to your tier)
42
 
43
  # --------------------------------------------------------------------------------------
 
63
  if not _AGG_BF16_DONE["done"] and getattr(MODEL, "aggregator", None) is not None:
64
  MODEL.aggregator = MODEL.aggregator.to(dtype=torch.bfloat16)
65
  _AGG_BF16_DONE["done"] = True
66
+ return P.reconstruct_points(
67
  MODEL, images_cpu,
68
  num_scale_frames=NUM_SCALE_FRAMES,
69
  keyframe_interval=KEYFRAME_INTERVAL,
 
70
  )
71
 
72
 
 
107
  images = P.preprocess_paths(paths) # [S, 3, H, W] on CPU
108
  s, _, h, w = images.shape
109
 
110
+ rec = gpu_reconstruct(images) # GPU; returns a COMPACT flat point set
111
  t_infer = time.time() - t0
112
 
113
  t1 = time.time()
114
+ glb = P.build_glb_from_points(rec, conf_thres=conf_thres, show_cam=show_cam)
115
  t_export = time.time() - t1
116
 
117
+ kept, total = P.count_points(rec, conf_thres)
118
  status = _status_md(source, s, h, w, kept, total, t_infer, t_export, conf_thres)
119
+ return glb, rec, status
120
 
121
 
122
+ def reexport(rec, conf_thres, show_cam):
123
  """CPU-only re-render when the user nudges the confidence filter / camera toggle.
124
+ Uses the cached compact point set — no GPU, no re-inference."""
125
+ if not rec:
126
  return None, "Run a reconstruction first."
127
  t1 = time.time()
128
+ glb = P.build_glb_from_points(rec, conf_thres=conf_thres, show_cam=show_cam)
129
+ kept, total = P.count_points(rec, conf_thres)
130
  pct = (100.0 * kept / total) if total else 0.0
131
  msg = (
132
  f"Re-rendered (CPU). Points kept: **{kept:,} / {total:,}** ({pct:.0f}%) at "
 
162
  with gr.Accordion("Settings", open=True):
163
  fps = gr.Slider(1, 12, value=6, step=1,
164
  label="Video sampling FPS (frames/sec to extract)")
165
+ max_frames = gr.Slider(2, MAX_FRAMES_HARD, value=80, step=1,
166
+ label="Max frames — higher = denser & more complete (spread across the whole video)")
167
  conf_thres = gr.Slider(0, 95, value=60, step=1,
168
  label="Confidence filter — drops the lowest-confidence % of points (raise to declutter)")
169
  show_cam = gr.Checkbox(value=True, label="Show camera trajectory")
pipeline.py CHANGED
@@ -360,39 +360,86 @@ def reconstruct_predictions(
360
  # GLB export (CPU only — no GPU needed)
361
  # =============================================================================
362
 
363
- def _add_trajectory_line(scene, extrinsic, seed: int = 0):
364
- """Add a single clean colored tube tracing the camera path (README "track" style),
365
- in the SAME aligned frame predictions_to_glb uses. Far less cluttered than drawing a
366
- frustum per frame. Colors run start→end along gist_rainbow."""
367
- import trimesh
368
- from scipy.spatial.transform import Rotation
369
- import matplotlib
370
-
371
- extrinsic = np.asarray(extrinsic, dtype=np.float64)
372
- S = len(extrinsic)
373
- if S < 2:
374
- return scene
375
  w2c = np.repeat(np.eye(4)[None], S, axis=0)
376
- w2c[:, :3, :4] = extrinsic
377
- centers = np.linalg.inv(w2c)[:, :3, 3] # world camera centers (c2w t)
378
- # Replicate predictions_to_glb.apply_scene_alignment so the line overlays the cloud.
 
 
 
 
 
 
 
379
  opengl = np.eye(4); opengl[1, 1] = -1.0; opengl[2, 2] = -1.0
380
  align = np.eye(4); align[:3, :3] = Rotation.from_euler("y", 180, degrees=True).as_matrix()
381
- initial = np.linalg.inv(w2c[0]) @ opengl @ align
382
- pts = (np.c_[centers, np.ones(S)] @ initial.T)[:, :3] # aligned camera centers
383
- ext = float(np.linalg.norm(pts.max(0) - pts.min(0))) or 1.0
 
 
 
 
 
 
 
 
 
384
  radius = max(ext * 0.004, 1e-5)
385
  cmap = matplotlib.colormaps.get_cmap("gist_rainbow")
386
  tubes = []
387
  for i in range(S - 1):
388
- if np.linalg.norm(pts[i + 1] - pts[i]) < 1e-9:
389
  continue
390
- seg = trimesh.creation.cylinder(radius=radius, segment=pts[i:i + 2], sections=8)
391
  seg.visual.vertex_colors = tuple(int(255 * x) for x in cmap(i / (S - 1))[:3]) + (255,)
392
  tubes.append(seg)
393
  if tubes:
394
  scene.add_geometry(trimesh.util.concatenate(tubes))
395
- return scene
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
 
398
  def export_glb(
@@ -403,66 +450,89 @@ def export_glb(
403
  mask_sky: bool = False,
404
  target_dir: Optional[str] = None,
405
  ) -> str:
406
- """Export the vis dict to a .glb and return its path.
 
 
 
407
 
408
- The streaming model (GCTStream) is built with ``enable_point=False``, so it emits
409
- **depth**, not a point map. We unproject depth → world points (``world_points_from_depth``,
410
- via the camera w2c extrinsics + intrinsics) and use the exporter's *Depthmap* branch,
411
- which filters by ``depth_conf``. If a point map is present (e.g. a future enable_point
412
- build), we use it directly via the *Pointmap* branch instead.
413
 
414
- Reminder: ``conf_thres`` is a *percentile* — points below the ``conf_thres``-th
415
- percentile of confidence are dropped. 0 keeps everything; higher = fewer points.
 
 
 
 
 
 
 
 
 
 
 
416
  """
 
417
  if out_path is None:
418
  out_path = os.path.join(tempfile.mkdtemp(prefix="lingbot_glb_"), "scene.glb")
419
 
420
- preds = dict(vis_predictions) # shallow copy; we may add world_points_from_depth
421
- if "world_points" in preds:
422
- prediction_mode = "Predicted Pointmap"
423
- else:
424
- preds["world_points_from_depth"] = unproject_depth_map_to_point_map(
425
- preds["depth"], preds["extrinsic"], preds["intrinsic"]
426
- )
427
- prediction_mode = "Predicted Depthmap"
428
-
429
- scene = predictions_to_glb(
430
- preds,
431
- conf_thres=conf_thres,
432
- show_cam=False, # draw a single clean trajectory line instead of per-frame frustums
433
- mask_sky=mask_sky,
434
- target_dir=target_dir,
435
- prediction_mode=prediction_mode,
436
- )
437
- if show_cam and "extrinsic" in preds:
438
- _add_trajectory_line(scene, preds["extrinsic"])
439
-
440
- # Downsample the point cloud so the browser viewer (model-viewer) stays smooth — it
441
- # bogs down past a few million points. Camera frustums are left intact.
442
- import trimesh
443
- display_max = 2_000_000
444
- for _name in list(scene.geometry.keys()):
445
- _g = scene.geometry[_name]
446
- if isinstance(_g, trimesh.PointCloud) and len(_g.vertices) > display_max:
447
- _sel = np.random.default_rng(0).choice(len(_g.vertices), display_max, replace=False)
448
- _cols = np.asarray(_g.colors) if _g.colors is not None else None
449
- _cols = _cols[_sel] if (_cols is not None and len(_cols) == len(_g.vertices)) else None
450
- scene.geometry[_name] = trimesh.PointCloud(vertices=np.asarray(_g.vertices)[_sel], colors=_cols)
451
-
452
  scene.export(out_path)
453
  return out_path
454
 
455
 
456
- def count_visible_points(vis_predictions: dict, conf_thres: float = 50.0) -> Tuple[int, int]:
457
- """(kept, total) point counts after the same percentile filter the exporter uses.
458
- Uses world_points_conf in Pointmap mode, else depth_conf (the streaming/depth path).
459
- Handy for the status readout so users can see when conf_thres nukes the cloud.
460
- """
461
- conf_key = "world_points_conf" if "world_points" in vis_predictions else "depth_conf"
462
- conf = np.asarray(vis_predictions[conf_key]).reshape(-1)
463
  total = conf.size
464
  if total == 0:
465
  return 0, 0
466
  thr = np.percentile(conf, conf_thres) if conf_thres > 0 else 0.0
467
  kept = int(np.count_nonzero((conf >= thr) & (conf > 1e-5)))
468
  return kept, total
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  # GLB export (CPU only — no GPU needed)
361
  # =============================================================================
362
 
363
+ def _camera_centers_world(extrinsic):
364
+ """(camera centers in world coords [S,3], w2c 4x4 [S,4,4]) from w2c [S,3,4]."""
365
+ extr = np.asarray(extrinsic, dtype=np.float64)
366
+ S = len(extr)
 
 
 
 
 
 
 
 
367
  w2c = np.repeat(np.eye(4)[None], S, axis=0)
368
+ w2c[:, :3, :4] = extr
369
+ centers = np.linalg.inv(w2c)[:, :3, 3]
370
+ return centers, w2c
371
+
372
+
373
+ def _alignment_transform(w2c):
374
+ """The same scene alignment predictions_to_glb applies (orient to the first camera +
375
+ flip into the GLB/OpenGL convention). Applied to the WHOLE scene (points + trajectory).
376
+ Verified to match predictions_to_glb's apply_scene_alignment."""
377
+ from scipy.spatial.transform import Rotation
378
  opengl = np.eye(4); opengl[1, 1] = -1.0; opengl[2, 2] = -1.0
379
  align = np.eye(4); align[:3, :3] = Rotation.from_euler("y", 180, degrees=True).as_matrix()
380
+ return np.linalg.inv(w2c[0]) @ opengl @ align
381
+
382
+
383
+ def _add_trajectory_tube(scene, centers_world):
384
+ """Add a clean colored tube tracing the camera path (README "track" style) in WORLD
385
+ coords; the caller applies the scene alignment afterwards. gist_rainbow start→end."""
386
+ import trimesh
387
+ import matplotlib
388
+ S = len(centers_world)
389
+ if S < 2:
390
+ return
391
+ ext = float(np.linalg.norm(centers_world.max(0) - centers_world.min(0))) or 1.0
392
  radius = max(ext * 0.004, 1e-5)
393
  cmap = matplotlib.colormaps.get_cmap("gist_rainbow")
394
  tubes = []
395
  for i in range(S - 1):
396
+ if np.linalg.norm(centers_world[i + 1] - centers_world[i]) < 1e-9:
397
  continue
398
+ seg = trimesh.creation.cylinder(radius=radius, segment=centers_world[i:i + 2], sections=8)
399
  seg.visual.vertex_colors = tuple(int(255 * x) for x in cmap(i / (S - 1))[:3]) + (255,)
400
  tubes.append(seg)
401
  if tubes:
402
  scene.add_geometry(trimesh.util.concatenate(tubes))
403
+
404
+
405
+ def _flatten_vis(vis_predictions: dict) -> dict:
406
+ """Flatten a vis dict → a compact flat point set in WORLD coords (the format the GPU
407
+ call returns and the GLB builder consumes). Unprojects depth at full resolution.
408
+ Returns {points (N,3) f32, colors (N,3) u8, conf (N,) f32, extrinsic (S,3,4) f32}.
409
+ """
410
+ vis = vis_predictions
411
+ if "world_points" in vis:
412
+ world = np.asarray(vis["world_points"], np.float32)
413
+ conf = np.asarray(vis.get("world_points_conf"), np.float32)
414
+ else:
415
+ world = np.asarray(
416
+ unproject_depth_map_to_point_map(vis["depth"], vis["extrinsic"], vis["intrinsic"]),
417
+ np.float32,
418
+ )
419
+ conf = np.asarray(vis["depth_conf"], np.float32)
420
+ imgs = np.asarray(vis["images"])
421
+ if imgs.ndim == 4 and imgs.shape[1] == 3: # NCHW → NHWC
422
+ imgs = np.transpose(imgs, (0, 2, 3, 1))
423
+ return {
424
+ "points": world.reshape(-1, 3),
425
+ "colors": np.clip(imgs.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8),
426
+ "conf": conf.reshape(-1),
427
+ "extrinsic": np.asarray(vis["extrinsic"], np.float32),
428
+ }
429
+
430
+
431
+ def _downsample_rec(rec: dict, max_points: int, seed: int = 0) -> dict:
432
+ """Drop invalid points and randomly cap to max_points so the GPU→app payload stays
433
+ bounded and independent of frame count."""
434
+ pts, cols, conf = rec["points"], rec["colors"], rec["conf"]
435
+ valid = conf > 1e-5
436
+ pts, cols, conf = pts[valid], cols[valid], conf[valid]
437
+ n_total = int(pts.shape[0])
438
+ if n_total > max_points:
439
+ sel = np.random.default_rng(seed).choice(n_total, max_points, replace=False)
440
+ pts, cols, conf = pts[sel], cols[sel], conf[sel]
441
+ return {"points": pts, "colors": cols, "conf": conf,
442
+ "extrinsic": rec["extrinsic"], "n_total": n_total}
443
 
444
 
445
  def export_glb(
 
450
  mask_sky: bool = False,
451
  target_dir: Optional[str] = None,
452
  ) -> str:
453
+ """Convenience wrapper (smoke_test / local CPU runs): flatten a vis dict build the GLB.
454
+ The Space uses reconstruct_points() + build_glb_from_points() directly (compact payload)."""
455
+ rec = _flatten_vis(vis_predictions)
456
+ return build_glb_from_points(rec, out_path=out_path, conf_thres=conf_thres, show_cam=show_cam)
457
 
 
 
 
 
 
458
 
459
+ def build_glb_from_points(
460
+ rec: dict,
461
+ out_path: Optional[str] = None,
462
+ conf_thres: float = 60.0,
463
+ show_cam: bool = True,
464
+ display_max: int = 2_000_000,
465
+ seed: int = 0,
466
+ ) -> str:
467
+ """Build the .glb from a flat point set: percentile-filter by confidence, downsample for
468
+ smooth in-browser rendering, add the camera-path line, and apply the scene alignment.
469
+
470
+ ``conf_thres`` is a *percentile* — points below the conf_thres-th percentile of
471
+ confidence are dropped. 0 keeps everything; higher = fewer points.
472
  """
473
+ import trimesh
474
  if out_path is None:
475
  out_path = os.path.join(tempfile.mkdtemp(prefix="lingbot_glb_"), "scene.glb")
476
 
477
+ pts = np.asarray(rec["points"], np.float64)
478
+ cols = np.asarray(rec["colors"])
479
+ conf = np.asarray(rec["conf"], np.float64)
480
+
481
+ if conf.size:
482
+ thr = np.percentile(conf, conf_thres) if conf_thres > 0 else 0.0
483
+ mask = (conf >= thr) & (conf > 1e-5)
484
+ pts, cols = pts[mask], cols[mask]
485
+ if len(pts) > display_max: # browser viewer bogs down past a few M points
486
+ sel = np.random.default_rng(seed).choice(len(pts), display_max, replace=False)
487
+ pts, cols = pts[sel], cols[sel]
488
+ if len(pts) == 0: # degenerate guard
489
+ pts, cols = np.zeros((1, 3)), np.array([[255, 255, 255]])
490
+
491
+ scene = trimesh.Scene()
492
+ scene.add_geometry(trimesh.PointCloud(vertices=pts, colors=cols))
493
+ centers, w2c = _camera_centers_world(rec["extrinsic"])
494
+ if show_cam:
495
+ _add_trajectory_tube(scene, centers)
496
+ scene.apply_transform(_alignment_transform(w2c)) # align points + trajectory together
 
 
 
 
 
 
 
 
 
 
 
 
497
  scene.export(out_path)
498
  return out_path
499
 
500
 
501
+ def count_points(rec: dict, conf_thres: float = 60.0) -> Tuple[int, int]:
502
+ """(kept, total) after the same percentile filter build_glb_from_points uses."""
503
+ conf = np.asarray(rec["conf"]).reshape(-1)
 
 
 
 
504
  total = conf.size
505
  if total == 0:
506
  return 0, 0
507
  thr = np.percentile(conf, conf_thres) if conf_thres > 0 else 0.0
508
  kept = int(np.count_nonzero((conf >= thr) & (conf > 1e-5)))
509
  return kept, total
510
+
511
+
512
+ def count_visible_points(vis_predictions: dict, conf_thres: float = 60.0) -> Tuple[int, int]:
513
+ """Back-compat wrapper for the vis-dict form (smoke_test)."""
514
+ return count_points(_flatten_vis(vis_predictions), conf_thres)
515
+
516
+
517
+ # =============================================================================
518
+ # GPU entry point that returns a COMPACT, frame-count-independent payload
519
+ # =============================================================================
520
+
521
+ def reconstruct_points(
522
+ model: GCTStream,
523
+ images: torch.Tensor,
524
+ num_scale_frames: int = 8,
525
+ keyframe_interval: int = 1,
526
+ max_return_points: int = 6_000_000,
527
+ ) -> dict:
528
+ """End-to-end GPU path → a COMPACT flat point set whose size is bounded (independent of
529
+ frame count). This is what lets the Space return many frames across the @spaces.GPU
530
+ boundary without a huge per-pixel payload.
531
+
532
+ Returns {points (M,3) f32, colors (M,3) u8, conf (M,) f32, extrinsic (S,3,4) f32, n_total}.
533
+ """
534
+ vis = reconstruct_predictions(
535
+ model, images, num_scale_frames=num_scale_frames,
536
+ keyframe_interval=keyframe_interval, offload=True,
537
+ )
538
+ return _downsample_rec(_flatten_vis(vis), max_return_points)