lingbot-map-3d / pipeline.py
oualidD4's picture
Compact flat-point GPU payload → frame cap 128 (denser); GLB built from points, verified == predictions_to_glb
cc6d487 verified
Raw
History Blame Contribute Delete
21.5 kB
"""Core LingBot-Map reconstruction pipeline (device-agnostic building blocks).
Shared by ``app.py`` (Gradio / ZeroGPU front-end) and ``smoke_test.py`` (local CLI).
Nothing here is ZeroGPU-specific — the GPU attach / bf16 dance lives in ``app.py`` —
so this module can be imported and exercised on a plain CPU box for verification.
Attribution
-----------
Built on LingBot-Map by the Robbyant Team (Apache-2.0):
https://github.com/Robbyant/lingbot-map
``postprocess``, ``prepare_for_visualization`` and the video-frame extraction logic
are adapted (lightly) from the upstream ``demo.py``. The model itself builds on
VGGT and DINOv2. See the Space README for full credits.
"""
from __future__ import annotations
import os
import tempfile
from typing import List, Optional, Sequence, Tuple
import numpy as np
import torch
from lingbot_map.models.gct_stream import GCTStream
from lingbot_map.utils.pose_enc import pose_encoding_to_extri_intri
from lingbot_map.utils.geometry import unproject_depth_map_to_point_map
# (w2c kept for the GLB path → closed_form_inverse_se3_general intentionally NOT imported)
# NB: preprocess_paths() reimplements upstream load_and_preprocess_images WITHOUT torchvision
# (the ZeroGPU base image ships torch but not torchvision), so nothing here imports torchvision.
# NB: import the exporter from the *submodule*, not ``lingbot_map.vis``. The package
# __init__ imports the viser-based PointCloudViewer at load time, which would force a
# viser install (and a crash without it). glb_export only needs numpy/cv2/matplotlib/
# scipy/trimesh.
from lingbot_map.vis.glb_export import predictions_to_glb
# =============================================================================
# Model construction
# =============================================================================
def build_model(
ckpt_path: Optional[str] = None,
device: str = "cpu",
camera_num_iterations: int = 4,
use_sdpa: bool = True,
) -> GCTStream:
"""Build GCTStream with the same config as upstream ``demo.load_model`` and
(optionally) load a checkpoint.
``use_sdpa=True`` selects the built-in scaled-dot-product-attention fallback so
no FlashInfer source build is needed — the right choice for ZeroGPU.
"""
model = GCTStream(
img_size=518,
patch_size=14,
enable_3d_rope=True,
max_frame_num=1024,
kv_cache_sliding_window=64,
kv_cache_scale_frames=8,
kv_cache_cross_frame_special=True,
kv_cache_include_scale_frames=True,
use_sdpa=use_sdpa,
camera_num_iterations=camera_num_iterations,
)
if ckpt_path:
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
state_dict = ckpt.get("model", ckpt)
missing, unexpected = model.load_state_dict(state_dict, strict=False)
if missing:
print(f"[build_model] missing keys: {len(missing)}")
if unexpected:
print(f"[build_model] unexpected keys: {len(unexpected)}")
return model.to(device).eval()
# =============================================================================
# Input gathering / preprocessing
# =============================================================================
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPG", ".JPEG", ".PNG")
def list_image_paths(files: Optional[Sequence]) -> List[str]:
"""Normalise a Gradio file upload (paths or file-like objs) to a sorted list of
image paths. Sorted by basename so frames named 000.jpg, 001.jpg, ... keep order.
"""
if not files:
return []
paths = []
for f in files:
p = f if isinstance(f, str) else getattr(f, "name", None)
if p and p.lower().endswith(tuple(e.lower() for e in _IMAGE_EXTS)):
paths.append(p)
return sorted(paths, key=lambda p: os.path.basename(p))
def extract_video_frames(
video_path: str,
fps: int = 10,
max_frames: int = 60,
out_dir: Optional[str] = None,
) -> Tuple[List[str], str]:
"""Sample up to ``max_frames`` frames spread EVENLY across the WHOLE video.
This is the key to reconstructing a full walkthrough (e.g. an apartment tour through
several rooms): we pick frame indices uniformly over the entire duration instead of
grabbing the first N from the start (which would only ever show the opening room).
``fps`` bounds the density for short clips; for long clips ``max_frames`` is the
binding cap and the chosen frames still span end-to-end. Memory-safe (reads
frame-by-frame, O(1) memory). Frames are written to a fresh temp dir.
"""
import cv2
if out_dir is None:
out_dir = tempfile.mkdtemp(prefix="lingbot_frames_")
cap = cv2.VideoCapture(video_path)
src_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
if total <= 0: # CAP_PROP_FRAME_COUNT is unreliable for some codecs → count by grabbing
total = 0
while cap.grab():
total += 1
cap.release()
cap = cv2.VideoCapture(video_path)
total = max(total, 1)
# how many frames the target fps yields over the whole clip; cap by max_frames + availability
n_at_fps = max(1, int(round(total * float(fps) / max(1.0, src_fps))))
n = max(2, min(int(max_frames), n_at_fps, total))
want = {int(round(x)) for x in np.linspace(0, total - 1, n)}
saved, idx = [], 0
while True:
ret, frame = cap.read()
if not ret:
break
if idx in want:
path = os.path.join(out_dir, f"{len(saved):06d}.jpg")
cv2.imwrite(path, frame)
saved.append(path)
idx += 1
cap.release()
return saved, out_dir
def preprocess_paths(
paths: Sequence[str],
image_size: int = 518,
patch_size: int = 14,
) -> torch.Tensor:
"""Preprocess images to the model's canonical input — a **torchvision-free**, faithful
reimplementation of upstream ``load_and_preprocess_images`` (crop mode).
The ZeroGPU base image ships torch but NOT torchvision, and upstream's loader imports
``torchvision.transforms`` only for ``ToTensor``. We replicate the exact logic (EXIF
transpose, alpha-on-white, width=518, height→multiple of patch_size, center-crop height,
pad mismatched shapes with white) and swap ToTensor for a numpy→torch equivalent.
Returns a tensor of shape [S, 3, H, W] in range [0, 1].
"""
from PIL import Image, ImageOps
def to_tensor_rgb(pil_img):
# torchvision.transforms.ToTensor() equivalent for an 8-bit RGB image:
# HWC uint8 [0,255] -> CHW float32 [0,1]
arr = np.array(pil_img, dtype=np.uint8) # [H, W, 3] (copy → writable)
return torch.from_numpy(arr).permute(2, 0, 1).contiguous().float().div_(255.0)
target = image_size
imgs = []
for p in paths:
img = Image.open(p)
img = ImageOps.exif_transpose(img) # honor phone/camera EXIF
if img.mode == "RGBA":
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(bg, img)
img = img.convert("RGB")
w, h = img.size
new_w = target
new_h = round(h * (new_w / w) / patch_size) * patch_size
img = img.resize((new_w, new_h), Image.Resampling.BICUBIC)
t = to_tensor_rgb(img) # [3, new_h, new_w]
if new_h > target: # center-crop height
sy = (new_h - target) // 2
t = t[:, sy:sy + target, :]
imgs.append(t)
# If frames differ in shape (mixed aspect ratios), pad to the max with white (1.0).
shapes = {(t.shape[1], t.shape[2]) for t in imgs}
if len(shapes) > 1:
max_h = max(s[0] for s in shapes)
max_w = max(s[1] for s in shapes)
out = []
for t in imgs:
ph, pw = max_h - t.shape[1], max_w - t.shape[2]
if ph > 0 or pw > 0:
pt, pb = ph // 2, ph - ph // 2
pl, pr = pw // 2, pw - pw // 2
t = torch.nn.functional.pad(t, (pl, pr, pt, pb), mode="constant", value=1.0)
out.append(t)
imgs = out
return torch.stack(imgs)
# =============================================================================
# Inference + post-processing
# =============================================================================
def infer(
model: GCTStream,
images: torch.Tensor,
num_scale_frames: int = 8,
keyframe_interval: int = 1,
output_device: Optional[torch.device] = None,
) -> dict:
"""Run streaming inference under ``no_grad`` (+ autocast on CUDA).
The model is expected to already be on its target device. dtype is chosen from
that device: bf16 on capability>=8 GPUs (all ZeroGPU hardware), fp16 on older
GPUs, fp32 on CPU.
"""
device = next(model.parameters()).device
images = images.to(device)
if device.type == "cuda":
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16
with torch.no_grad(), torch.amp.autocast("cuda", dtype=dtype):
return model.inference_streaming(
images,
num_scale_frames=num_scale_frames,
keyframe_interval=keyframe_interval,
output_device=output_device,
)
with torch.no_grad():
return model.inference_streaming(
images,
num_scale_frames=num_scale_frames,
keyframe_interval=keyframe_interval,
output_device=output_device,
)
# ---- copied verbatim from upstream demo.py (Apache-2.0) so the Space is
# ---- self-contained and does not depend on demo.py being importable. ----
_BATCHED_NDIMS = {
"pose_enc": 3,
"depth": 5,
"depth_conf": 4,
"world_points": 5,
"world_points_conf": 4,
"extrinsic": 4,
"intrinsic": 4,
"chunk_scales": 2,
"chunk_transforms": 4,
"images": 5,
}
def _squeeze_single_batch(key, value):
"""Drop the leading batch dimension for single-sequence demo outputs."""
batched_ndim = _BATCHED_NDIMS.get(key)
if batched_ndim is None or not hasattr(value, "ndim"):
return value
if value.ndim == batched_ndim and value.shape[0] == 1:
return value[0]
return value
def postprocess(predictions, images):
"""Decode pose encoding → (w2c) extrinsics + intrinsics, move everything to CPU.
COORDINATE CONVENTION (important): ``pose_encoding_to_extri_intri`` returns
*world-to-camera* (w2c) extrinsics (OpenCV "camera-from-world"). We KEEP w2c —
unlike upstream demo.py, which inverts to c2w for its **viser** viewer.
``predictions_to_glb`` expects **w2c** (it inverts internally to place camera
frustums and to align the scene), so handing it c2w would misplace every camera
and flip the scene's orientation. Keeping w2c is the correct input for the GLB path.
"""
extrinsic, intrinsic = pose_encoding_to_extri_intri(predictions["pose_enc"], images.shape[-2:])
predictions["extrinsic"] = extrinsic # w2c, [B, S, 3, 4]
predictions["intrinsic"] = intrinsic
predictions.pop("pose_enc_list", None)
predictions.pop("images", None)
for k in list(predictions.keys()):
if isinstance(predictions[k], torch.Tensor):
predictions[k] = _squeeze_single_batch(
k, predictions[k].to("cpu", non_blocking=True)
)
images_cpu = images.to("cpu", non_blocking=True)
if torch.cuda.is_available():
torch.cuda.synchronize()
return predictions, images_cpu
def prepare_for_visualization(predictions, images=None):
"""Convert predictions to the unbatched NumPy format used by vis code."""
vis_predictions = {}
for k, v in predictions.items():
if isinstance(v, torch.Tensor):
v = _squeeze_single_batch(k, v.detach().cpu())
vis_predictions[k] = v.numpy()
elif isinstance(v, np.ndarray):
vis_predictions[k] = _squeeze_single_batch(k, v)
else:
vis_predictions[k] = v
if images is None:
images = predictions.get("images")
if isinstance(images, torch.Tensor):
images = images.detach().cpu()
if isinstance(images, np.ndarray):
images = _squeeze_single_batch("images", images)
elif isinstance(images, torch.Tensor):
images = _squeeze_single_batch("images", images).numpy()
if isinstance(images, torch.Tensor):
images = images.numpy()
if images is not None:
vis_predictions["images"] = images
return vis_predictions
def reconstruct_predictions(
model: GCTStream,
images: torch.Tensor,
num_scale_frames: int = 8,
keyframe_interval: int = 1,
offload: bool = True,
) -> dict:
"""End-to-end GPU path: inference → postprocess → unbatched NumPy vis dict.
Returns a dict with (at least) world_points [S,H,W,3], world_points_conf [S,H,W],
extrinsic [S,3,4], images [S,3,H,W] — everything ``predictions_to_glb`` needs.
Designed to be the body of the ``@spaces.GPU`` call; its NumPy output is cheap to
cache so the GLB can be re-exported (CPU-only) when the user tweaks conf/show_cam.
"""
output_device = torch.device("cpu") if offload else None
preds = infer(
model, images,
num_scale_frames=num_scale_frames,
keyframe_interval=keyframe_interval,
output_device=output_device,
)
images_for_post = preds["images"] if offload else images
preds, images_cpu = postprocess(preds, images_for_post)
return prepare_for_visualization(preds, images_cpu)
# =============================================================================
# GLB export (CPU only — no GPU needed)
# =============================================================================
def _camera_centers_world(extrinsic):
"""(camera centers in world coords [S,3], w2c 4x4 [S,4,4]) from w2c [S,3,4]."""
extr = np.asarray(extrinsic, dtype=np.float64)
S = len(extr)
w2c = np.repeat(np.eye(4)[None], S, axis=0)
w2c[:, :3, :4] = extr
centers = np.linalg.inv(w2c)[:, :3, 3]
return centers, w2c
def _alignment_transform(w2c):
"""The same scene alignment predictions_to_glb applies (orient to the first camera +
flip into the GLB/OpenGL convention). Applied to the WHOLE scene (points + trajectory).
Verified to match predictions_to_glb's apply_scene_alignment."""
from scipy.spatial.transform import Rotation
opengl = np.eye(4); opengl[1, 1] = -1.0; opengl[2, 2] = -1.0
align = np.eye(4); align[:3, :3] = Rotation.from_euler("y", 180, degrees=True).as_matrix()
return np.linalg.inv(w2c[0]) @ opengl @ align
def _add_trajectory_tube(scene, centers_world):
"""Add a clean colored tube tracing the camera path (README "track" style) in WORLD
coords; the caller applies the scene alignment afterwards. gist_rainbow start→end."""
import trimesh
import matplotlib
S = len(centers_world)
if S < 2:
return
ext = float(np.linalg.norm(centers_world.max(0) - centers_world.min(0))) or 1.0
radius = max(ext * 0.004, 1e-5)
cmap = matplotlib.colormaps.get_cmap("gist_rainbow")
tubes = []
for i in range(S - 1):
if np.linalg.norm(centers_world[i + 1] - centers_world[i]) < 1e-9:
continue
seg = trimesh.creation.cylinder(radius=radius, segment=centers_world[i:i + 2], sections=8)
seg.visual.vertex_colors = tuple(int(255 * x) for x in cmap(i / (S - 1))[:3]) + (255,)
tubes.append(seg)
if tubes:
scene.add_geometry(trimesh.util.concatenate(tubes))
def _flatten_vis(vis_predictions: dict) -> dict:
"""Flatten a vis dict → a compact flat point set in WORLD coords (the format the GPU
call returns and the GLB builder consumes). Unprojects depth at full resolution.
Returns {points (N,3) f32, colors (N,3) u8, conf (N,) f32, extrinsic (S,3,4) f32}.
"""
vis = vis_predictions
if "world_points" in vis:
world = np.asarray(vis["world_points"], np.float32)
conf = np.asarray(vis.get("world_points_conf"), np.float32)
else:
world = np.asarray(
unproject_depth_map_to_point_map(vis["depth"], vis["extrinsic"], vis["intrinsic"]),
np.float32,
)
conf = np.asarray(vis["depth_conf"], np.float32)
imgs = np.asarray(vis["images"])
if imgs.ndim == 4 and imgs.shape[1] == 3: # NCHW → NHWC
imgs = np.transpose(imgs, (0, 2, 3, 1))
return {
"points": world.reshape(-1, 3),
"colors": np.clip(imgs.reshape(-1, 3) * 255.0, 0, 255).astype(np.uint8),
"conf": conf.reshape(-1),
"extrinsic": np.asarray(vis["extrinsic"], np.float32),
}
def _downsample_rec(rec: dict, max_points: int, seed: int = 0) -> dict:
"""Drop invalid points and randomly cap to max_points so the GPU→app payload stays
bounded and independent of frame count."""
pts, cols, conf = rec["points"], rec["colors"], rec["conf"]
valid = conf > 1e-5
pts, cols, conf = pts[valid], cols[valid], conf[valid]
n_total = int(pts.shape[0])
if n_total > max_points:
sel = np.random.default_rng(seed).choice(n_total, max_points, replace=False)
pts, cols, conf = pts[sel], cols[sel], conf[sel]
return {"points": pts, "colors": cols, "conf": conf,
"extrinsic": rec["extrinsic"], "n_total": n_total}
def export_glb(
vis_predictions: dict,
out_path: Optional[str] = None,
conf_thres: float = 50.0,
show_cam: bool = True,
mask_sky: bool = False,
target_dir: Optional[str] = None,
) -> str:
"""Convenience wrapper (smoke_test / local CPU runs): flatten a vis dict → build the GLB.
The Space uses reconstruct_points() + build_glb_from_points() directly (compact payload)."""
rec = _flatten_vis(vis_predictions)
return build_glb_from_points(rec, out_path=out_path, conf_thres=conf_thres, show_cam=show_cam)
def build_glb_from_points(
rec: dict,
out_path: Optional[str] = None,
conf_thres: float = 60.0,
show_cam: bool = True,
display_max: int = 2_000_000,
seed: int = 0,
) -> str:
"""Build the .glb from a flat point set: percentile-filter by confidence, downsample for
smooth in-browser rendering, add the camera-path line, and apply the scene alignment.
``conf_thres`` is a *percentile* — points below the conf_thres-th percentile of
confidence are dropped. 0 keeps everything; higher = fewer points.
"""
import trimesh
if out_path is None:
out_path = os.path.join(tempfile.mkdtemp(prefix="lingbot_glb_"), "scene.glb")
pts = np.asarray(rec["points"], np.float64)
cols = np.asarray(rec["colors"])
conf = np.asarray(rec["conf"], np.float64)
if conf.size:
thr = np.percentile(conf, conf_thres) if conf_thres > 0 else 0.0
mask = (conf >= thr) & (conf > 1e-5)
pts, cols = pts[mask], cols[mask]
if len(pts) > display_max: # browser viewer bogs down past a few M points
sel = np.random.default_rng(seed).choice(len(pts), display_max, replace=False)
pts, cols = pts[sel], cols[sel]
if len(pts) == 0: # degenerate guard
pts, cols = np.zeros((1, 3)), np.array([[255, 255, 255]])
scene = trimesh.Scene()
scene.add_geometry(trimesh.PointCloud(vertices=pts, colors=cols))
centers, w2c = _camera_centers_world(rec["extrinsic"])
if show_cam:
_add_trajectory_tube(scene, centers)
scene.apply_transform(_alignment_transform(w2c)) # align points + trajectory together
scene.export(out_path)
return out_path
def count_points(rec: dict, conf_thres: float = 60.0) -> Tuple[int, int]:
"""(kept, total) after the same percentile filter build_glb_from_points uses."""
conf = np.asarray(rec["conf"]).reshape(-1)
total = conf.size
if total == 0:
return 0, 0
thr = np.percentile(conf, conf_thres) if conf_thres > 0 else 0.0
kept = int(np.count_nonzero((conf >= thr) & (conf > 1e-5)))
return kept, total
def count_visible_points(vis_predictions: dict, conf_thres: float = 60.0) -> Tuple[int, int]:
"""Back-compat wrapper for the vis-dict form (smoke_test)."""
return count_points(_flatten_vis(vis_predictions), conf_thres)
# =============================================================================
# GPU entry point that returns a COMPACT, frame-count-independent payload
# =============================================================================
def reconstruct_points(
model: GCTStream,
images: torch.Tensor,
num_scale_frames: int = 8,
keyframe_interval: int = 1,
max_return_points: int = 6_000_000,
) -> dict:
"""End-to-end GPU path → a COMPACT flat point set whose size is bounded (independent of
frame count). This is what lets the Space return many frames across the @spaces.GPU
boundary without a huge per-pixel payload.
Returns {points (M,3) f32, colors (M,3) u8, conf (M,) f32, extrinsic (S,3,4) f32, n_total}.
"""
vis = reconstruct_predictions(
model, images, num_scale_frames=num_scale_frames,
keyframe_interval=keyframe_interval, offload=True,
)
return _downsample_rec(_flatten_vis(vis), max_return_points)