Просмотр исходного кода

feat(backend+viewer): real reconstructions end-to-end + web-ready walkthrough

Validated the full path on a real room scene (Mip-NeRF360 bonsai): video →
COLMAP → GenRecon SLAT (DINOv3 + TRELLIS) → 8M-face mesh → 58MB walkthrough glb,
renders 939k tris in-browser with floor collision + WASD movement.

- rgb/ input dir: GenRecon Iphone mode reads frames from <job>/rgb/, not images/.
- scale-adaptive point-cloud cleaning (radius_m=auto): plain-video COLMAP is only
  up-to-scale, so a fixed metric radius wipes the cloud; derive it from median NN.
- web-optimize glb (weld → simplify → webp) so the 100s-of-MB raw mesh loads in a
  browser; raw kept as scene_raw.glb; best-effort, never fails a job.
- friendly reconstruct errors (no depth / no overlap / OOM) instead of tracebacks.
- viewer: /?job=<id> deep-link for shareable/reloadable scenes.
Oivo Agent 1 неделя назад
Родитель
Сommit
8584e69c71
4 измененных файлов с 147 добавлено и 7 удалено
  1. 12 1
      backend/config.py
  2. 23 0
      backend/optimize_glb.sh
  3. 102 6
      backend/pipeline.py
  4. 10 0
      frontend/app.js

+ 12 - 1
backend/config.py

@@ -41,10 +41,21 @@ MAX_UPLOAD_MB = int(os.environ.get("GENRECON_MAX_UPLOAD_MB", "2048"))
 # crash the PLY export. These are the README-validated values; radius_m is a
 # metric radius, so on arbitrary-scale COLMAP clouds (plain video, no ARKit)
 # loosen it (raise radius_m / lower radius_nb_points) if cleaning wipes points.
+# Web-optimize the raw GenRecon glb (100s of MB) into a browser-loadable
+# walkthrough asset (simplify + webp). Best-effort; the raw mesh is kept as
+# scene_raw.glb. Needs a local gltf-transform install (see backend/optimize_glb.sh).
+WEB_OPTIMIZE = _flag("GENRECON_WEB_OPTIMIZE", "1")
+OPTIMIZE_GLB_SCRIPT = os.environ.get("GENRECON_OPTIMIZE_SCRIPT", str(BASE / "optimize_glb.sh"))
+
 CHUNK_SIZE_FACTOR = os.environ.get("GENRECON_CHUNK_SIZE_FACTOR", "1.08")
 STAT_STD_RATIO = os.environ.get("GENRECON_STAT_STD_RATIO", "3.0")
 RADIUS_NB_POINTS = os.environ.get("GENRECON_RADIUS_NB_POINTS", "7")
-RADIUS_M = os.environ.get("GENRECON_RADIUS_M", "0.2")
+# radius_m is a METRIC radius in GenRecon (tuned for ARKit-scale iPhone captures).
+# Our app reconstructs from a plain video → COLMAP, which is only up-to-scale, so a
+# fixed 0.2 wipes the cloud. "auto" (default) derives radius_m from the actual
+# point-cloud spacing per job (pipeline._resolve_radius_m); set a number to force it.
+RADIUS_M = os.environ.get("GENRECON_RADIUS_M", "auto")
+RADIUS_K = float(os.environ.get("GENRECON_RADIUS_K", "4.0"))  # radius_m = median_nn_dist × RADIUS_K
 PROJ_BATCH_VOXELS = os.environ.get("GENRECON_PROJ_BATCH_VOXELS", "2048")
 
 JOBS_DIR.mkdir(parents=True, exist_ok=True)

+ 23 - 0
backend/optimize_glb.sh

@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Web-optimize a GenRecon scene.glb into a browser-loadable walkthrough asset:
+# weld → simplify (meshoptimizer) → resize + webp textures. NO draco/meshopt geometry
+# codec, so a plain three.js GLTFLoader loads it (no extra decoder in the viewer).
+# BEST-EFFORT: every step falls back to the previous file, so this NEVER fails a job;
+# worst case it copies the raw glb through. Uses a LOCAL gltf-transform install
+# (a split @gltf-transform/core between separate globals dies with null.getLogger).
+set -uo pipefail
+IN="$1"; OUT="$2"
+RATIO="${GENRECON_SIMPLIFY_RATIO:-0.12}"
+TEXSZ="${GENRECON_TEX_SIZE:-2048}"
+export NODE_OPTIONS="--max-old-space-size=${GENRECON_GLTF_HEAP_MB:-49152}"
+GT="/opt/genrecon/gltftool/node_modules/.bin/gltf-transform"
+[ -x "$GT" ] || GT="$(command -v gltf-transform || true)"
+if [ -z "${GT:-}" ] || [ ! -e "$GT" ]; then echo "web-optimize: no gltf-transform, copying raw"; cp -f "$IN" "$OUT"; echo "OPTIMIZE_SKIPPED"; exit 0; fi
+T=$(mktemp -d); cp -f "$IN" "$T/cur.glb"
+try(){ local label="$1"; shift; if "$GT" "$@" 2>&1 | tail -1; then mv -f "$T/next.glb" "$T/cur.glb" 2>/dev/null && echo "  $label ok"; else echo "  $label skipped"; fi; }
+try weld     weld     "$T/cur.glb" "$T/next.glb"
+try simplify simplify "$T/cur.glb" "$T/next.glb" --ratio "$RATIO" --error 0.002
+try resize   resize   "$T/cur.glb" "$T/next.glb" --width "$TEXSZ" --height "$TEXSZ"
+try webp     webp     "$T/cur.glb" "$T/next.glb" --quality 82
+cp -f "$T/cur.glb" "$OUT"; rm -rf "$T"
+echo "OPTIMIZE_DONE $(du -h "$OUT" | cut -f1)"

+ 102 - 6
backend/pipeline.py

@@ -19,6 +19,7 @@ import os
 import shutil
 import subprocess
 import time
+from collections import deque
 from pathlib import Path
 from typing import Callable
 
@@ -52,7 +53,7 @@ def _conda(args: list[str]) -> list[str]:
 # --- stages -----------------------------------------------------------------
 
 def extract_frames(job: Path, log: Log) -> None:
-    images = job / "images"
+    images = job / "rgb"   # GenRecon Iphone mode reads frames from <job>/rgb/, not images/
     images.mkdir(exist_ok=True)
     src = next(job.glob("input.*"))
     _run([config.FFMPEG_BIN, "-y", "-i", str(src),
@@ -72,7 +73,7 @@ def extract_frames(job: Path, log: Log) -> None:
 
 
 def run_colmap(job: Path, log: Log) -> None:
-    images = job / "images"
+    images = job / "rgb"   # GenRecon Iphone mode reads frames from <job>/rgb/, not images/
     db = job / "colmap.db"
     sparse = job / "sparse"
     out = job / "colmap"
@@ -99,8 +100,45 @@ def run_colmap(job: Path, log: Log) -> None:
         raise RuntimeError("COLMAP did not write colmap/cameras.txt")
 
 
-def _reconstruct_cmd(job: Path) -> list[str]:
+def _resolve_radius_m(job: Path, log: Log) -> str:
+    """radius_m is a metric radius; a plain-video COLMAP is only up-to-scale, so a
+    fixed value either wipes the cloud or keeps outliers. When config.RADIUS_M is
+    'auto', derive it from the actual median nearest-neighbour spacing of the COLMAP
+    points (× config.RADIUS_K) so the outlier-clean is scale-invariant."""
+    rm = str(config.RADIUS_M).strip().lower()
+    if rm != "auto":
+        return str(config.RADIUS_M)
+    pts = job / "colmap" / "points3D.txt"
+    if not pts.exists():
+        log("radius_m=auto: no points3D.txt, falling back to 0.2"); return "0.2"
+    coords = []
+    for line in pts.read_text().splitlines():
+        if not line or line[0] == "#":
+            continue
+        f = line.split()
+        try:
+            coords.append((float(f[1]), float(f[2]), float(f[3])))
+        except (IndexError, ValueError):
+            continue
+    if len(coords) < 50:
+        log(f"radius_m=auto: only {len(coords)} points, falling back to 0.2"); return "0.2"
+    try:
+        import numpy as np
+        from scipy.spatial import cKDTree
+        pc = np.asarray(coords)
+        idx = np.linspace(0, len(pc) - 1, min(3000, len(pc))).astype(int)
+        dist, _ = cKDTree(pc).query(pc[idx], k=2)
+        median_nn = float(np.median(dist[:, 1]))
+        radius = round(median_nn * config.RADIUS_K, 5)
+        log(f"radius_m=auto → {radius} (median_nn={median_nn:.5f} × k={config.RADIUS_K}, n={len(pc)})")
+        return str(radius) if radius > 0 else "0.2"
+    except Exception as e:  # noqa: BLE001 — never let auto-scale break the run
+        log(f"radius_m=auto failed ({e}); falling back to 0.2"); return "0.2"
+
+
+def _reconstruct_cmd(job: Path, log: Log) -> list[str]:
     ck = config.GENRECON_CKPTS
+    radius_m = _resolve_radius_m(job, log)
     return _conda([
         "python", "reconstruct_scene.py",
         "--mode", "Iphone",
@@ -119,15 +157,46 @@ def _reconstruct_cmd(job: Path) -> list[str]:
         "--chunk_size_factor", str(config.CHUNK_SIZE_FACTOR),
         "--stat_std_ratio", str(config.STAT_STD_RATIO),
         "--radius_nb_points", str(config.RADIUS_NB_POINTS),
-        "--radius_m", str(config.RADIUS_M),
+        "--radius_m", radius_m,
         "--proj_batch_voxels", str(config.PROJ_BATCH_VOXELS),
     ])
 
 
+def _friendly_recon_error(recent: "deque[str]") -> str:
+    """Turn a raw reconstruct crash into an actionable message for the uploader.
+    GenRecon's research code dies with cryptic tracebacks when the input is a poor
+    fit (single object, no depth, too little overlap); map the known signatures to
+    plain guidance so a user knows how to re-shoot rather than seeing a stack trace."""
+    blob = "\n".join(recent)
+    if "Number points after cleaning: 0" in blob or "shape (0," in blob:
+        return ("Reconstruction found no stable geometry. Film a well-lit, textured "
+                "space and move the camera slowly with lots of overlap between frames "
+                "— avoid blank walls, reflections and fast motion.")
+    if "chunk_centers" in blob or ("_get_transforms" in blob and "index out of range" in blob):
+        return ("The clip has no scene depth to reconstruct. Walk through a room / space "
+                "so the camera sees both near and far surfaces (not just one object).")
+    if "not enough overlapping views" in blob or "mapper produced no model" in blob:
+        return ("Camera tracking failed — not enough overlapping views. Re-record moving "
+                "slowly and steadily so consecutive frames share plenty of the scene.")
+    if "CUDA out of memory" in blob or "OutOfMemoryError" in blob:
+        return ("Ran out of GPU memory on this scene. Try a shorter clip or lower the "
+                "per-scene image count.")
+    return "Reconstruction failed. Try a slower, more thorough capture of a textured space."
+
+
 def run_reconstruct(job: Path, log: Log) -> None:
     (job / "out").mkdir(exist_ok=True)
     env = dict(os.environ, CUDA_VISIBLE_DEVICES=config.CUDA_DEVICE)
-    _run(_reconstruct_cmd(job), cwd=config.GENRECON_SRC, log=log, env=env)
+    recent: "deque[str]" = deque(maxlen=80)
+
+    def tee(line: str) -> None:
+        recent.append(line)
+        log(line)
+
+    try:
+        _run(_reconstruct_cmd(job, log), cwd=config.GENRECON_SRC, log=tee, env=env)
+    except RuntimeError:
+        raise RuntimeError(_friendly_recon_error(recent)) from None
 
 
 def run_glb(job: Path, log: Log) -> None:
@@ -139,8 +208,35 @@ def run_glb(job: Path, log: Log) -> None:
         "--chunk_inputs", str(out / "chunk_inputs.pt"),
         "--output_dir", str(out),
     ]), cwd=config.GENRECON_SRC, log=log, env=env)
-    if not (out / "scene.glb").exists():
+    glb = out / "scene.glb"
+    if not glb.exists():
         raise RuntimeError("chunked_to_glb.py did not produce scene.glb")
+    _web_optimize_glb(glb, log)
+
+
+def _web_optimize_glb(glb: Path, log: Log) -> None:
+    """Shrink the raw multi-hundred-MB mesh into a browser-loadable walkthrough glb
+    (simplify + webp, no geometry codec). Best-effort: the raw mesh is preserved as
+    scene_raw.glb, and any failure leaves the original scene.glb in place — a heavy
+    but correct asset always beats a broken job."""
+    if not config.WEB_OPTIMIZE:
+        return
+    script = Path(config.OPTIMIZE_GLB_SCRIPT)
+    if not script.exists():
+        log(f"web-optimize skipped (script not found: {script})")
+        return
+    raw = glb.with_name("scene_raw.glb")
+    try:
+        shutil.copy2(glb, raw)
+        # optimize the raw copy back into scene.glb (script never hard-fails)
+        _run(["bash", str(script), str(raw), str(glb)], cwd=glb.parent, log=log)
+        if not glb.exists() or glb.stat().st_size == 0:
+            shutil.copy2(raw, glb)
+            log("web-optimize produced no output; kept full-res glb")
+    except Exception as e:  # noqa: BLE001 — optimization must never fail the job
+        log(f"web-optimize error ({e}); kept full-res glb")
+        if raw.exists() and (not glb.exists() or glb.stat().st_size == 0):
+            shutil.copy2(raw, glb)
 
 
 _REAL = {"frames": extract_frames, "colmap": run_colmap,

+ 10 - 0
frontend/app.js

@@ -111,6 +111,16 @@ function init() {
   $('btn-recenter').addEventListener('click', recenter);
   wireJoystick();
   if ('serviceWorker' in navigator) navigator.serviceWorker.register('./sw.js').catch(() => {});
+
+  // Deep-link: /?job=<id> resumes (or opens) a specific reconstruction, so a
+  // finished scene has a shareable URL and progress survives a reload.
+  const jobId = new URLSearchParams(location.search).get('job');
+  if (jobId) {
+    buildStages();
+    show('view-progress');
+    poll = setInterval(() => refresh(jobId), 1500);
+    refresh(jobId);
+  }
 }
 
 init();