pipeline.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """The reconstruction pipeline: phone video -> walkable scene.glb.
  2. frames ffmpeg extracts frames from the uploaded video
  3. colmap COLMAP SfM -> <job>/colmap/{cameras,images,points3D}.txt
  4. reconstruct GenRecon reconstruct_scene.py --mode Iphone
  5. glb chunked_to_glb.py -> <job>/out/scene.glb
  6. MOCK mode short-circuits every stage and copies the bundled sample scene so
  7. the API + PWA can be exercised end-to-end without a GPU.
  8. NOTE for the executor (issue i-opavuyaq): the exact on-disk layout GenRecon's
  9. `Iphone` mode wants is confirmed during the Step-2 smoke test. This module
  10. produces the layout the README implies (`<job>/images/` + `<job>/colmap/cameras.txt`);
  11. adjust `_reconstruct_cmd` / COLMAP output paths if the smoke test shows otherwise.
  12. """
  13. from __future__ import annotations
  14. import os
  15. import shutil
  16. import subprocess
  17. import time
  18. from collections import deque
  19. from pathlib import Path
  20. from typing import Callable
  21. import config
  22. Log = Callable[[str], None]
  23. def _run(cmd: list[str], cwd: Path | None, log: Log, env: dict | None = None) -> None:
  24. """Run a command, streaming combined output into the job log. Raise on non-zero."""
  25. log(f"$ {' '.join(str(c) for c in cmd)}")
  26. proc = subprocess.Popen(
  27. cmd, cwd=str(cwd) if cwd else None,
  28. stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
  29. text=True, bufsize=1, env=env,
  30. )
  31. assert proc.stdout is not None
  32. for line in proc.stdout:
  33. log(line.rstrip())
  34. code = proc.wait()
  35. if code != 0:
  36. raise RuntimeError(f"command failed (exit {code}): {' '.join(str(c) for c in cmd)}")
  37. def _conda(args: list[str]) -> list[str]:
  38. """Wrap a python invocation so it runs inside the genrecon conda env."""
  39. inner = "conda run -n %s --no-capture-output %s" % (config.CONDA_ENV, " ".join(args))
  40. return ["bash", "-lc", inner]
  41. # --- stages -----------------------------------------------------------------
  42. def extract_frames(job: Path, log: Log) -> None:
  43. images = job / "rgb" # GenRecon Iphone mode reads frames from <job>/rgb/, not images/
  44. images.mkdir(exist_ok=True)
  45. src = next(job.glob("input.*"))
  46. _run([config.FFMPEG_BIN, "-y", "-i", str(src),
  47. "-vf", f"fps={config.FRAMES_FPS}", "-qscale:v", "2",
  48. str(images / "%06d.jpg")], cwd=job, log=log)
  49. frames = sorted(images.glob("*.jpg"))
  50. if not frames:
  51. raise RuntimeError("ffmpeg produced no frames — is the upload a valid video?")
  52. # Evenly subsample if we overshot the cap (keeps COLMAP tractable).
  53. if len(frames) > config.FRAMES_MAX:
  54. step = len(frames) / config.FRAMES_MAX
  55. keep = {frames[int(i * step)] for i in range(config.FRAMES_MAX)}
  56. for f in frames:
  57. if f not in keep:
  58. f.unlink()
  59. log(f"frames: {len(list(images.glob('*.jpg')))}")
  60. def run_colmap(job: Path, log: Log) -> None:
  61. images = job / "rgb" # GenRecon Iphone mode reads frames from <job>/rgb/, not images/
  62. db = job / "colmap.db"
  63. sparse = job / "sparse"
  64. out = job / "colmap"
  65. sparse.mkdir(exist_ok=True)
  66. out.mkdir(exist_ok=True)
  67. C = config.COLMAP_BIN
  68. # COLMAP links Qt and aborts on QApplication init in a headless container
  69. # (no display). QT_QPA_PLATFORM=offscreen makes every CLI subcommand run
  70. # without a display. SIFT extract/match are forced to CPU because the apt
  71. # COLMAP build's GPU SIFT needs an OpenGL/EGL context we don't have headless.
  72. cenv = dict(os.environ, QT_QPA_PLATFORM="offscreen")
  73. _run([C, "feature_extractor", "--database_path", str(db), "--image_path", str(images),
  74. "--ImageReader.single_camera", "1", "--SiftExtraction.use_gpu", "0"], cwd=job, log=log, env=cenv)
  75. _run([C, "exhaustive_matcher", "--database_path", str(db),
  76. "--SiftMatching.use_gpu", "0"], cwd=job, log=log, env=cenv)
  77. _run([C, "mapper", "--database_path", str(db), "--image_path", str(images),
  78. "--output_path", str(sparse)], cwd=job, log=log, env=cenv)
  79. model = sparse / "0"
  80. if not model.exists():
  81. raise RuntimeError("COLMAP mapper produced no model — not enough overlapping views?")
  82. _run([C, "model_converter", "--input_path", str(model),
  83. "--output_path", str(out), "--output_type", "TXT"], cwd=job, log=log, env=cenv)
  84. if not (out / "cameras.txt").exists():
  85. raise RuntimeError("COLMAP did not write colmap/cameras.txt")
  86. def _resolve_radius_m(job: Path, log: Log) -> str:
  87. """radius_m is a metric radius; a plain-video COLMAP is only up-to-scale, so a
  88. fixed value either wipes the cloud or keeps outliers. When config.RADIUS_M is
  89. 'auto', derive it from the actual median nearest-neighbour spacing of the COLMAP
  90. points (× config.RADIUS_K) so the outlier-clean is scale-invariant."""
  91. rm = str(config.RADIUS_M).strip().lower()
  92. if rm != "auto":
  93. return str(config.RADIUS_M)
  94. pts = job / "colmap" / "points3D.txt"
  95. if not pts.exists():
  96. log("radius_m=auto: no points3D.txt, falling back to 0.2"); return "0.2"
  97. coords = []
  98. for line in pts.read_text().splitlines():
  99. if not line or line[0] == "#":
  100. continue
  101. f = line.split()
  102. try:
  103. coords.append((float(f[1]), float(f[2]), float(f[3])))
  104. except (IndexError, ValueError):
  105. continue
  106. if len(coords) < 50:
  107. log(f"radius_m=auto: only {len(coords)} points, falling back to 0.2"); return "0.2"
  108. try:
  109. import numpy as np
  110. from scipy.spatial import cKDTree
  111. pc = np.asarray(coords)
  112. idx = np.linspace(0, len(pc) - 1, min(3000, len(pc))).astype(int)
  113. dist, _ = cKDTree(pc).query(pc[idx], k=2)
  114. median_nn = float(np.median(dist[:, 1]))
  115. radius = round(median_nn * config.RADIUS_K, 5)
  116. log(f"radius_m=auto → {radius} (median_nn={median_nn:.5f} × k={config.RADIUS_K}, n={len(pc)})")
  117. return str(radius) if radius > 0 else "0.2"
  118. except Exception as e: # noqa: BLE001 — never let auto-scale break the run
  119. log(f"radius_m=auto failed ({e}); falling back to 0.2"); return "0.2"
  120. def _reconstruct_cmd(job: Path, log: Log) -> list[str]:
  121. ck = config.GENRECON_CKPTS
  122. radius_m = _resolve_radius_m(job, log)
  123. return _conda([
  124. "python", "reconstruct_scene.py",
  125. "--mode", "Iphone",
  126. "--path", str(job),
  127. "--output_path", str(job / "out"),
  128. # GenRecon derives each stage's training config as <parent-of-ckpt-dir>/config.json
  129. # (setup_utils.load_train_config), so each checkpoint lives in its own
  130. # run dir: <ck>/<stage>/checkpoints/<name>.pt + <ck>/<stage>/config.json
  131. # (the matching configs/gen/*/genrecon*.json). Weights are symlinked so
  132. # the flat 13.7GB download is not duplicated.
  133. "--ss_ckpt", str(ck / "ss" / "checkpoints" / "sparse_structure.pt"),
  134. "--shape_ckpt", str(ck / "shape" / "checkpoints" / "shape_slat.pt"),
  135. "--tex_ckpt", str(ck / "tex" / "checkpoints" / "texture_slat.pt"),
  136. "--pipeline_config", config.PIPELINE_CONFIG,
  137. "--num_imgs_per_scene", str(config.NUM_IMGS_PER_SCENE),
  138. "--chunk_size_factor", str(config.CHUNK_SIZE_FACTOR),
  139. "--stat_std_ratio", str(config.STAT_STD_RATIO),
  140. "--radius_nb_points", str(config.RADIUS_NB_POINTS),
  141. "--radius_m", radius_m,
  142. "--proj_batch_voxels", str(config.PROJ_BATCH_VOXELS),
  143. ])
  144. def _friendly_recon_error(recent: "deque[str]") -> str:
  145. """Turn a raw reconstruct crash into an actionable message for the uploader.
  146. GenRecon's research code dies with cryptic tracebacks when the input is a poor
  147. fit (single object, no depth, too little overlap); map the known signatures to
  148. plain guidance so a user knows how to re-shoot rather than seeing a stack trace."""
  149. blob = "\n".join(recent)
  150. if "Number points after cleaning: 0" in blob or "shape (0," in blob:
  151. return ("Reconstruction found no stable geometry. Film a well-lit, textured "
  152. "space and move the camera slowly with lots of overlap between frames "
  153. "— avoid blank walls, reflections and fast motion.")
  154. if "chunk_centers" in blob or ("_get_transforms" in blob and "index out of range" in blob):
  155. return ("The clip has no scene depth to reconstruct. Walk through a room / space "
  156. "so the camera sees both near and far surfaces (not just one object).")
  157. if "not enough overlapping views" in blob or "mapper produced no model" in blob:
  158. return ("Camera tracking failed — not enough overlapping views. Re-record moving "
  159. "slowly and steadily so consecutive frames share plenty of the scene.")
  160. if "CUDA out of memory" in blob or "OutOfMemoryError" in blob:
  161. return ("Ran out of GPU memory on this scene. Try a shorter clip or lower the "
  162. "per-scene image count.")
  163. return "Reconstruction failed. Try a slower, more thorough capture of a textured space."
  164. def run_reconstruct(job: Path, log: Log) -> None:
  165. (job / "out").mkdir(exist_ok=True)
  166. env = dict(os.environ, CUDA_VISIBLE_DEVICES=config.CUDA_DEVICE)
  167. recent: "deque[str]" = deque(maxlen=80)
  168. def tee(line: str) -> None:
  169. recent.append(line)
  170. log(line)
  171. try:
  172. _run(_reconstruct_cmd(job, log), cwd=config.GENRECON_SRC, log=tee, env=env)
  173. except RuntimeError:
  174. raise RuntimeError(_friendly_recon_error(recent)) from None
  175. def run_glb(job: Path, log: Log) -> None:
  176. out = job / "out"
  177. env = dict(os.environ, CUDA_VISIBLE_DEVICES=config.CUDA_DEVICE)
  178. _run(_conda([
  179. "python", "chunked_to_glb.py",
  180. "--inputs", str(out / "to_glb_inputs.pt"),
  181. "--chunk_inputs", str(out / "chunk_inputs.pt"),
  182. "--output_dir", str(out),
  183. ]), cwd=config.GENRECON_SRC, log=log, env=env)
  184. glb = out / "scene.glb"
  185. if not glb.exists():
  186. raise RuntimeError("chunked_to_glb.py did not produce scene.glb")
  187. _web_optimize_glb(glb, log)
  188. def _web_optimize_glb(glb: Path, log: Log) -> None:
  189. """Shrink the raw multi-hundred-MB mesh into a browser-loadable walkthrough glb
  190. (simplify + webp, no geometry codec). Best-effort: the raw mesh is preserved as
  191. scene_raw.glb, and any failure leaves the original scene.glb in place — a heavy
  192. but correct asset always beats a broken job."""
  193. if not config.WEB_OPTIMIZE:
  194. return
  195. script = Path(config.OPTIMIZE_GLB_SCRIPT)
  196. if not script.exists():
  197. log(f"web-optimize skipped (script not found: {script})")
  198. return
  199. raw = glb.with_name("scene_raw.glb")
  200. try:
  201. shutil.copy2(glb, raw)
  202. # optimize the raw copy back into scene.glb (script never hard-fails)
  203. _run(["bash", str(script), str(raw), str(glb)], cwd=glb.parent, log=log)
  204. if not glb.exists() or glb.stat().st_size == 0:
  205. shutil.copy2(raw, glb)
  206. log("web-optimize produced no output; kept full-res glb")
  207. except Exception as e: # noqa: BLE001 — optimization must never fail the job
  208. log(f"web-optimize error ({e}); kept full-res glb")
  209. if raw.exists() and (not glb.exists() or glb.stat().st_size == 0):
  210. shutil.copy2(raw, glb)
  211. _REAL = {"frames": extract_frames, "colmap": run_colmap,
  212. "reconstruct": run_reconstruct, "glb": run_glb}
  213. def run_pipeline(job: Path, log: Log, set_progress: Callable[[str, int, str], None]) -> None:
  214. """Drive all stages, reporting (stage, percent, message). Raises on failure."""
  215. for stage, lo, hi in config.STAGES:
  216. set_progress(stage, lo, f"{stage}…")
  217. if config.MOCK:
  218. for p in range(lo, hi + 1, max(1, (hi - lo) // 5)):
  219. set_progress(stage, p, f"{stage} (mock)…")
  220. time.sleep(0.4)
  221. else:
  222. _REAL[stage](job, log)
  223. set_progress(stage, hi, f"{stage} done")
  224. if config.MOCK:
  225. (job / "out").mkdir(exist_ok=True)
  226. shutil.copyfile(config.SAMPLE_GLB, job / "out" / "scene.glb")
  227. log("MOCK: copied sample scene.glb")