pipeline.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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 pathlib import Path
  19. from typing import Callable
  20. import config
  21. Log = Callable[[str], None]
  22. def _run(cmd: list[str], cwd: Path | None, log: Log, env: dict | None = None) -> None:
  23. """Run a command, streaming combined output into the job log. Raise on non-zero."""
  24. log(f"$ {' '.join(str(c) for c in cmd)}")
  25. proc = subprocess.Popen(
  26. cmd, cwd=str(cwd) if cwd else None,
  27. stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
  28. text=True, bufsize=1, env=env,
  29. )
  30. assert proc.stdout is not None
  31. for line in proc.stdout:
  32. log(line.rstrip())
  33. code = proc.wait()
  34. if code != 0:
  35. raise RuntimeError(f"command failed (exit {code}): {' '.join(str(c) for c in cmd)}")
  36. def _conda(args: list[str]) -> list[str]:
  37. """Wrap a python invocation so it runs inside the genrecon conda env."""
  38. inner = "conda run -n %s --no-capture-output %s" % (config.CONDA_ENV, " ".join(args))
  39. return ["bash", "-lc", inner]
  40. # --- stages -----------------------------------------------------------------
  41. def extract_frames(job: Path, log: Log) -> None:
  42. images = job / "images"
  43. images.mkdir(exist_ok=True)
  44. src = next(job.glob("input.*"))
  45. _run([config.FFMPEG_BIN, "-y", "-i", str(src),
  46. "-vf", f"fps={config.FRAMES_FPS}", "-qscale:v", "2",
  47. str(images / "%06d.jpg")], cwd=job, log=log)
  48. frames = sorted(images.glob("*.jpg"))
  49. if not frames:
  50. raise RuntimeError("ffmpeg produced no frames — is the upload a valid video?")
  51. # Evenly subsample if we overshot the cap (keeps COLMAP tractable).
  52. if len(frames) > config.FRAMES_MAX:
  53. step = len(frames) / config.FRAMES_MAX
  54. keep = {frames[int(i * step)] for i in range(config.FRAMES_MAX)}
  55. for f in frames:
  56. if f not in keep:
  57. f.unlink()
  58. log(f"frames: {len(list(images.glob('*.jpg')))}")
  59. def run_colmap(job: Path, log: Log) -> None:
  60. images = job / "images"
  61. db = job / "colmap.db"
  62. sparse = job / "sparse"
  63. out = job / "colmap"
  64. sparse.mkdir(exist_ok=True)
  65. out.mkdir(exist_ok=True)
  66. C = config.COLMAP_BIN
  67. # COLMAP links Qt and aborts on QApplication init in a headless container
  68. # (no display). QT_QPA_PLATFORM=offscreen makes every CLI subcommand run
  69. # without a display. SIFT extract/match are forced to CPU because the apt
  70. # COLMAP build's GPU SIFT needs an OpenGL/EGL context we don't have headless.
  71. cenv = dict(os.environ, QT_QPA_PLATFORM="offscreen")
  72. _run([C, "feature_extractor", "--database_path", str(db), "--image_path", str(images),
  73. "--ImageReader.single_camera", "1", "--SiftExtraction.use_gpu", "0"], cwd=job, log=log, env=cenv)
  74. _run([C, "exhaustive_matcher", "--database_path", str(db),
  75. "--SiftMatching.use_gpu", "0"], cwd=job, log=log, env=cenv)
  76. _run([C, "mapper", "--database_path", str(db), "--image_path", str(images),
  77. "--output_path", str(sparse)], cwd=job, log=log, env=cenv)
  78. model = sparse / "0"
  79. if not model.exists():
  80. raise RuntimeError("COLMAP mapper produced no model — not enough overlapping views?")
  81. _run([C, "model_converter", "--input_path", str(model),
  82. "--output_path", str(out), "--output_type", "TXT"], cwd=job, log=log, env=cenv)
  83. if not (out / "cameras.txt").exists():
  84. raise RuntimeError("COLMAP did not write colmap/cameras.txt")
  85. def _reconstruct_cmd(job: Path) -> list[str]:
  86. ck = config.GENRECON_CKPTS
  87. return _conda([
  88. "python", "reconstruct_scene.py",
  89. "--mode", "Iphone",
  90. "--path", str(job),
  91. "--output_path", str(job / "out"),
  92. # GenRecon derives each stage's training config as <parent-of-ckpt-dir>/config.json
  93. # (setup_utils.load_train_config), so each checkpoint lives in its own
  94. # run dir: <ck>/<stage>/checkpoints/<name>.pt + <ck>/<stage>/config.json
  95. # (the matching configs/gen/*/genrecon*.json). Weights are symlinked so
  96. # the flat 13.7GB download is not duplicated.
  97. "--ss_ckpt", str(ck / "ss" / "checkpoints" / "sparse_structure.pt"),
  98. "--shape_ckpt", str(ck / "shape" / "checkpoints" / "shape_slat.pt"),
  99. "--tex_ckpt", str(ck / "tex" / "checkpoints" / "texture_slat.pt"),
  100. "--pipeline_config", config.PIPELINE_CONFIG,
  101. "--num_imgs_per_scene", str(config.NUM_IMGS_PER_SCENE),
  102. "--chunk_size_factor", str(config.CHUNK_SIZE_FACTOR),
  103. "--stat_std_ratio", str(config.STAT_STD_RATIO),
  104. "--radius_nb_points", str(config.RADIUS_NB_POINTS),
  105. "--radius_m", str(config.RADIUS_M),
  106. "--proj_batch_voxels", str(config.PROJ_BATCH_VOXELS),
  107. ])
  108. def run_reconstruct(job: Path, log: Log) -> None:
  109. (job / "out").mkdir(exist_ok=True)
  110. env = dict(os.environ, CUDA_VISIBLE_DEVICES=config.CUDA_DEVICE)
  111. _run(_reconstruct_cmd(job), cwd=config.GENRECON_SRC, log=log, env=env)
  112. def run_glb(job: Path, log: Log) -> None:
  113. out = job / "out"
  114. env = dict(os.environ, CUDA_VISIBLE_DEVICES=config.CUDA_DEVICE)
  115. _run(_conda([
  116. "python", "chunked_to_glb.py",
  117. "--inputs", str(out / "to_glb_inputs.pt"),
  118. "--chunk_inputs", str(out / "chunk_inputs.pt"),
  119. "--output_dir", str(out),
  120. ]), cwd=config.GENRECON_SRC, log=log, env=env)
  121. if not (out / "scene.glb").exists():
  122. raise RuntimeError("chunked_to_glb.py did not produce scene.glb")
  123. _REAL = {"frames": extract_frames, "colmap": run_colmap,
  124. "reconstruct": run_reconstruct, "glb": run_glb}
  125. def run_pipeline(job: Path, log: Log, set_progress: Callable[[str, int, str], None]) -> None:
  126. """Drive all stages, reporting (stage, percent, message). Raises on failure."""
  127. for stage, lo, hi in config.STAGES:
  128. set_progress(stage, lo, f"{stage}…")
  129. if config.MOCK:
  130. for p in range(lo, hi + 1, max(1, (hi - lo) // 5)):
  131. set_progress(stage, p, f"{stage} (mock)…")
  132. time.sleep(0.4)
  133. else:
  134. _REAL[stage](job, log)
  135. set_progress(stage, hi, f"{stage} done")
  136. if config.MOCK:
  137. (job / "out").mkdir(exist_ok=True)
  138. shutil.copyfile(config.SAMPLE_GLB, job / "out" / "scene.glb")
  139. log("MOCK: copied sample scene.glb")