Skip to content

Commit 77c64b4

Browse files
jjohareruvnet
andcommitted
fix(reconstruct/train): default to direct COLMAP + cap undistort size for GPU-bound training
Two defects that blocked the first LichtFeld-native E2E on real raw capture: 1. reconstruct(): the bundled SplatReady plugin derives its status-file path via str.replace("_run_config.json","_progress.txt"). stages.py named the config "splatready_config.json" (no such substring) so the status path COLLIDED with the config and the runner clobbered its own config mid-run (JSONDecodeError on reuse); the runner also exits 0 on failure, so errors were swallowed. Now defaults to the transparent _run_colmap_direct path; SplatReady stays opt-in behind config.reconstruct.use_splatready with the naming bug fixed and stdout captured in the error. 2. _run_colmap_direct(): image_undistorter omitted --max_image_size, so undistorted training images were full 36MP. LichtFeld CPU-downscales anything wider than its --max-width (3840px) on EVERY load (that path is not disk-cached), ~5s/image, starving the GPU — 30k iters projected to hours at 0% GPU util. Now undistorts at config.ingest.max_image_size (2000px), correct intrinsics baked in; training runs GPU-bound at 99% util (30k igs+ in minutes). Validated on rawsForDev (55 DNG): decode 55/55 -> select 50 -> COLMAP 50/50 (100%), 10.4k pts -> LichtFeld igs+ training GPU-bound. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 309e896 commit 77c64b4

2 files changed

Lines changed: 47 additions & 21 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ unreal/runtime/Plugins/*/_ref/
134134
tasks/
135135
research/gap-analysis-local/
136136
research/come-paper/
137+
# Raw capture staged locally for dev/testing the E2E (large; not source).
138+
rawsForDev/
137139

138140
# ---------------------------------------------------------------------------
139141
# ruflo / ruvector local database (not source)

src/pipeline/stages.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -581,27 +581,40 @@ def reconstruct(self, frames_dir: str) -> StageResult:
581581
colmap_dir = self.job_dir / "colmap"
582582
colmap_dir.mkdir(parents=True, exist_ok=True)
583583

584-
# Try SplatReady first
585-
splatready_config = {
586-
"video_path": "",
587-
"base_output_folder": str(self.job_dir),
588-
"frame_rate": self.config.ingest.fps,
589-
"skip_extraction": True,
590-
"reconstruction_method": self.config.reconstruct.method,
591-
"colmap_exe_path": self.config.reconstruct.colmap_exe,
592-
"use_fisheye": self.config.reconstruct.use_fisheye,
593-
"max_image_size": self.config.ingest.max_image_size,
594-
"min_scale": self.config.reconstruct.min_scale,
595-
"skip_reconstruction": False,
596-
}
597-
598-
config_path = self.job_dir / "splatready_config.json"
599-
config_path.write_text(json.dumps(splatready_config, indent=2), encoding="utf-8")
600-
584+
# COLMAP SfM. Default to running COLMAP directly (feature_extractor → matcher →
585+
# mapper → image_undistorter, see _run_colmap_direct) rather than via the bundled
586+
# SplatReady plugin. SplatReady is fragile for our still-image path in two ways:
587+
# 1. Its runner derives its status file by string-replacing "_run_config.json"
588+
# in the config path with "_progress.txt". A config NOT ending in
589+
# "_run_config.json" makes that a no-op, so the status file path COLLIDES with
590+
# the config path and the runner clobbers its own config mid-run (the reused
591+
# config then fails json.load with "Extra data").
592+
# 2. The runner exits 0 even on failure, so returncode is not a reliable signal.
593+
# The direct path is transparent and its output is validated below (the
594+
# registration-rate gate). SplatReady stays available behind
595+
# config.reconstruct.use_splatready (video fast path); the naming bug is worked
596+
# around by giving the config the "_run_config.json" suffix it expects.
597+
use_splatready = getattr(self.config.reconstruct, "use_splatready", False)
601598
plugin_dir = Path.home() / ".lichtfeld" / "plugins" / "splat_ready"
602599
runner = plugin_dir / "core" / "runner.py"
603600

604-
if runner.exists():
601+
if use_splatready and runner.exists():
602+
splatready_config = {
603+
"video_path": "",
604+
"base_output_folder": str(self.job_dir),
605+
"frame_rate": self.config.ingest.fps,
606+
"skip_extraction": True,
607+
"reconstruction_method": self.config.reconstruct.method,
608+
"colmap_exe_path": self.config.reconstruct.colmap_exe,
609+
"use_fisheye": self.config.reconstruct.use_fisheye,
610+
"max_image_size": self.config.ingest.max_image_size,
611+
"min_scale": self.config.reconstruct.min_scale,
612+
"skip_reconstruction": False,
613+
}
614+
# MUST end in "_run_config.json" so the runner's status file becomes a distinct
615+
# "_progress.txt" instead of overwriting this config (see note above).
616+
config_path = self.job_dir / "splatready_run_config.json"
617+
config_path.write_text(json.dumps(splatready_config, indent=2), encoding="utf-8")
605618
try:
606619
proc = subprocess.run(
607620
["python3", str(runner), str(config_path)],
@@ -610,21 +623,24 @@ def reconstruct(self, frames_dir: str) -> StageResult:
610623
if proc.returncode != 0:
611624
return StageResult(
612625
success=False, stage="reconstruct",
613-
error=f"SplatReady failed: {proc.stderr[:500]}",
626+
error=f"SplatReady failed: {(proc.stderr or proc.stdout)[:500]}",
614627
)
615628
except subprocess.TimeoutExpired:
616629
return StageResult(
617630
success=False, stage="reconstruct",
618631
error="COLMAP reconstruction timed out",
619632
)
620633
else:
621-
# Fallback: run COLMAP directly
634+
# Transparent, reliable path (default).
622635
try:
623636
self._run_colmap_direct(colmap_dir, frames_path)
624637
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc:
638+
stderr = getattr(exc, "stderr", "") or ""
639+
if isinstance(stderr, bytes):
640+
stderr = stderr.decode("utf-8", "replace")
625641
return StageResult(
626642
success=False, stage="reconstruct",
627-
error=f"COLMAP failed: {exc}",
643+
error=f"COLMAP failed: {exc} :: {stderr[:500]}",
628644
)
629645

630646
dataset_dir = colmap_dir / "undistorted"
@@ -714,12 +730,20 @@ def _run_colmap_direct(self, output_dir: Path, frame_dir: Path) -> None:
714730
], check=True, capture_output=True, timeout=1800)
715731

716732
undist_dir = output_dir / "undistorted"
733+
# Cap undistorted image size. LichtFeld downscales any image wider than its
734+
# --max-width (default 3840px) on the CPU on *every* load — the downscale path is
735+
# NOT disk-cached, so a 36MP source costs ~5s/image every iteration, starving the
736+
# GPU and turning a 30k-iter run into hours. Undistorting at a bounded size (COLMAP
737+
# bakes the correctly-rescaled intrinsics into cameras.bin) keeps images under that
738+
# threshold so LichtFeld takes its fast, no-resample path and stays GPU-bound.
739+
undistort_max = getattr(self.config.ingest, "max_image_size", 2000) or 2000
717740
subprocess.run([
718741
colmap, "image_undistorter",
719742
"--image_path", str(frame_dir),
720743
"--input_path", str(sparse_dir / "0"),
721744
"--output_path", str(undist_dir),
722745
"--output_type", "COLMAP",
746+
"--max_image_size", str(undistort_max),
723747
], check=True, capture_output=True, timeout=300)
724748

725749
undist_sparse = undist_dir / "sparse"

0 commit comments

Comments
 (0)