Spaces:
Running on Zero
Running on Zero
| """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) | |