Files
Drone-3DGS/main.py
Jon 7f4cdd9459 Add two-video drone 3DGS pipeline with Apple Silicon fixes
- main.py: extract frames from two videos, run COLMAP feature extraction
- match_features.py: Python-based within-video SIFT matching via OpenCV
  (replaces colmap exhaustive_matcher which segfaults on ARM64 in COLMAP 4.x)
- match_crossvideo.py: exhaustive cross-video matching (v1×v2) to stitch
  two flights into a single COLMAP model
- run.sh: entry point for frame extraction + feature extraction
- train_splat.sh: ns-process-data → splatfacto → .ply export, with
  correct PATH for Homebrew ffmpeg and MPS device flags for Apple Silicon
- .gitignore: exclude videos, generated scene data, venv, logs
- README.md: full pipeline walkthrough, all known issues and fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:09:30 +01:00

156 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""
Two-video drone footage -> 3DGS pipeline for Apple Silicon Macs.
Usage:
python drone_3dgs_pipeline.py \
--video1 path/to/flight1.mp4 \
--video2 path/to/flight2.mp4 \
--output_dir ./my_scene \
--fps 2
What it does:
1. Extracts frames from both videos at the given fps (default 2 fps).
2. Pools them into one folder with non-colliding names.
3. Runs COLMAP feature extraction, matching, and sparse reconstruction.
4. Hands you a Nerfstudio-ready folder structure to train splatfacto.
Then run:
ns-train splatfacto --data ./my_scene
"""
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
def run(cmd, check=True):
"""Run a shell command, streaming output."""
print(f"\n>>> {' '.join(str(c) for c in cmd)}\n")
result = subprocess.run(cmd, check=check)
return result
def check_dependencies():
"""Verify ffmpeg and colmap are installed."""
for tool in ("ffmpeg", "colmap"):
if shutil.which(tool) is None:
sys.exit(f"ERROR: {tool} not found. Install with: brew install {tool}")
def extract_frames(video_path: Path, out_dir: Path, fps: float, prefix: str):
"""Extract frames from a video using ffmpeg at the given fps."""
out_dir.mkdir(parents=True, exist_ok=True)
pattern = str(out_dir / f"{prefix}_%05d.jpg")
run([
"ffmpeg", "-i", str(video_path),
"-r", str(fps),
"-q:v", "2", # JPEG quality (2 = high)
"-y", # overwrite
pattern,
])
count = len(list(out_dir.glob(f"{prefix}_*.jpg")))
print(f"Extracted {count} frames from {video_path.name} -> {out_dir}")
return count
def run_colmap(workspace: Path, images_dir: Path, use_gpu: bool = False):
"""Run the COLMAP SfM pipeline: feature extraction, matching, mapping."""
sparse_dir = workspace / "sparse"
sparse_dir.mkdir(parents=True, exist_ok=True)
db_path = workspace / "database.db"
# Step 1: feature extraction (COLMAP 4.x renamed SiftExtraction → FeatureExtraction)
run([
"colmap", "feature_extractor",
"--database_path", str(db_path),
"--image_path", str(images_dir),
"--ImageReader.single_camera", "1",
"--ImageReader.camera_model", "OPENCV",
"--FeatureExtraction.use_gpu", "1" if use_gpu else "0",
])
# Step 2: Python matcher (COLMAP 4.x exhaustive_matcher segfaults on Apple Silicon ARM64;
# match_features.py reads SIFT descriptors from the DB and matches via OpenCV BFMatcher)
script = Path(__file__).parent / "match_features.py"
run([sys.executable, str(script), "--db", str(db_path)])
# Step 3: sparse reconstruction (this is the slow one)
run([
"colmap", "mapper",
"--database_path", str(db_path),
"--image_path", str(images_dir),
"--output_path", str(sparse_dir),
])
# COLMAP writes to sparse/0/ by default
print(f"\nCOLMAP done. Reconstruction in {sparse_dir}/0/")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video1", type=Path, required=True)
parser.add_argument("--video2", type=Path, required=True)
parser.add_argument("--output_dir", type=Path, required=True)
parser.add_argument("--fps", type=float, default=2.0,
help="Frames per second to extract (default: 2). "
"Higher = more frames, slower training, better quality.")
parser.add_argument("--use_gpu", action="store_true",
help="Try GPU SIFT in COLMAP (often unreliable on M1; default off).")
args = parser.parse_args()
check_dependencies()
if not args.video1.exists() or not args.video2.exists():
sys.exit("ERROR: one or both video files not found.")
workspace = args.output_dir
images_dir = workspace / "images"
workspace.mkdir(parents=True, exist_ok=True)
# Extract frames from both videos into the SAME images folder, with prefixes
# so filenames don't collide. COLMAP treats them as one set automatically.
print(f"\n=== Extracting frames from {args.video1.name} ===")
n1 = extract_frames(args.video1, images_dir, args.fps, prefix="v1")
print(f"\n=== Extracting frames from {args.video2.name} ===")
n2 = extract_frames(args.video2, images_dir, args.fps, prefix="v2")
total = n1 + n2
print(f"\n=== Total frames: {total} ===")
if total > 800:
print("WARNING: lots of frames. Consider lowering --fps. "
"Exhaustive matching will be slow; switch to sequential_matcher if needed.")
# Run COLMAP
print("\n=== Running COLMAP (this is the slow part, get a coffee) ===")
run_colmap(workspace, images_dir, use_gpu=args.use_gpu)
# Print next steps
print(f"""
========================================================================
DONE with SfM. Your scene is at: {workspace}
Next, train the splat. Two options on Mac:
OPTION 1: Nerfstudio (Python, scriptable)
pip install nerfstudio
ns-process-data images --data {images_dir} --output-dir {workspace}/ns_data \\
--skip-colmap --colmap-model-path {workspace}/sparse/0
ns-train splatfacto --data {workspace}/ns_data
OPTION 2: Brush (Rust binary, faster on Mac)
Download from https://github.com/ArthurBrussee/brush
brush --source {workspace}
View result:
- Online: https://playcanvas.com/supersplat/editor (drag .ply in)
- Local: install SuperSplat or use Nerfstudio's viewer
========================================================================
""")
if __name__ == "__main__":
main()