"""DA3 (Depth Anything 3) reconstruction entrypoint. Replaces COLMAP/MASt3R for the SfM stage. Runs inside the vw-studio-worker image with VW_IN / VW_OUT set by vw_stage.py. Modes: --sfm-only Run DA3 inference, produce processed_min.zip (transforms.json + sparse_pc.ply) for splatfacto --train-only. [split Job A] --gs-only Run DA3-GIANT with infer_gs=True, produce da3_gaussians.ply directly. No splatfacto training. [da3-draft preset] --train-only Skip DA3, consume processed_min.zip from VW_IN and run splatfacto + ns-export. [split Job B, same as recon_entrypoint] (no flag) Full pipeline: DA3 SfM + splatfacto training + export. Inputs (VW_IN): frames.zip Input images (jpg/png) Outputs (VW_OUT): splat.ply Full-attribute 3DGS PLY (from ns-export or DA3 direct) summary.json Job metadata model.zip Training checkpoint (when --keep-checkpoint) processed_min.zip transforms.json + sparse_pc.ply (SfM output for split jobs) da3_gaussians.ply Direct 3DGS output (when --gs-only) error.json Structured failure info """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import time import zipfile from pathlib import Path import numpy as np def log(msg: str) -> None: print(f"[da3-recon] {msg}", flush=True) def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: log(f"$ {' '.join(cmd)}") kwargs.setdefault("capture_output", True) kwargs.setdefault("text", True) result = subprocess.run(cmd, check=False, **kwargs) if result.returncode != 0 and result.stderr: log(f"stderr: {result.stderr[-2000:]}") return result def run_streaming(cmd: list[str]) -> subprocess.CompletedProcess: """Run a command with live stdout/stderr streaming to log.""" log(f"$ {' '.join(cmd)}") result = subprocess.run(cmd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) for line in result.stdout.splitlines(): log(f" {line}") if result.returncode != 0: log(f" exit code: {result.returncode}") return result def fail(out_dir: Path, code: str, detail: str) -> int: log(f"FAILED: {code} — {detail}") (out_dir / "error.json").write_text( json.dumps({"code": code, "detail": detail}, indent=2), encoding="utf-8" ) return 1 def load_images(images_dir: Path) -> list[Path]: exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} return sorted(p for p in images_dir.rglob("*") if p.suffix.lower() in exts) def da3_inference( images: list[Path], model_id: str, infer_gs: bool, export_dir: Path | None = None, export_format: str = "mini_npz", ) -> dict: """Run DA3 inference and return the prediction outputs. Returns a dict with keys: depth, extrinsics, intrinsics, conf, processed_images, and optionally gaussians. """ import torch from depth_anything_3.api import DepthAnything3 # Reduce fragmentation on L4 (22GB) — DA3's multi-view attention is memory-hungry. torch.cuda.empty_cache() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") log(f"Loading DA3 model: {model_id} on {device}") model = DepthAnything3.from_pretrained(model_id).to(device) image_paths = [str(p) for p in images] log(f"Running DA3 inference on {len(image_paths)} images") inference_kwargs: dict = {} if infer_gs: inference_kwargs["infer_gs"] = True if export_dir is not None: inference_kwargs["export_dir"] = str(export_dir) inference_kwargs["export_format"] = export_format prediction = model.inference(image=image_paths, **inference_kwargs) result = { "depth": prediction.depth, "extrinsics": prediction.extrinsics, "intrinsics": prediction.intrinsics, "conf": getattr(prediction, "conf", None), "processed_images": prediction.processed_images, } if infer_gs and hasattr(prediction, "aux") and "gaussians" in prediction.aux: result["gaussians"] = prediction.aux["gaussians"] log("DA3 Gaussian prediction available") return result def da3_to_transforms( prediction: dict, images: list[Path], images_dir: Path, output_dir: Path, ) -> Path: """Convert DA3 output to nerfstudio transforms.json format. DA3 gives us: - extrinsics: (N, 3, 4) world-to-camera in OpenCV convention - intrinsics: (N, 3, 3) camera intrinsics Nerfstudio transforms.json expects: - per-frame transform_matrix: 4x4 camera-to-world in OpenGL/Blender convention - fl_x, fl_y, cx, cy, w, h per frame - file_path relative to data root """ exts = prediction["extrinsics"] # (N, 3, 4) w2c ixts = prediction["intrinsics"] # (N, 3, 3) depths = prediction["depth"] # (N, H, W) processed_imgs = prediction["processed_images"] # (N, H, W, 3) n_frames = len(images) frames = [] # DA3 resizes images internally; we need to scale intrinsics back to # the original image resolution so nerfstudio can load the full-res files. from PIL import Image as PILImage for i in range(n_frames): # Convert w2c (3x4) to c2w (4x4) w2c = np.eye(4) w2c[:3, :] = exts[i] c2w = np.linalg.inv(w2c) # Convert OpenCV (x-right, y-down, z-forward) to OpenGL (x-right, y-up, z-backward) # by flipping Y and Z axes of the camera coordinate system opengl_to_cv = np.diag([1, -1, -1, 1]).astype(np.float64) c2w_opengl = c2w @ opengl_to_cv # DA3's intrinsics are for the resized image — scale to original resolution da_h, da_w = depths[i].shape[:2] with PILImage.open(images[i]) as img: orig_w, orig_h = img.size scale_x = orig_w / da_w scale_y = orig_h / da_h ixt = ixts[i] # (3, 3) fl_x = float(ixt[0, 0]) * scale_x fl_y = float(ixt[1, 1]) * scale_y cx = float(ixt[0, 2]) * scale_x cy = float(ixt[1, 2]) * scale_y h, w = orig_h, orig_w frame = { "file_path": f"images/{images[i].name}", "transform_matrix": c2w_opengl.tolist(), "fl_x": fl_x, "fl_y": fl_y, "cx": cx, "cy": cy, "w": int(w), "h": int(h), } frames.append(frame) # Use first frame's intrinsics as the applied scale reference first_ixt = ixts[0] camera_angle_x = float(2 * np.arctan2(first_ixt[0, 2], first_ixt[0, 0])) transforms = { "camera_angle_x": camera_angle_x, "frames": frames, "ply_file_path": "sparse_pc.ply", } transforms_path = output_dir / "transforms.json" transforms_path.write_text(json.dumps(transforms, indent=2), encoding="utf-8") log(f"Wrote transforms.json with {n_frames} frames to {transforms_path}") return transforms_path def da3_to_sparse_pc( prediction: dict, output_dir: Path, max_points: int = 500_000, ) -> Path: """Fuse DA3 depth maps into a sparse point cloud (PLY). Back-projects each frame's depth map using the predicted intrinsics and extrinsics, then concatenates and downsamples to max_points. """ depths = prediction["depth"] # (N, H, W) exts = prediction["extrinsics"] # (N, 3, 4) w2c ixts = prediction["intrinsics"] # (N, 3, 3) confs = prediction.get("conf") # (N, H, W) or None processed = prediction["processed_images"] # (N, H, W, 3) all_points = [] all_colors = [] for i in range(len(depths)): depth = depths[i] # (H, W) h, w = depth.shape ixt = ixts[i] # Create pixel coordinate grid ys, xs = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") # Normalize to camera coordinates z = depth x = (xs - ixt[0, 2]) * z / ixt[0, 0] y = (ys - ixt[1, 2]) * z / ixt[1, 1] # Stack into (H*W, 3) camera coordinates pts_cam = np.stack([x.flatten(), y.flatten(), z.flatten()], axis=-1) # Filter by confidence if available if confs is not None: conf = confs[i].flatten() # Keep top 60% confidence points thresh = np.percentile(conf, 40) mask = conf > thresh pts_cam = pts_cam[mask] colors = processed[i].reshape(-1, 3)[mask] else: colors = processed[i].reshape(-1, 3) # Filter invalid depth (zero, negative, or extreme) valid = pts_cam[:, 2] > 0.01 pts_cam = pts_cam[valid] colors = colors[valid] # Transform to world coordinates: inv(w2c) * [x, y, z, 1] w2c = np.eye(4) w2c[:3, :] = exts[i] c2w = np.linalg.inv(w2c) pts_homog = np.hstack([pts_cam, np.ones((len(pts_cam), 1))]) pts_world = (c2w @ pts_homog.T).T[:, :3] all_points.append(pts_world) all_colors.append(colors) all_points = np.concatenate(all_points, axis=0) all_colors = np.concatenate(all_colors, axis=0) # Downsample if needed if len(all_points) > max_points: idx = np.random.choice(len(all_points), max_points, replace=False) all_points = all_points[idx] all_colors = all_colors[idx] log(f"Sparse point cloud: {len(all_points)} points") # Write PLY ply_path = output_dir / "sparse_pc.ply" with open(ply_path, "w") as f: f.write("ply\n") f.write("format ascii 1.0\n") f.write(f"element vertex {len(all_points)}\n") f.write("property float x\n") f.write("property float y\n") f.write("property float z\n") f.write("property uchar red\n") f.write("property uchar green\n") f.write("property uchar blue\n") f.write("end_header\n") for pt, col in zip(all_points, all_colors): f.write(f"{pt[0]:.6f} {pt[1]:.6f} {pt[2]:.6f} ") r = int(np.clip(col[0], 0, 1) * 255) if col[0] <= 1.0 else int(col[0]) g = int(np.clip(col[1], 0, 1) * 255) if col[1] <= 1.0 else int(col[1]) b = int(np.clip(col[2], 0, 1) * 255) if col[2] <= 1.0 else int(col[2]) f.write(f"{r} {g} {b}\n") log(f"Wrote sparse_pc.ply to {ply_path}") return ply_path def export_da3_gs_ply(prediction: dict, output_path: Path) -> Path: """Export DA3's direct 3DGS prediction to a standard 3DGS PLY file. The DA3 Gaussian output is in the prediction.aux['gaussians'] dict, containing positions, rotations, scales, colors, and opacities. This function converts them to the standard 3DGS PLY format that our splat_io.py can read. """ gaussians = prediction.get("gaussians") if gaussians is None: raise RuntimeError("No Gaussian data in DA3 prediction (infer_gs=True required)") # DA3's Gaussian output format may vary; we try common keys positions = gaussians.get("positions") or gaussians.get("means") if positions is None: raise RuntimeError(f"DA3 gaussians dict keys: {list(gaussians.keys())}") # Convert to numpy if needed positions = np.asarray(positions) n_gaussians = len(positions) log(f"DA3 produced {n_gaussians} gaussians") # Try to get other attributes scales = gaussians.get("scales") or gaussians.get("scale") rotations = gaussians.get("rotations") or gaussians.get("quats") colors = gaussians.get("colors") or gaussians.get("sh_dc") or gaussians.get("rgb") opacities = gaussians.get("opacities") or gaussians.get("opacity") # Defaults if missing if scales is None: scales = np.ones((n_gaussians, 3)) * 0.01 else: scales = np.asarray(scales) if rotations is None: rotations = np.tile([1.0, 0.0, 0.0, 0.0], (n_gaussians, 1)) else: rotations = np.asarray(rotations) if colors is None: colors = np.ones((n_gaussians, 3)) * 128 else: colors = np.asarray(colors) if opacities is None: opacities = np.ones(n_gaussians) * 0.9 else: opacities = np.asarray(opacities) # Write 3DGS PLY with open(output_path, "w") as f: f.write("ply\n") f.write("format binary_little_endian 1.0\n") f.write(f"element vertex {n_gaussians}\n") f.write("property float x\n") f.write("property float y\n") f.write("property float z\n") f.write("property float nx\n") f.write("property float ny\n") f.write("property float nz\n") for i in range(3): f.write(f"property float f_dc_{i}\n") for i in range(3): f.write(f"property float f_scale_{i}\n") for i in range(4): f.write(f"property float f_rot_{i}\n") f.write("property float opacity\n") f.write("end_header\n") # SH DC coefficient: color * 0.28209479 (SH C0) SH_C0 = 0.28209479177387814 import struct for i in range(n_gaussians): pos = positions[i] sc = np.exp(scales[i]) if np.any(scales[i] < 0) else scales[i] # log-space → linear rot = rotations[i] col = colors[i] opa = opacities[i] # Sigmoid for opacity if raw logits if opa < 0 or opa > 1: opa = 1.0 / (1.0 + np.exp(-opa)) f_dc = [float(col[0] / 255.0 - 0.5) / SH_C0, float(col[1] / 255.0 - 0.5) / SH_C0, float(col[2] / 255.0 - 0.5) / SH_C0] data = struct.pack( "3f3f3f3f4ff", float(pos[0]), float(pos[1]), float(pos[2]), 0.0, 0.0, 0.0, # normals (unused) f_dc[0], f_dc[1], f_dc[2], float(sc[0]), float(sc[1]), float(sc[2]), float(rot[0]), float(rot[1]), float(rot[2]), float(rot[3]), float(opa), ) f.write(data) log(f"Wrote DA3 3DGS PLY ({n_gaussians} gaussians) to {output_path}") return output_path def write_depth_maps(prediction: dict, output_dir: Path) -> None: """Write per-frame depth maps as .npy files for nerfstudio.""" depths_dir = output_dir / "depths" depths_dir.mkdir(parents=True, exist_ok=True) for i, depth in enumerate(prediction["depth"]): np.save(depths_dir / f"frame_{i:05d}.npy", depth) log(f"Wrote {len(prediction['depth'])} depth maps to {depths_dir}") def make_processed_min(processed: Path, output_path: Path) -> None: """Bundle transforms.json + sparse_pc.ply + colmap_database.db into processed_min.zip.""" with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive: for name in ("transforms.json", "sparse_pc.ply"): candidate = processed / name if candidate.exists(): archive.write(candidate, name) # No colmap_database.db for DA3 — we don't use COLMAP at all. # nerfstudio's dataparser only needs transforms.json. log(f"Wrote {output_path}") def filter_supported_flags(train_args: list[str]) -> list[str]: """Drop --flag value pairs that the installed ns-train doesn't know.""" help_text = "" probe = run(["ns-train", "splatfacto", "--help"], capture_output=True, text=True) if probe.returncode == 0: help_text = probe.stdout + probe.stderr if not help_text: return train_args if "--vis" in train_args: import re index = train_args.index("--vis") + 1 window = re.search(r"--vis\b(.{0,300})", help_text, re.DOTALL) choices = window.group(1) if window else "" if index < len(train_args) and train_args[index] == "none" and "none" not in choices: log("--vis none unsupported in this nerfstudio; using tensorboard") train_args[index] = "tensorboard" kept: list[str] = [] index = 0 while index < len(train_args): arg = train_args[index] if arg.startswith("--") and arg not in help_text: log(f"dropping unsupported flag: {arg}") index += 2 if index + 1 < len(train_args) and not train_args[index + 1].startswith("--") else 1 continue kept.append(arg) index += 1 return kept def finish_export( args, out_dir: Path, processed: Path, train_out: Path, export: Path, timings: dict, frame_count: int, matching_used: str, ) -> int: """Shared post-train flow: ns-export, copy outputs, archive bundles, summary.""" configs = sorted(train_out.rglob("config.yml"), key=lambda p: p.stat().st_mtime, reverse=True) if not configs: return fail(out_dir, "no_config", "no config.yml produced by training") started = time.monotonic() exported = run([ "ns-export", "gaussian-splat", "--load-config", str(configs[0]), "--output-dir", str(export), ]) timings["export_s"] = round(time.monotonic() - started, 1) if exported.returncode != 0: return fail(out_dir, "export_failed", f"ns-export exit {exported.returncode}") plys = sorted(export.rglob("*.ply"), key=lambda p: p.stat().st_mtime, reverse=True) if not plys: return fail(out_dir, "no_ply", "ns-export produced no PLY") shutil.copyfile(plys[0], out_dir / "splat.ply") if args.keep_checkpoint: shutil.make_archive(str(out_dir / "model"), "zip", root_dir=str(train_out)) make_processed_min(processed, out_dir / "processed_min.zip") log("checkpoint archived (model.zip + processed_min.zip)") (out_dir / "summary.json").write_text( json.dumps( { "frames": frame_count, "matching_method": matching_used, "train_args": json.loads(args.train_args), "timings": timings, "sfm_engine": "da3", }, indent=2, ), encoding="utf-8", ) log("complete") return 0 def main() -> int: # noqa: PLR0911, PLR0915 parser = argparse.ArgumentParser() parser.add_argument("--downscale", type=int, default=2) parser.add_argument("--train-args", default="[]", help="JSON list of ns-train args") parser.add_argument("--keep-checkpoint", action="store_true") parser.add_argument( "--da3-model", default="depth-anything/DA3-LARGE-1.1", help="HuggingFace model ID for DA3 (e.g. depth-anything/DA3-GIANT-1.1)", ) parser.add_argument( "--gs-only", action="store_true", help="Run DA3 with infer_gs=True and output da3_gaussians.ply directly. No splatfacto.", ) parser.add_argument( "--sfm-only", action="store_true", help="Run DA3 SfM only, produce processed_min.zip. No splatfacto training.", ) parser.add_argument( "--train-only", action="store_true", help="Skip DA3. Consume processed_min.zip from VW_IN, run splatfacto + ns-export.", ) parser.add_argument( "--refine-mode", action="store_true", help="Refine an existing splat: VW_IN must contain model.zip + processed_min.zip.", ) parser.add_argument( "--merge-splat", action="store_true", help="Incremental merge: run DA3 direct 3DGS on new frames, then merge with base splat.ply from VW_IN.", ) parser.add_argument( "--voxel-size", type=float, default=0.05, help="Voxel edge length for deduplication during merge (scene units).", ) parser.add_argument( "--dynamic-cull-radius", type=float, default=0.10, help="Cull base gaussians within this radius of new ones (0 = disable).", ) parser.add_argument( "--icp-max-distance", type=float, default=0.5, help="ICP max correspondence distance for alignment (0 = skip alignment).", ) parser.add_argument( "--max-sfm-frames", type=int, default=80, help="Max frames passed to DA3 inference (subsampled evenly). Prevents OOM on 22GB L4.", ) args = parser.parse_args() in_dir = Path(os.environ["VW_IN"]) out_dir = Path(os.environ["VW_OUT"]) work = Path("/tmp/da3_recon") images = work / "images" processed = work / "processed" train_out = work / "train" export = work / "export" for folder in (images, processed, train_out, export): folder.mkdir(parents=True, exist_ok=True) timings: dict[str, float] = {} frames_zip = in_dir / "frames.zip" if not frames_zip.exists(): return fail(out_dir, "missing_input", "frames.zip not found in stage inputs") with zipfile.ZipFile(frames_zip) as archive: archive.extractall(images) image_paths = load_images(images) frame_count = len(image_paths) log(f"{frame_count} frames extracted") # Subsample frames for DA3 inference — the multi-view attention scales # quadratically with frame count and OOMs on 22GB L4 above ~100 frames. # All frames are still passed to splatfacto training via processed/images/. sfm_frames = image_paths if not args.train_only and frame_count > args.max_sfm_frames: step = frame_count / args.max_sfm_frames indices = [int(i * step) for i in range(args.max_sfm_frames)] sfm_frames = [image_paths[i] for i in indices] log(f"Subsampled {frame_count} → {len(sfm_frames)} frames for DA3 SfM (max-sfm-frames={args.max_sfm_frames})") if args.gs_only and (args.sfm_only or args.train_only): return fail(out_dir, "bad_args", "--gs-only is mutually exclusive with --sfm-only and --train-only") if args.sfm_only and args.train_only: return fail(out_dir, "bad_args", "--sfm-only and --train-only are mutually exclusive") if args.merge_splat and not args.gs_only: return fail(out_dir, "bad_args", "--merge-splat requires --gs-only") # ---- --gs-only: DA3 direct 3DGS, no splatfacto ---- if args.gs_only: log(f"DA3 direct 3DGS mode (model: {args.da3_model})") started = time.monotonic() prediction = da3_inference( sfm_frames, model_id=args.da3_model, infer_gs=True, export_dir=out_dir, export_format="gs_ply", ) timings["da3_inference_s"] = round(time.monotonic() - started, 1) # Try to find the gs_ply exported by DA3's export pipeline gs_ply_candidates = sorted(out_dir.rglob("*.ply"), key=lambda p: p.stat().st_mtime, reverse=True) if gs_ply_candidates: shutil.copyfile(gs_ply_candidates[0], out_dir / "da3_gaussians.ply") shutil.copyfile(gs_ply_candidates[0], out_dir / "splat.ply") log(f"DA3 gs_ply exported to {out_dir / 'splat.ply'}") else: # Fallback: export from prediction.aux['gaussians'] try: export_da3_gs_ply(prediction, out_dir / "da3_gaussians.ply") shutil.copyfile(out_dir / "da3_gaussians.ply", out_dir / "splat.ply") except Exception as exc: return fail(out_dir, "gs_export_failed", str(exc)) (out_dir / "summary.json").write_text( json.dumps({ "frames": frame_count, "sfm_engine": "da3", "da3_model": args.da3_model, "direct_gs": True, "timings": timings, }, indent=2), encoding="utf-8", ) # ---- --merge-splat: merge new DA3 3DGS with base splat ---- if args.merge_splat: base_ply = in_dir / "splat.ply" if not base_ply.exists(): return fail(out_dir, "missing_input", "--merge-splat requires splat.ply (base splat) in $VW_IN") log(f"Merging new DA3 splat with base: {base_ply}") from gaussian_merge import MergeConfig, merge_splats from splat_io import read_gaussian_ply, write_gaussian_ply merge_config = MergeConfig( voxel_size=args.voxel_size, icp_max_distance=args.icp_max_distance, dynamic_cull_radius=args.dynamic_cull_radius, prefer_new=True, ) merge_started = time.monotonic() base_splat = read_gaussian_ply(base_ply) new_splat = read_gaussian_ply(out_dir / "splat.ply") log(f"Base: {base_splat.count} gaussians | New: {new_splat.count} gaussians") merged = merge_splats(base_splat, new_splat, merge_config) timings["merge_s"] = round(time.monotonic() - merge_started, 1) log(f"Merged: {merged.count} gaussians (culled {base_splat.count + new_splat.count - merged.count})") write_gaussian_ply(merged, out_dir / "splat.ply") (out_dir / "summary.json").write_text( json.dumps({ "frames": frame_count, "sfm_engine": "da3", "da3_model": args.da3_model, "direct_gs": True, "incremental_merge": True, "base_gaussians": base_splat.count, "new_gaussians": new_splat.count, "merged_gaussians": merged.count, "merge_config": { "voxel_size": args.voxel_size, "dynamic_cull_radius": args.dynamic_cull_radius, "icp_max_distance": args.icp_max_distance, }, "timings": timings, }, indent=2), encoding="utf-8", ) log("DA3 incremental merge complete") return 0 log("DA3 direct 3DGS complete") return 0 # ---- --train-only: skip DA3, consume processed_min.zip ---- if args.train_only: processed_zip = in_dir / "processed_min.zip" if not processed_zip.exists(): return fail(out_dir, "missing_input", "--train-only requires processed_min.zip in $VW_IN") with zipfile.ZipFile(processed_zip) as archive: archive.extractall(processed) # Only move extracted frames into processed/images/ if the SfM zip # didn't already include them. DA3's processed_min.zip contains only # the subsampled SfM frames — moving all 500 would cause a mismatch # between transforms.json (80 frames) and images/ (500 files). target_images = processed / "images" if not target_images.exists() and images.exists(): shutil.move(str(images), str(target_images)) train_args = filter_supported_flags(json.loads(args.train_args)) # Disable torch.compile/dynamo — nerfstudio's splatfacto uses @torch.compile # which triggers a broken inductor import chain when torch version is mismatched. os.environ["TORCHDYNAMO_DISABLE"] = "1" started = time.monotonic() train_cmd = [ "ns-train", "splatfacto", "--data", str(processed), "--output-dir", str(train_out), *train_args, ] if args.refine_mode: bundle_model = in_dir / "model.zip" if bundle_model.exists(): base_train = Path("/tmp/da3_recon/base_train") base_train.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(bundle_model) as archive: archive.extractall(base_train) configs = sorted(base_train.rglob("config.yml"), key=lambda p: p.stat().st_mtime, reverse=True) if configs: train_cmd.extend(["--load-dir", str(configs[0].parent)]) log(f"Refine: loading checkpoint from {configs[0].parent}") result = run_streaming(train_cmd) timings["train_s"] = round(time.monotonic() - started, 1) if result.returncode != 0: return fail(out_dir, "train_failed", f"ns-train exit {result.returncode}") return finish_export(args, out_dir, processed, train_out, export, timings, frame_count, "da3") # ---- --sfm-only or full: run DA3 SfM first ---- log(f"DA3 SfM mode (model: {args.da3_model})") started = time.monotonic() prediction = da3_inference( sfm_frames, model_id=args.da3_model, infer_gs=False, ) timings["da3_inference_s"] = round(time.monotonic() - started, 1) # Convert DA3 output to nerfstudio format (use sfm_frames — prediction only has these) da3_to_transforms(prediction, sfm_frames, images, processed) da3_to_sparse_pc(prediction, processed) # Move ALL images into processed/images/ for nerfstudio dataparser # (splatfacto can use more frames than DA3 processed for training supervision) target_images = processed / "images" target_images.mkdir(parents=True, exist_ok=True) for img in image_paths: shutil.copyfile(img, target_images / img.name) if args.sfm_only: make_processed_min(processed, out_dir / "processed_min.zip") (out_dir / "summary.json").write_text( json.dumps({ "frames": frame_count, "sfm_engine": "da3", "da3_model": args.da3_model, "timings": timings, }, indent=2), encoding="utf-8", ) log("DA3 SfM-only complete") return 0 # Full mode: continue to splatfacto training train_args = filter_supported_flags(json.loads(args.train_args)) os.environ["TORCHDYNAMO_DISABLE"] = "1" started = time.monotonic() train_cmd = [ "ns-train", "splatfacto", "--data", str(processed), "--output-dir", str(train_out), *train_args, ] result = run_streaming(train_cmd) timings["train_s"] = round(time.monotonic() - started, 1) if result.returncode != 0: return fail(out_dir, "train_failed", f"ns-train exit {result.returncode}") return finish_export(args, out_dir, processed, train_out, export, timings, frame_count, "da3") if __name__ == "__main__": sys.exit(main())