Files

314 lines
12 KiB
Python

#!/usr/bin/env python3
import argparse
import logging
from pathlib import Path
from typing import Tuple
import cv2
import imageio
import numpy as np
import torch
from scipy.spatial.transform import Rotation
from vipe.slam.interface import SLAMMap
from vipe.utils.cameras import CameraType
from vipe.utils.depth import reliable_depth_mask_range
from vipe.utils.io import (
ArtifactPath,
read_depth_artifacts,
read_intrinsics_artifacts,
read_pose_artifacts,
read_rgb_artifacts,
)
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def quaternion_from_matrix(matrix: np.ndarray) -> np.ndarray:
"""Convert rotation matrix to quaternion (w, x, y, z)."""
rotation = Rotation.from_matrix(matrix[:3, :3])
quat_xyzw = rotation.as_quat() # Returns [x, y, z, w]
return np.array([quat_xyzw[3], quat_xyzw[0], quat_xyzw[1], quat_xyzw[2]]) # Convert to [w, x, y, z]
def matrix_to_colmap_pose(c2w_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert camera-to-world matrix to COLMAP format.
COLMAP uses world-to-camera transformation.
"""
w2c = np.linalg.inv(c2w_matrix)
quaternion = quaternion_from_matrix(w2c)
translation = w2c[:3, 3]
return quaternion, translation
def write_cameras_txt(output_dir: Path, artifact: ArtifactPath, frame_width: int, frame_height: int):
"""Write COLMAP cameras.txt file."""
cameras_file = output_dir / "cameras.txt"
_, intrinsics, camera_types = read_intrinsics_artifacts(artifact.intrinsics_path)
# Use first frame's intrinsics (assuming constant intrinsics)
assert camera_types[0] == CameraType.PINHOLE, "Only PINHOLE camera type is supported"
fx, fy, cx, cy = intrinsics[0].cpu().numpy()
with open(cameras_file, "w") as f:
f.write("# Camera list with one line of data per camera:\n")
f.write("# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n")
f.write("# Number of cameras: 1\n")
fx, fy, cx, cy = intrinsics[0]
# COLMAP camera format: CAMERA_ID MODEL WIDTH HEIGHT fx fy cx cy
f.write(f"1 PINHOLE {frame_width} {frame_height} {fx:.6f} {fy:.6f} {cx:.6f} {cy:.6f}\n")
logger.info(f"Written cameras.txt with intrinsics: fx={fx:.2f}, fy={fy:.2f}, cx={cx:.2f}, cy={cy:.2f}")
def write_images_txt(output_dir: Path, artifact: ArtifactPath):
"""Write COLMAP images.txt file."""
images_file = output_dir / "images.txt"
# Load pose data
pose_data = np.load(artifact.pose_path)
poses = pose_data["data"] # Shape: (N, 4, 4)
indices = pose_data["inds"] # Frame indices
with open(images_file, "w") as f:
f.write("# Image list with two lines of data per image:\n")
f.write("# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n")
f.write("# POINTS2D[] as (X, Y, POINT3D_ID)\n")
f.write(f"# Number of images: {len(poses)}\n")
for i, (pose_matrix, frame_idx) in enumerate(zip(poses, indices)):
# Convert pose to COLMAP format
quaternion, translation = matrix_to_colmap_pose(pose_matrix)
qw, qx, qy, qz = quaternion
tx, ty, tz = translation
# Image filename
image_name = f"images/frame_{frame_idx:06d}.jpg"
# Write image line
f.write(f"{i + 1} {qw:.9f} {qx:.9f} {qy:.9f} {qz:.9f} {tx:.9f} {ty:.9f} {tz:.9f} 1 {image_name}\n")
# Empty points2D line (no 2D-3D correspondences)
f.write("\n")
logger.info(f"Written images.txt with {len(poses)} images")
def write_points3d_txt_from_slam_map(output_dir: Path, artifact: ArtifactPath):
"""Write points3D.txt from SLAM map (placeholder implementation)."""
assert artifact.slam_map_path.exists(), "SLAM map not found, please refer to README.md for more details."
slam_map = SLAMMap.load(artifact.slam_map_path, device=torch.device("cpu"))
points3d_file = output_dir / "points3D.txt"
with open(points3d_file, "w") as f:
f.write("# 3D point list with one line of data per point:\n")
f.write("# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)\n")
f.write(f"# Number of points: {slam_map.dense_disp_xyz.shape[0]}\n")
point_id = 1
for keyframe_idx, frame_idx in enumerate(slam_map.dense_disp_frame_inds):
xyz, rgb = slam_map.get_dense_disp_pcd(keyframe_idx)
xyz = xyz.cpu().numpy()
rgb = rgb.cpu().numpy()
for xyz, rgb in zip(xyz, rgb):
x, y, z = xyz
r, g, b = (rgb * 255).astype(np.uint8)
f.write(f"{point_id} {x:.6f} {y:.6f} {z:.6f} {r} {g} {b} 0.0 {frame_idx} {point_id} 0 0 0 0\n")
point_id += 1
def write_points3d_txt_from_depth(
output_dir: Path, artifact: ArtifactPath, depth_step: int, spatial_subsample: int = 4
):
"""Write empty COLMAP points3D.txt file."""
_, pose_data = read_pose_artifacts(artifact.pose_path)
_, intrinsics, camera_types = read_intrinsics_artifacts(artifact.intrinsics_path)
camera_type = camera_types[0]
points3d_file = output_dir / "points3D.txt"
image_dir = output_dir / "images"
images = sorted(list(image_dir.glob("*.jpg")))
# Collect all 3D points first
all_points = []
point_id = 1
rays: np.ndarray | None = None
for idx, (_, depth) in enumerate(read_depth_artifacts(artifact.depth_path)):
if idx % 30 == 0:
logger.info(f"Processed {idx} depth maps")
if idx % depth_step != 0:
continue
rgb = cv2.cvtColor(cv2.imread(str(images[idx]), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB)
frame_height, frame_width = rgb.shape[:2]
rgb = rgb[::spatial_subsample, ::spatial_subsample]
if rays is None:
camera_model = camera_type.build_camera_model(intrinsics[idx])
disp_v, disp_u = torch.meshgrid(
torch.arange(frame_height).float()[::spatial_subsample],
torch.arange(frame_width).float()[::spatial_subsample],
indexing="ij",
)
if camera_type == CameraType.PANORAMA:
disp_v = disp_v / (frame_height - 1)
disp_u = disp_u / (frame_width - 1)
disp = torch.ones_like(disp_v)
pts, _, _ = camera_model.iproj_disp(disp, disp_u, disp_v)
rays = pts[..., :3].numpy()
if camera_type != CameraType.PANORAMA:
rays /= rays[..., 2:3]
if depth is not None:
pcd = rays * depth.numpy()[::spatial_subsample, ::spatial_subsample, None]
depth_mask = reliable_depth_mask_range(depth)[::spatial_subsample, ::spatial_subsample].numpy()
rgb, pcd = rgb[depth_mask], pcd[depth_mask]
c2w_matrix = pose_data[idx].matrix().numpy()
pcd = pcd @ c2w_matrix[:3, :3].T + c2w_matrix[:3, 3][None]
for pts_rgb, pts_xyz in zip(rgb, pcd):
all_points.append(
(
point_id,
pts_xyz[0],
pts_xyz[1],
pts_xyz[2],
int(pts_rgb[0]),
int(pts_rgb[1]),
int(pts_rgb[2]),
0.0,
idx + 1,
)
)
point_id += 1
# Write points to file
with open(points3d_file, "w") as f:
f.write("# 3D point list with one line of data per point:\n")
f.write("# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)\n")
f.write(f"# Number of points: {len(all_points)}\n")
for point_id, point_data in enumerate(all_points):
point_id, x, y, z, r, g, b, error, image_id = point_data
# The last 4 values are for visualization purposes.
f.write(f"{point_id} {x:.6f} {y:.6f} {z:.6f} {r} {g} {b} {error:.6f} {image_id} {point_id} 0 0 0 0\n")
logger.info(f"Written points3D.txt with {len(all_points)} points")
def extract_frames(artifact: ArtifactPath, output_dir: Path) -> Tuple[int, int]:
"""Extract frames from video to individual image files."""
video_path = artifact.rgb_path
images_dir = output_dir / "images"
images_dir.mkdir(exist_ok=True)
logger.info(f"Extracting frames from {video_path}")
for frame_idx, rgb in read_rgb_artifacts(video_path):
frame_path = images_dir / f"frame_{frame_idx:06d}.jpg"
frame_height, frame_width = rgb.shape[:2]
imageio.imwrite(str(frame_path), (rgb.cpu().numpy() * 255).astype(np.uint8))
if frame_idx % 30 == 0:
logger.info(f"Extracted {frame_idx} frames")
logger.info(f"Extracted {frame_idx} frames to {images_dir}")
return frame_width, frame_height
def convert_vipe_to_colmap(artifact: ArtifactPath, output_path: Path, depth_step: int, use_slam_map: bool):
"""Convert ViPE reconstruction results to COLMAP format."""
logger.info(
f"Converting ViPE results from {artifact.base_path} ({artifact.artifact_name}) to COLMAP format at {output_path}"
)
# Verify required files exist
required_files = [artifact.rgb_path, artifact.pose_path, artifact.intrinsics_path, artifact.depth_path]
for file_path in required_files:
if not file_path.exists():
raise FileNotFoundError(f"Required file not found: {file_path}")
# Create output directory
output_path.mkdir(parents=True, exist_ok=True)
# Extract frames and get video dimensions
frame_width, frame_height = extract_frames(artifact, output_path)
# Write COLMAP files
write_cameras_txt(output_path, artifact, frame_width, frame_height)
write_images_txt(output_path, artifact)
if use_slam_map:
write_points3d_txt_from_slam_map(output_path, artifact)
else:
write_points3d_txt_from_depth(output_path, artifact, depth_step)
logger.info("COLMAP conversion completed successfully!")
logger.info(f"Output directory: {output_path}")
logger.info("Files created:")
logger.info(" - cameras.txt: Camera intrinsics")
logger.info(" - images.txt: Camera poses")
logger.info(" - points3D.txt: 3D points")
logger.info(" - images/: Individual frame images")
def main():
"""Main function for ViPE to COLMAP conversion script."""
parser = argparse.ArgumentParser(
description="Convert ViPE reconstruction results to COLMAP format",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("vipe_path", type=Path, help="Path to ViPE results directory")
parser.add_argument(
"--sequence",
"-s",
type=str,
help="Sequence name (if not provided will convert all sequences in the directory)",
default=None,
)
parser.add_argument("--use_slam_map", action="store_true", help="Use SLAM map to unproject depth maps")
parser.add_argument("--depth_step", type=int, default=16, help="Step size for depth extraction (default: 16)")
parser.add_argument(
"--output",
"-o",
type=Path,
default=None,
help="Output directory for COLMAP format (default: <vipe_path>_colmap)",
)
args = parser.parse_args()
if not args.vipe_path.exists():
print(f"Error: ViPE path '{args.vipe_path}' does not exist.")
return 1
# Find artifacts
artifacts = list(ArtifactPath.glob_artifacts(args.vipe_path, use_video=True))
if args.sequence is not None:
artifacts = [artifact for artifact in artifacts if artifact.artifact_name == args.sequence]
# Set default output path
if args.output is None:
args.output = args.vipe_path.parent / f"{args.vipe_path.name}_colmap"
for artifact in artifacts:
convert_vipe_to_colmap(artifact, args.output / artifact.artifact_name, args.depth_step, args.use_slam_map)
return 0
if __name__ == "__main__":
exit(main())