[WIP] Upload demo_colmap.py, which can (a) run both vggt and vggt+ba, (b) output in a colmap format, (c) in the original image resolution. The output can be directly used for nerf/gaussian training.

This commit is contained in:
Jianyuan Wang
2025-05-22 23:24:25 +01:00
parent c4b5da2d85
commit a9f0fa7d6b
39 changed files with 3371 additions and 801 deletions
-1
View File
@@ -1,7 +1,6 @@
.hydra/
output/
ckpt/
dependency/
# Byte-compiled / optimized / DLL files
__pycache__/
**/__pycache__/
+327
View File
@@ -0,0 +1,327 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import random
import numpy as np
import glob
import os
import copy
import torch
import torch.nn.functional as F
# Configure CUDA settings
torch.backends.cudnn.enabled = True
torch.backends.cudnn.benchmark = True
torch.backends.cudnn.deterministic = False
import argparse
from pathlib import Path
import trimesh
import pycolmap
from vggt.models.vggt import VGGT
from vggt.utils.load_fn import load_and_preprocess_images_square
from vggt.utils.pose_enc import pose_encoding_to_extri_intri
from vggt.utils.geometry import unproject_depth_map_to_point_map
from vggt.utils.helper import create_pixel_coordinate_grid, randomly_limit_trues
from vggt.dependency.track_predict import predict_tracks
from vggt.dependency.np_to_pycolmap import batch_np_matrix_to_pycolmap, batch_np_matrix_to_pycolmap_wo_track
# TODO: add support for masks
# TODO: add iterative BA
# TODO: add support for radial distortion, which needs extra_params
# TODO: test with more cases
# TODO: test different camera types
def parse_args():
parser = argparse.ArgumentParser(description="VGGT Demo")
parser.add_argument("--scene_dir", type=str, required=True, help="Directory containing the scene images")
parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility")
parser.add_argument("--use_ba", action="store_true", default=False, help="Use BA for reconstruction")
######### BA parameters #########
parser.add_argument(
"--max_reproj_error", type=float, default=8.0, help="Maximum reprojection error for reconstruction"
)
parser.add_argument("--shared_camera", action="store_true", default=False, help="Use shared camera for all images")
parser.add_argument("--camera_type", type=str, default="SIMPLE_PINHOLE", help="Camera type for reconstruction")
parser.add_argument("--vis_thresh", type=float, default=0.2, help="Visibility threshold for tracks")
parser.add_argument("--query_frame_num", type=int, default=5, help="Number of frames to query")
parser.add_argument("--max_query_pts", type=int, default=2048, help="Maximum number of query points")
parser.add_argument(
"--fine_tracking", action="store_true", default=True, help="Use fine tracking (slower but more accurate)"
)
return parser.parse_args()
def run_VGGT(model, images, dtype, resolution=518):
# images: [B, 3, H, W]
assert len(images.shape) == 4
assert images.shape[1] == 3
# hard-coded to use 518 for VGGT
images = F.interpolate(images, size=(resolution, resolution), mode="bilinear", align_corners=False)
with torch.no_grad():
with torch.cuda.amp.autocast(dtype=dtype):
images = images[None] # add batch dimension
aggregated_tokens_list, ps_idx = model.aggregator(images)
# Predict Cameras
pose_enc = model.camera_head(aggregated_tokens_list)[-1]
# Extrinsic and intrinsic matrices, following OpenCV convention (camera from world)
extrinsic, intrinsic = pose_encoding_to_extri_intri(pose_enc, images.shape[-2:])
# Predict Depth Maps
depth_map, depth_conf = model.depth_head(aggregated_tokens_list, images, ps_idx)
extrinsic = extrinsic.squeeze(0).cpu().numpy()
intrinsic = intrinsic.squeeze(0).cpu().numpy()
depth_map = depth_map.squeeze(0).cpu().numpy()
depth_conf = depth_conf.squeeze(0).cpu().numpy()
return extrinsic, intrinsic, depth_map, depth_conf
def demo_fn(args):
# Print configuration
print("Arguments:", vars(args))
# Set seed for reproducibility
np.random.seed(args.seed)
torch.manual_seed(args.seed)
random.seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(args.seed)
torch.cuda.manual_seed_all(args.seed) # for multi-GPU
print(f"Setting seed as: {args.seed}")
# Set device and dtype
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
print(f"Using dtype: {dtype}")
# Run VGGT for camera and depth estimation
model = VGGT()
_URL = "https://huggingface.co/facebook/VGGT-1B/resolve/main/model.pt"
model.load_state_dict(torch.hub.load_state_dict_from_url(_URL))
model.eval()
model = model.to(device)
print(f"Model loaded")
# Get image paths and preprocess them
image_dir = os.path.join(args.scene_dir, "images")
image_path_list = glob.glob(os.path.join(image_dir, "*"))
if len(image_path_list) == 0:
raise ValueError(f"No images found in {image_dir}")
base_image_path_list = [os.path.basename(path) for path in image_path_list]
# Load images and original coordinates
# Load Image in 1024, while running VGGT with 518
vggt_fixed_resolution = 518
img_load_resolution = 1024
images, original_coords = load_and_preprocess_images_square(image_path_list, img_load_resolution)
images = images.to(device)
original_coords = original_coords.to(device)
print(f"Loaded {len(images)} images from {image_dir}")
# Run VGGT to estimate camera and depth
# Run with 518x518 images
extrinsic, intrinsic, depth_map, depth_conf = run_VGGT(model, images, dtype, vggt_fixed_resolution)
points_3d = unproject_depth_map_to_point_map(depth_map, extrinsic, intrinsic)
if args.use_ba:
image_size = np.array(images.shape[-2:])
scale = img_load_resolution / vggt_fixed_resolution
shared_camera = args.shared_camera
with torch.cuda.amp.autocast(dtype=dtype):
# Predicting Tracks
# Using VGGSfM tracker instead of VGGT tracker for efficiency
# VGGT tracker requires multiple backbone runs to query different frames (this is a problem caused by the training process)
# Will be fixed in VGGT v2
# You can also change the pred_tracks to tracks from any other methods
# e.g., from COLMAP, from CoTracker, or by chaining 2D matches from Lightglue/LoFTR.
pred_tracks, pred_vis_scores, pred_confs, points_3d, points_rgb = predict_tracks(
images,
conf=depth_conf,
points_3d=points_3d,
masks=None,
max_query_pts=args.max_query_pts,
query_frame_num=args.query_frame_num,
keypoint_extractor="aliked+sp",
fine_tracking=args.fine_tracking,
)
torch.cuda.empty_cache()
# rescale the intrinsic matrix from 518 to 1024
intrinsic[:, :2, :] *= scale
track_mask = pred_vis_scores > args.vis_thresh
# TODO: radial distortion, iterative BA, masks
reconstruction, valid_track_mask = batch_np_matrix_to_pycolmap(
points_3d,
extrinsic,
intrinsic,
pred_tracks,
image_size,
masks=track_mask,
max_reproj_error=args.max_reproj_error,
shared_camera=shared_camera,
camera_type=args.camera_type,
points_rgb=points_rgb,
)
if reconstruction is None:
raise ValueError("No reconstruction can be built with BA")
# Bundle Adjustment
ba_options = pycolmap.BundleAdjustmentOptions()
pycolmap.bundle_adjustment(reconstruction, ba_options)
reconstruction_resolution = img_load_resolution
else:
conf_thres_value = 5 # hard-coded to 5
max_points_for_colmap = 100000 # randomly sample 3D points
shared_camera = False # in the feedforward manner, we do not support shared camera
camera_type = "PINHOLE" # in the feedforward manner, we only support PINHOLE camera
image_size = np.array([vggt_fixed_resolution, vggt_fixed_resolution])
num_frames, height, width, _ = points_3d.shape
points_rgb = F.interpolate(
images, size=(vggt_fixed_resolution, vggt_fixed_resolution), mode="bilinear", align_corners=False
)
points_rgb = (points_rgb.cpu().numpy() * 255).astype(np.uint8)
points_rgb = points_rgb.transpose(0, 2, 3, 1)
# (S, H, W, 3), with x, y coordinates and frame indices
points_xyf = create_pixel_coordinate_grid(num_frames, height, width)
conf_mask = depth_conf >= conf_thres_value
# at most writing 100000 3d points to colmap reconstruction object
conf_mask = randomly_limit_trues(conf_mask, max_points_for_colmap)
points_3d = points_3d[conf_mask]
points_xyf = points_xyf[conf_mask]
points_rgb = points_rgb[conf_mask]
print("Converting to COLMAP format")
reconstruction = batch_np_matrix_to_pycolmap_wo_track(
points_3d,
points_xyf,
points_rgb,
extrinsic,
intrinsic,
image_size,
shared_camera=shared_camera,
camera_type=camera_type,
)
reconstruction_resolution = vggt_fixed_resolution
# Save point cloud for fast visualization
trimesh.PointCloud(points_3d, colors=points_rgb).export(os.path.join(args.scene_dir, "sparse/points.ply"))
reconstruction = rename_colmap_recons_and_rescale_camera(
reconstruction,
base_image_path_list,
original_coords.cpu().numpy(),
img_size=reconstruction_resolution,
shift_point2d_to_original_res=True,
shared_camera=shared_camera,
)
print(f"Saving reconstruction to {args.scene_dir}/sparse")
sparse_reconstruction_dir = os.path.join(args.scene_dir, "sparse")
os.makedirs(sparse_reconstruction_dir, exist_ok=True)
reconstruction.write(sparse_reconstruction_dir)
return True
def rename_colmap_recons_and_rescale_camera(
reconstruction, image_paths, original_coords, img_size, shift_point2d_to_original_res=False, shared_camera=False
):
rescale_camera = True
for pyimageid in reconstruction.images:
# Reshaped the padded&resized image to the original size
# Rename the images to the original names
pyimage = reconstruction.images[pyimageid]
pycamera = reconstruction.cameras[pyimage.camera_id]
pyimage.name = image_paths[pyimageid - 1]
if rescale_camera:
# Rescale the camera parameters
pred_params = copy.deepcopy(pycamera.params)
real_image_size = original_coords[pyimageid - 1, -2:]
resize_ratio = max(real_image_size) / img_size
pred_params = pred_params * resize_ratio
real_pp = real_image_size / 2
pred_params[-2:] = real_pp # center of the image
pycamera.params = pred_params
pycamera.width = real_image_size[0]
pycamera.height = real_image_size[1]
if shift_point2d_to_original_res:
# Also shift the point2D to original resolution
top_left = original_coords[pyimageid - 1, :2]
for point2D in pyimage.points2D:
point2D.xy = (point2D.xy - top_left) * resize_ratio
if shared_camera:
# If shared_camera, all images share the same camera
# no need to rescale any more
rescale_camera = False
return reconstruction
if __name__ == "__main__":
args = parse_args()
with torch.no_grad():
demo_fn(args)
# Work in Progress (WIP)
"""
VGGT Runner Script
=================
A script to run the VGGT model for 3D reconstruction from image sequences.
Directory Structure
------------------
Input:
input_folder/
└── images/ # Source images for reconstruction
Output:
output_folder/
├── images/
├── sparse/ # Reconstruction results
│ ├── cameras.bin # Camera parameters (COLMAP format)
│ ├── images.bin # Pose for each image (COLMAP format)
│ ├── points3D.bin # 3D points (COLMAP format)
│ └── points.ply # Point cloud visualization file
└── visuals/ # Visualization outputs TODO
Key Features
-----------
• Dual-mode Support: Run reconstructions using either VGGT or VGGT+BA
• Resolution Preservation: Maintains original image resolution in camera parameters and tracks
• COLMAP Compatibility: Exports results in standard COLMAP sparse reconstruction format
"""
+10 -18
View File
@@ -289,21 +289,20 @@ def update_visualization(
return None, f"No reconstruction available at {predictions_path}. Please run 'Reconstruct' first."
key_list = [
'pose_enc',
'depth',
'depth_conf',
'world_points',
'world_points_conf',
'images',
'extrinsic',
'intrinsic',
'world_points_from_depth',
"pose_enc",
"depth",
"depth_conf",
"world_points",
"world_points_conf",
"images",
"extrinsic",
"intrinsic",
"world_points_from_depth",
]
loaded = np.load(predictions_path)
predictions = {key: np.array(loaded[key]) for key in key_list}
glbfile = os.path.join(
target_dir,
f"glbscene_{conf_thres}_{frame_filter.replace('.', '_').replace(':', '').replace(' ', '_')}_maskb{mask_black_bg}_maskw{mask_white_bg}_cam{show_cam}_sky{mask_sky}_pred{prediction_mode.replace(' ', '_')}.glb",
@@ -390,7 +389,6 @@ with gr.Blocks(
}
""",
) as demo:
# Instead of gr.State, we use a hidden Textbox:
is_example = gr.Textbox(label="is_example", visible=False, value="None")
num_images = gr.Textbox(label="num_images", visible=False, value="None")
@@ -534,13 +532,7 @@ with gr.Blocks(
prediction_mode,
is_example,
],
outputs=[
reconstruction_output,
log_output,
target_dir_output,
frame_filter,
image_gallery,
],
outputs=[reconstruction_output, log_output, target_dir_output, frame_filter, image_gallery],
fn=example_pipeline,
cache_examples=False,
examples_per_page=50,
+5 -19
View File
@@ -112,24 +112,15 @@ def viser_wrapper(
frame_indices = np.repeat(np.arange(S), H * W)
# Build the viser GUI
gui_show_frames = server.gui.add_checkbox(
"Show Cameras",
initial_value=True,
)
gui_show_frames = server.gui.add_checkbox("Show Cameras", initial_value=True)
# Now the slider represents percentage of points to filter out
gui_points_conf = server.gui.add_slider(
"Confidence Percent",
min=0,
max=100,
step=0.1,
initial_value=init_conf_threshold,
"Confidence Percent", min=0, max=100, step=0.1, initial_value=init_conf_threshold
)
gui_frame_selector = server.gui.add_dropdown(
"Show Points from Frames",
options=["All"] + [str(i) for i in range(S)],
initial_value="All",
"Show Points from Frames", options=["All"] + [str(i) for i in range(S)], initial_value="All"
)
# Create the main point cloud handle
@@ -200,12 +191,7 @@ def viser_wrapper(
# Add the frustum
frustum_cam = server.scene.add_camera_frustum(
f"frame_{img_id}/frustum",
fov=fov,
aspect=w / h,
scale=0.05,
image=img,
line_width=1.0,
f"frame_{img_id}/frustum", fov=fov, aspect=w / h, scale=0.05, image=img, line_width=1.0
)
frustums.append(frustum_cam)
attach_callback(frustum_cam, frame_axis)
@@ -373,7 +359,7 @@ def main():
images = load_and_preprocess_images(image_names).to(device)
print(f"Preprocessed images shape: {images.shape}")
print("Running inference...")
print("Running inference...")
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16
with torch.no_grad():
+4
View File
@@ -9,3 +9,7 @@ onnxruntime
requests
trimesh
matplotlib
pycolmap==3.10.0
pyceres==2.3
git+https://github.com/jytime/LightGlue.git#egg=lightglue
+3
View File
@@ -0,0 +1,3 @@
from .track_modules.track_refine import refine_track
from .track_modules.blocks import BasicEncoder, ShallowEncoder
from .track_modules.base_track_predictor import BaseTrackerPredictor
+182
View File
@@ -0,0 +1,182 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import torch
import numpy as np
from typing import Union
ArrayLike = Union[np.ndarray, torch.Tensor]
def _is_numpy(x: ArrayLike) -> bool:
return isinstance(x, np.ndarray)
def _is_torch(x: ArrayLike) -> bool:
return isinstance(x, torch.Tensor)
def _ensure_torch(x: ArrayLike) -> torch.Tensor:
"""Convert input to torch tensor if it's not already one."""
if _is_numpy(x):
return torch.from_numpy(x)
elif _is_torch(x):
return x
else:
return torch.tensor(x)
def single_undistortion(params, tracks_normalized):
"""
Apply undistortion to the normalized tracks using the given distortion parameters once.
Args:
params (torch.Tensor or numpy.ndarray): Distortion parameters of shape BxN.
tracks_normalized (torch.Tensor or numpy.ndarray): Normalized tracks tensor of shape [batch_size, num_tracks, 2].
Returns:
torch.Tensor: Undistorted normalized tracks tensor.
"""
params = _ensure_torch(params)
tracks_normalized = _ensure_torch(tracks_normalized)
u, v = tracks_normalized[..., 0].clone(), tracks_normalized[..., 1].clone()
u_undist, v_undist = apply_distortion(params, u, v)
return torch.stack([u_undist, v_undist], dim=-1)
def iterative_undistortion(params, tracks_normalized, max_iterations=100, max_step_norm=1e-10, rel_step_size=1e-6):
"""
Iteratively undistort the normalized tracks using the given distortion parameters.
Args:
params (torch.Tensor or numpy.ndarray): Distortion parameters of shape BxN.
tracks_normalized (torch.Tensor or numpy.ndarray): Normalized tracks tensor of shape [batch_size, num_tracks, 2].
max_iterations (int): Maximum number of iterations for the undistortion process.
max_step_norm (float): Maximum step norm for convergence.
rel_step_size (float): Relative step size for numerical differentiation.
Returns:
torch.Tensor: Undistorted normalized tracks tensor.
"""
params = _ensure_torch(params)
tracks_normalized = _ensure_torch(tracks_normalized)
B, N, _ = tracks_normalized.shape
u, v = tracks_normalized[..., 0].clone(), tracks_normalized[..., 1].clone()
original_u, original_v = u.clone(), v.clone()
eps = torch.finfo(u.dtype).eps
for idx in range(max_iterations):
u_undist, v_undist = apply_distortion(params, u, v)
dx = original_u - u_undist
dy = original_v - v_undist
step_u = torch.clamp(torch.abs(u) * rel_step_size, min=eps)
step_v = torch.clamp(torch.abs(v) * rel_step_size, min=eps)
J_00 = (apply_distortion(params, u + step_u, v)[0] - apply_distortion(params, u - step_u, v)[0]) / (2 * step_u)
J_01 = (apply_distortion(params, u, v + step_v)[0] - apply_distortion(params, u, v - step_v)[0]) / (2 * step_v)
J_10 = (apply_distortion(params, u + step_u, v)[1] - apply_distortion(params, u - step_u, v)[1]) / (2 * step_u)
J_11 = (apply_distortion(params, u, v + step_v)[1] - apply_distortion(params, u, v - step_v)[1]) / (2 * step_v)
J = torch.stack([torch.stack([J_00 + 1, J_01], dim=-1), torch.stack([J_10, J_11 + 1], dim=-1)], dim=-2)
delta = torch.linalg.solve(J, torch.stack([dx, dy], dim=-1))
u += delta[..., 0]
v += delta[..., 1]
if torch.max((delta**2).sum(dim=-1)) < max_step_norm:
break
return torch.stack([u, v], dim=-1)
def apply_distortion(extra_params, u, v):
"""
Applies radial or OpenCV distortion to the given 2D points.
Args:
extra_params (torch.Tensor or numpy.ndarray): Distortion parameters of shape BxN, where N can be 1, 2, or 4.
u (torch.Tensor or numpy.ndarray): Normalized x coordinates of shape Bxnum_tracks.
v (torch.Tensor or numpy.ndarray): Normalized y coordinates of shape Bxnum_tracks.
Returns:
points2D (torch.Tensor): Distorted 2D points of shape BxNx2.
"""
extra_params = _ensure_torch(extra_params)
u = _ensure_torch(u)
v = _ensure_torch(v)
num_params = extra_params.shape[1]
if num_params == 1:
# Simple radial distortion
k = extra_params[:, 0]
u2 = u * u
v2 = v * v
r2 = u2 + v2
radial = k[:, None] * r2
du = u * radial
dv = v * radial
elif num_params == 2:
# RadialCameraModel distortion
k1, k2 = extra_params[:, 0], extra_params[:, 1]
u2 = u * u
v2 = v * v
r2 = u2 + v2
radial = k1[:, None] * r2 + k2[:, None] * r2 * r2
du = u * radial
dv = v * radial
elif num_params == 4:
# OpenCVCameraModel distortion
k1, k2, p1, p2 = (extra_params[:, 0], extra_params[:, 1], extra_params[:, 2], extra_params[:, 3])
u2 = u * u
v2 = v * v
uv = u * v
r2 = u2 + v2
radial = k1[:, None] * r2 + k2[:, None] * r2 * r2
du = u * radial + 2 * p1[:, None] * uv + p2[:, None] * (r2 + 2 * u2)
dv = v * radial + 2 * p2[:, None] * uv + p1[:, None] * (r2 + 2 * v2)
else:
raise ValueError("Unsupported number of distortion parameters")
u = u.clone() + du
v = v.clone() + dv
return u, v
if __name__ == "__main__":
import random
import pycolmap
max_diff = 0
for i in range(1000):
# Define distortion parameters (assuming 1 parameter for simplicity)
B = random.randint(1, 500)
track_num = random.randint(100, 1000)
params = torch.rand((B, 1), dtype=torch.float32) # Batch size 1, 4 parameters
tracks_normalized = torch.rand((B, track_num, 2), dtype=torch.float32) # Batch size 1, 5 points
# Undistort the tracks
undistorted_tracks = iterative_undistortion(params, tracks_normalized)
for b in range(B):
pycolmap_intri = np.array([1, 0, 0, params[b].item()])
pycam = pycolmap.Camera(model="SIMPLE_RADIAL", width=1, height=1, params=pycolmap_intri, camera_id=0)
undistorted_tracks_pycolmap = pycam.cam_from_img(tracks_normalized[b].numpy())
diff = (undistorted_tracks[b] - undistorted_tracks_pycolmap).abs().median()
max_diff = max(max_diff, diff)
print(f"diff: {diff}, max_diff: {max_diff}")
import pdb
pdb.set_trace()
+318
View File
@@ -0,0 +1,318 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import pycolmap
from .projection import project_3D_points_np
def batch_np_matrix_to_pycolmap(
points3d,
extrinsics,
intrinsics,
tracks,
image_size,
masks=None,
max_reproj_error=None,
max_points3D_val=3000,
shared_camera=False,
camera_type="SIMPLE_PINHOLE",
extra_params=None,
min_inlier_per_frame=64,
points_rgb=None,
):
"""
Convert Batched NumPy Arrays to PyCOLMAP
Check https://github.com/colmap/pycolmap for more details about its format
NOTE that colmap expects images/cameras/points3D to be 1-indexed
so there is a +1 offset between colmap index and batch index
NOTE: different from VGGSfM, this function:
1. Use np instead of torch
2. Frame index and camera id starts from 1 rather than 0 (to fit the format of PyCOLMAP)
"""
# points3d: Px3
# extrinsics: Nx3x4
# intrinsics: Nx3x3
# tracks: NxPx2
# masks: NxP
# image_size: 2, assume all the frames have been padded to the same size
# where N is the number of frames and P is the number of tracks
N, P, _ = tracks.shape
assert len(extrinsics) == N
assert len(intrinsics) == N
assert len(points3d) == P
assert image_size.shape[0] == 2
if max_reproj_error is not None:
projected_points_2d, projected_points_cam = project_3D_points_np(points3d, extrinsics, intrinsics)
projected_diff = np.linalg.norm(projected_points_2d - tracks, axis=-1)
projected_points_2d[projected_points_cam[:, -1] <= 0] = 1e6
reproj_mask = projected_diff < max_reproj_error
if masks is not None and reproj_mask is not None:
masks = np.logical_and(masks, reproj_mask)
elif masks is not None:
masks = masks
else:
masks = reproj_mask
assert masks is not None
if masks.sum(1).min() < min_inlier_per_frame:
print(f"Not enough inliers per frame, skip BA.")
return None, None
# Reconstruction object, following the format of PyCOLMAP/COLMAP
reconstruction = pycolmap.Reconstruction()
inlier_num = masks.sum(0)
valid_mask = inlier_num >= 2 # a track is invalid if without two inliers
valid_idx = np.nonzero(valid_mask)[0]
# Only add 3D points that have sufficient 2D points
for vidx in valid_idx:
# Use RGB colors if provided, otherwise use zeros
rgb = points_rgb[vidx] if points_rgb is not None else np.zeros(3)
reconstruction.add_point3D(points3d[vidx], pycolmap.Track(), rgb)
num_points3D = len(valid_idx)
camera = None
# frame idx
for fidx in range(N):
# set camera
if camera is None or (not shared_camera):
pycolmap_intri = _build_pycolmap_intri(fidx, intrinsics, camera_type, extra_params)
camera = pycolmap.Camera(
model=camera_type, width=image_size[0], height=image_size[1], params=pycolmap_intri, camera_id=fidx + 1
)
# add camera
reconstruction.add_camera(camera)
# set image
cam_from_world = pycolmap.Rigid3d(
pycolmap.Rotation3d(extrinsics[fidx][:3, :3]), extrinsics[fidx][:3, 3]
) # Rot and Trans
image = pycolmap.Image(
id=fidx + 1, name=f"image_{fidx + 1}", camera_id=camera.camera_id, cam_from_world=cam_from_world
)
points2D_list = []
point2D_idx = 0
# NOTE point3D_id start by 1
for point3D_id in range(1, num_points3D + 1):
original_track_idx = valid_idx[point3D_id - 1]
if (reconstruction.points3D[point3D_id].xyz < max_points3D_val).all():
if masks[fidx][original_track_idx]:
# It seems we don't need +0.5 for BA
point2D_xy = tracks[fidx][original_track_idx]
# Please note when adding the Point2D object
# It not only requires the 2D xy location, but also the id to 3D point
points2D_list.append(pycolmap.Point2D(point2D_xy, point3D_id))
# add element
track = reconstruction.points3D[point3D_id].track
track.add_element(fidx + 1, point2D_idx)
point2D_idx += 1
assert point2D_idx == len(points2D_list)
try:
image.points2D = pycolmap.ListPoint2D(points2D_list)
image.registered = True
except:
print(f"frame {fidx + 1} is out of BA")
image.registered = False
# add image
reconstruction.add_image(image)
return reconstruction, valid_mask
def pycolmap_to_batch_np_matrix(reconstruction, device="cpu", camera_type="SIMPLE_PINHOLE"):
"""
Convert a PyCOLMAP Reconstruction Object to batched NumPy arrays.
Args:
reconstruction (pycolmap.Reconstruction): The reconstruction object from PyCOLMAP.
device (str): Ignored in NumPy version (kept for API compatibility).
camera_type (str): The type of camera model used (default: "SIMPLE_PINHOLE").
Returns:
tuple: A tuple containing points3D, extrinsics, intrinsics, and optionally extra_params.
"""
num_images = len(reconstruction.images)
max_points3D_id = max(reconstruction.point3D_ids())
points3D = np.zeros((max_points3D_id, 3))
for point3D_id in reconstruction.points3D:
points3D[point3D_id - 1] = reconstruction.points3D[point3D_id].xyz
extrinsics = []
intrinsics = []
extra_params = [] if camera_type == "SIMPLE_RADIAL" else None
for i in range(num_images):
# Extract and append extrinsics
pyimg = reconstruction.images[i + 1]
pycam = reconstruction.cameras[pyimg.camera_id]
matrix = pyimg.cam_from_world.matrix()
extrinsics.append(matrix)
# Extract and append intrinsics
calibration_matrix = pycam.calibration_matrix()
intrinsics.append(calibration_matrix)
if camera_type == "SIMPLE_RADIAL":
extra_params.append(pycam.params[-1])
# Convert lists to NumPy arrays instead of torch tensors
extrinsics = np.stack(extrinsics)
intrinsics = np.stack(intrinsics)
if camera_type == "SIMPLE_RADIAL":
extra_params = np.stack(extra_params)
extra_params = extra_params[:, None]
return points3D, extrinsics, intrinsics, extra_params
########################################################
def batch_np_matrix_to_pycolmap_wo_track(
points3d,
points_xyf,
points_rgb,
extrinsics,
intrinsics,
image_size,
shared_camera=False,
camera_type="SIMPLE_PINHOLE",
):
"""
Convert Batched NumPy Arrays to PyCOLMAP
Different from batch_np_matrix_to_pycolmap, this function does not use tracks.
It saves points3d to colmap reconstruction format only to serve as init for Gaussians or other nvs methods.
Do NOT use this for BA.
"""
# points3d: Px3
# points_xyf: Px3, with x, y coordinates and frame indices
# points_rgb: Px3, rgb colors
# extrinsics: Nx3x4
# intrinsics: Nx3x3
# image_size: 2, assume all the frames have been padded to the same size
# where N is the number of frames and P is the number of tracks
N = len(extrinsics)
P = len(points3d)
# Reconstruction object, following the format of PyCOLMAP/COLMAP
reconstruction = pycolmap.Reconstruction()
for vidx in range(P):
reconstruction.add_point3D(points3d[vidx], pycolmap.Track(), points_rgb[vidx])
camera = None
# frame idx
for fidx in range(N):
# set camera
if camera is None or (not shared_camera):
pycolmap_intri = _build_pycolmap_intri(fidx, intrinsics, camera_type)
camera = pycolmap.Camera(
model=camera_type, width=image_size[0], height=image_size[1], params=pycolmap_intri, camera_id=fidx + 1
)
# add camera
reconstruction.add_camera(camera)
# set image
cam_from_world = pycolmap.Rigid3d(
pycolmap.Rotation3d(extrinsics[fidx][:3, :3]), extrinsics[fidx][:3, 3]
) # Rot and Trans
image = pycolmap.Image(
id=fidx + 1, name=f"image_{fidx + 1}", camera_id=camera.camera_id, cam_from_world=cam_from_world
)
points2D_list = []
point2D_idx = 0
points_belong_to_fidx = points_xyf[:, 2].astype(np.int32) == fidx
points_belong_to_fidx = np.nonzero(points_belong_to_fidx)[0]
for point3D_batch_idx in points_belong_to_fidx:
point3D_id = point3D_batch_idx + 1
point2D_xyf = points_xyf[point3D_batch_idx]
point2D_xy = point2D_xyf[:2]
points2D_list.append(pycolmap.Point2D(point2D_xy, point3D_id))
# add element
track = reconstruction.points3D[point3D_id].track
track.add_element(fidx + 1, point2D_idx)
point2D_idx += 1
assert point2D_idx == len(points2D_list)
try:
image.points2D = pycolmap.ListPoint2D(points2D_list)
image.registered = True
except:
print(f"frame {fidx + 1} does not have any points")
image.registered = False
# add image
reconstruction.add_image(image)
return reconstruction
def _build_pycolmap_intri(fidx, intrinsics, camera_type, extra_params=None):
"""
Helper function to get camera parameters based on camera type.
Args:
fidx: Frame index
intrinsics: Camera intrinsic parameters
camera_type: Type of camera model
extra_params: Additional parameters for certain camera types
Returns:
pycolmap_intri: NumPy array of camera parameters
"""
if camera_type == "PINHOLE":
pycolmap_intri = np.array(
[intrinsics[fidx][0, 0], intrinsics[fidx][1, 1], intrinsics[fidx][0, 2], intrinsics[fidx][1, 2]]
)
elif camera_type == "SIMPLE_PINHOLE":
focal = (intrinsics[fidx][0, 0] + intrinsics[fidx][1, 1]) / 2
pycolmap_intri = np.array([focal, intrinsics[fidx][0, 2], intrinsics[fidx][1, 2]])
elif camera_type == "SIMPLE_RADIAL":
raise NotImplementedError("SIMPLE_RADIAL is not supported yet")
focal = (intrinsics[fidx][0, 0] + intrinsics[fidx][1, 1]) / 2
pycolmap_intri = np.array([focal, intrinsics[fidx][0, 2], intrinsics[fidx][1, 2], extra_params[fidx][0]])
else:
raise ValueError(f"Camera type {camera_type} is not supported yet")
return pycolmap_intri
+228
View File
@@ -0,0 +1,228 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import torch
import numpy as np
from .distortion import apply_distortion
def img_from_cam_np(
intrinsics: np.ndarray, points_cam: np.ndarray, extra_params: np.ndarray | None = None, default: float = 0.0
) -> np.ndarray:
"""
Apply intrinsics (and optional radial distortion) to camera-space points.
Args
----
intrinsics : (B,3,3) camera matrix K.
points_cam : (B,3,N) homogeneous camera coords (x, y, z)ᵀ.
extra_params: (B, N) or (B, k) distortion params (k = 1,2,4) or None.
default : value used for np.nan replacement.
Returns
-------
points2D : (B,N,2) pixel coordinates.
"""
# 1. perspective divide ───────────────────────────────────────
z = points_cam[:, 2:3, :] # (B,1,N)
points_cam_norm = points_cam / z # (B,3,N)
uv = points_cam_norm[:, :2, :] # (B,2,N)
# 2. optional distortion ──────────────────────────────────────
if extra_params is not None:
uu, vv = apply_distortion(extra_params, uv[:, 0], uv[:, 1])
uv = np.stack([uu, vv], axis=1) # (B,2,N)
# 3. homogeneous coords then K multiplication ─────────────────
ones = np.ones_like(uv[:, :1, :]) # (B,1,N)
points_cam_h = np.concatenate([uv, ones], axis=1) # (B,3,N)
# batched mat-mul: K · [u v 1]ᵀ
points2D_h = np.einsum("bij,bjk->bik", intrinsics, points_cam_h) # (B,3,N)
points2D = np.nan_to_num(points2D_h[:, :2, :], nan=default) # (B,2,N)
return points2D.transpose(0, 2, 1) # (B,N,2)
def project_3D_points_np(
points3D: np.ndarray,
extrinsics: np.ndarray,
intrinsics: np.ndarray | None = None,
extra_params: np.ndarray | None = None,
*,
default: float = 0.0,
only_points_cam: bool = False,
):
"""
NumPy clone of ``project_3D_points``.
Parameters
----------
points3D : (N,3) world-space points.
extrinsics : (B,3,4) [R|t] matrix for each of B cameras.
intrinsics : (B,3,3) K matrix (optional if you only need cam-space).
extra_params : (B,k) or (B,N) distortion parameters (k ∈ {1,2,4}) or None.
default : value used to replace NaNs.
only_points_cam : if True, skip the projection and return points_cam with points2D as None.
Returns
-------
(points2D, points_cam) : A tuple where points2D is (B,N,2) pixel coords or None if only_points_cam=True,
and points_cam is (B,3,N) camera-space coordinates.
"""
# ----- 0. prep sizes -----------------------------------------------------
N = points3D.shape[0] # #points
B = extrinsics.shape[0] # #cameras
# ----- 1. world → homogeneous -------------------------------------------
w_h = np.ones((N, 1), dtype=points3D.dtype)
points3D_h = np.concatenate([points3D, w_h], axis=1) # (N,4)
# broadcast to every camera (no actual copying with np.broadcast_to) ------
points3D_h_B = np.broadcast_to(points3D_h, (B, N, 4)) # (B,N,4)
# ----- 2. apply extrinsics (camera frame) ------------------------------
# X_cam = E · X_hom
# einsum: E_(b i j) · X_(b n j) → (b n i)
points_cam = np.einsum("bij,bnj->bni", extrinsics, points3D_h_B) # (B,N,3)
points_cam = points_cam.transpose(0, 2, 1) # (B,3,N)
if only_points_cam:
return None, points_cam
# ----- 3. intrinsics + distortion ---------------------------------------
if intrinsics is None:
raise ValueError("`intrinsics` must be provided unless only_points_cam=True")
points2D = img_from_cam_np(intrinsics, points_cam, extra_params=extra_params, default=default)
return points2D, points_cam
def project_3D_points(points3D, extrinsics, intrinsics=None, extra_params=None, default=0, only_points_cam=False):
"""
Transforms 3D points to 2D using extrinsic and intrinsic parameters.
Args:
points3D (torch.Tensor): 3D points of shape Px3.
extrinsics (torch.Tensor): Extrinsic parameters of shape Bx3x4.
intrinsics (torch.Tensor): Intrinsic parameters of shape Bx3x3.
extra_params (torch.Tensor): Extra parameters of shape BxN, used for radial distortion.
default (float): Default value to replace NaNs.
only_points_cam (bool): If True, skip the projection and return points2D as None.
Returns:
tuple: (points2D, points_cam) where points2D is of shape BxNx2 or None if only_points_cam=True,
and points_cam is of shape Bx3xN.
"""
with torch.cuda.amp.autocast(dtype=torch.double):
N = points3D.shape[0] # Number of points
B = extrinsics.shape[0] # Batch size, i.e., number of cameras
points3D_homogeneous = torch.cat([points3D, torch.ones_like(points3D[..., 0:1])], dim=1) # Nx4
# Reshape for batch processing
points3D_homogeneous = points3D_homogeneous.unsqueeze(0).expand(B, -1, -1) # BxNx4
# Step 1: Apply extrinsic parameters
# Transform 3D points to camera coordinate system for all cameras
points_cam = torch.bmm(extrinsics, points3D_homogeneous.transpose(-1, -2))
if only_points_cam:
return None, points_cam
# Step 2: Apply intrinsic parameters and (optional) distortion
points2D = img_from_cam(intrinsics, points_cam, extra_params, default)
return points2D, points_cam
def img_from_cam(intrinsics, points_cam, extra_params=None, default=0.0):
"""
Applies intrinsic parameters and optional distortion to the given 3D points.
Args:
intrinsics (torch.Tensor): Intrinsic camera parameters of shape Bx3x3.
points_cam (torch.Tensor): 3D points in camera coordinates of shape Bx3xN.
extra_params (torch.Tensor, optional): Distortion parameters of shape BxN, where N can be 1, 2, or 4.
default (float, optional): Default value to replace NaNs in the output.
Returns:
points2D (torch.Tensor): 2D points in pixel coordinates of shape BxNx2.
"""
# Normalize by the third coordinate (homogeneous division)
points_cam = points_cam / points_cam[:, 2:3, :]
# Extract uv
uv = points_cam[:, :2, :]
# Apply distortion if extra_params are provided
if extra_params is not None:
uu, vv = apply_distortion(extra_params, uv[:, 0], uv[:, 1])
uv = torch.stack([uu, vv], dim=1)
# Prepare points_cam for batch matrix multiplication
points_cam_homo = torch.cat((uv, torch.ones_like(uv[:, :1, :])), dim=1) # Bx3xN
# Apply intrinsic parameters using batch matrix multiplication
points2D_homo = torch.bmm(intrinsics, points_cam_homo) # Bx3xN
# Extract x and y coordinates
points2D = points2D_homo[:, :2, :] # Bx2xN
# Replace NaNs with default value
points2D = torch.nan_to_num(points2D, nan=default)
return points2D.transpose(1, 2) # BxNx2
if __name__ == "__main__":
# Set up example input
B, N = 24, 10240
for _ in range(100):
points3D = np.random.rand(N, 3).astype(np.float64)
extrinsics = np.random.rand(B, 3, 4).astype(np.float64)
intrinsics = np.random.rand(B, 3, 3).astype(np.float64)
# Convert to torch tensors
points3D_torch = torch.tensor(points3D)
extrinsics_torch = torch.tensor(extrinsics)
intrinsics_torch = torch.tensor(intrinsics)
# Run NumPy implementation
points2D_np, points_cam_np = project_3D_points_np(points3D, extrinsics, intrinsics)
# Run torch implementation
points2D_torch, points_cam_torch = project_3D_points(points3D_torch, extrinsics_torch, intrinsics_torch)
# Convert torch output to numpy
points2D_torch_np = points2D_torch.detach().numpy()
points_cam_torch_np = points_cam_torch.detach().numpy()
# Compute difference
diff = np.abs(points2D_np - points2D_torch_np)
print("Difference between NumPy and PyTorch implementations:")
print(diff)
# Check max error
max_diff = np.max(diff)
print(f"Maximum difference: {max_diff}")
if np.allclose(points2D_np, points2D_torch_np, atol=1e-6):
print("Implementations match closely.")
else:
print("Significant differences detected.")
if points_cam_np is not None:
points_cam_diff = np.abs(points_cam_np - points_cam_torch_np)
print("Difference between NumPy and PyTorch camera-space coordinates:")
print(points_cam_diff)
# Check max error
max_cam_diff = np.max(points_cam_diff)
print(f"Maximum camera-space coordinate difference: {max_cam_diff}")
if np.allclose(points_cam_np, points_cam_torch_np, atol=1e-6):
print("Camera-space coordinates match closely.")
else:
print("Significant differences detected in camera-space coordinates.")
@@ -0,0 +1,190 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import torch
import torch.nn as nn
from einops import rearrange, repeat
from .blocks import EfficientUpdateFormer, CorrBlock
from .utils import sample_features4d, get_2d_embedding, get_2d_sincos_pos_embed
class BaseTrackerPredictor(nn.Module):
def __init__(
self,
stride=4,
corr_levels=5,
corr_radius=4,
latent_dim=128,
hidden_size=384,
use_spaceatt=True,
depth=6,
fine=False,
):
super(BaseTrackerPredictor, self).__init__()
"""
The base template to create a track predictor
Modified from https://github.com/facebookresearch/co-tracker/
"""
self.stride = stride
self.latent_dim = latent_dim
self.corr_levels = corr_levels
self.corr_radius = corr_radius
self.hidden_size = hidden_size
self.fine = fine
self.flows_emb_dim = latent_dim // 2
self.transformer_dim = self.corr_levels * (self.corr_radius * 2 + 1) ** 2 + self.latent_dim * 2
if self.fine:
# TODO this is the old dummy code, will remove this when we train next model
self.transformer_dim += 4 if self.transformer_dim % 2 == 0 else 5
else:
self.transformer_dim += (4 - self.transformer_dim % 4) % 4
space_depth = depth if use_spaceatt else 0
time_depth = depth
self.updateformer = EfficientUpdateFormer(
space_depth=space_depth,
time_depth=time_depth,
input_dim=self.transformer_dim,
hidden_size=self.hidden_size,
output_dim=self.latent_dim + 2,
mlp_ratio=4.0,
add_space_attn=use_spaceatt,
)
self.norm = nn.GroupNorm(1, self.latent_dim)
# A linear layer to update track feats at each iteration
self.ffeat_updater = nn.Sequential(nn.Linear(self.latent_dim, self.latent_dim), nn.GELU())
if not self.fine:
self.vis_predictor = nn.Sequential(nn.Linear(self.latent_dim, 1))
def forward(self, query_points, fmaps=None, iters=4, return_feat=False, down_ratio=1):
"""
query_points: B x N x 2, the number of batches, tracks, and xy
fmaps: B x S x C x HH x WW, the number of batches, frames, and feature dimension.
note HH and WW is the size of feature maps instead of original images
"""
B, N, D = query_points.shape
B, S, C, HH, WW = fmaps.shape
assert D == 2
# Scale the input query_points because we may downsample the images
# by down_ratio or self.stride
# e.g., if a 3x1024x1024 image is processed to a 128x256x256 feature map
# its query_points should be query_points/4
if down_ratio > 1:
query_points = query_points / float(down_ratio)
query_points = query_points / float(self.stride)
# Init with coords as the query points
# It means the search will start from the position of query points at the reference frames
coords = query_points.clone().reshape(B, 1, N, 2).repeat(1, S, 1, 1)
# Sample/extract the features of the query points in the query frame
query_track_feat = sample_features4d(fmaps[:, 0], coords[:, 0])
# init track feats by query feats
track_feats = query_track_feat.unsqueeze(1).repeat(1, S, 1, 1) # B, S, N, C
# back up the init coords
coords_backup = coords.clone()
# Construct the correlation block
fcorr_fn = CorrBlock(fmaps, num_levels=self.corr_levels, radius=self.corr_radius)
coord_preds = []
# Iterative Refinement
for itr in range(iters):
# Detach the gradients from the last iteration
# (in my experience, not very important for performance)
coords = coords.detach()
# Compute the correlation (check the implementation of CorrBlock)
fcorr_fn.corr(track_feats)
fcorrs = fcorr_fn.sample(coords) # B, S, N, corrdim
corrdim = fcorrs.shape[3]
fcorrs_ = fcorrs.permute(0, 2, 1, 3).reshape(B * N, S, corrdim)
# Movement of current coords relative to query points
flows = (coords - coords[:, 0:1]).permute(0, 2, 1, 3).reshape(B * N, S, 2)
flows_emb = get_2d_embedding(flows, self.flows_emb_dim, cat_coords=False)
# (In my trials, it is also okay to just add the flows_emb instead of concat)
flows_emb = torch.cat([flows_emb, flows], dim=-1)
track_feats_ = track_feats.permute(0, 2, 1, 3).reshape(B * N, S, self.latent_dim)
# Concatenate them as the input for the transformers
transformer_input = torch.cat([flows_emb, fcorrs_, track_feats_], dim=2)
if transformer_input.shape[2] < self.transformer_dim:
# pad the features to match the dimension
pad_dim = self.transformer_dim - transformer_input.shape[2]
pad = torch.zeros_like(flows_emb[..., 0:pad_dim])
transformer_input = torch.cat([transformer_input, pad], dim=2)
# 2D positional embed
# TODO: this can be much simplified
pos_embed = get_2d_sincos_pos_embed(self.transformer_dim, grid_size=(HH, WW)).to(query_points.device)
sampled_pos_emb = sample_features4d(pos_embed.expand(B, -1, -1, -1), coords[:, 0])
sampled_pos_emb = rearrange(sampled_pos_emb, "b n c -> (b n) c").unsqueeze(1)
x = transformer_input + sampled_pos_emb
# B, N, S, C
x = rearrange(x, "(b n) s d -> b n s d", b=B)
# Compute the delta coordinates and delta track features
delta = self.updateformer(x)
# BN, S, C
delta = rearrange(delta, " b n s d -> (b n) s d", b=B)
delta_coords_ = delta[:, :, :2]
delta_feats_ = delta[:, :, 2:]
track_feats_ = track_feats_.reshape(B * N * S, self.latent_dim)
delta_feats_ = delta_feats_.reshape(B * N * S, self.latent_dim)
# Update the track features
track_feats_ = self.ffeat_updater(self.norm(delta_feats_)) + track_feats_
track_feats = track_feats_.reshape(B, N, S, self.latent_dim).permute(0, 2, 1, 3) # BxSxNxC
# B x S x N x 2
coords = coords + delta_coords_.reshape(B, N, S, 2).permute(0, 2, 1, 3)
# Force coord0 as query
# because we assume the query points should not be changed
coords[:, 0] = coords_backup[:, 0]
# The predicted tracks are in the original image scale
if down_ratio > 1:
coord_preds.append(coords * self.stride * down_ratio)
else:
coord_preds.append(coords * self.stride)
# B, S, N
if not self.fine:
vis_e = self.vis_predictor(track_feats.reshape(B * S * N, self.latent_dim)).reshape(B, S, N)
vis_e = torch.sigmoid(vis_e)
else:
vis_e = None
if return_feat:
return coord_preds, vis_e, track_feats, query_track_feat
else:
return coord_preds, vis_e
+329
View File
@@ -0,0 +1,329 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
# Modified from https://github.com/facebookresearch/co-tracker/
import torch
import torch.nn as nn
import torch.nn.functional as F
from functools import partial
from typing import Callable
import collections
from torch import Tensor
from itertools import repeat
from .utils import bilinear_sampler
from .modules import Mlp, AttnBlock, CrossAttnBlock, ResidualBlock
class BasicEncoder(nn.Module):
def __init__(self, input_dim=3, output_dim=128, stride=4):
super(BasicEncoder, self).__init__()
self.stride = stride
self.norm_fn = "instance"
self.in_planes = output_dim // 2
self.norm1 = nn.InstanceNorm2d(self.in_planes)
self.norm2 = nn.InstanceNorm2d(output_dim * 2)
self.conv1 = nn.Conv2d(input_dim, self.in_planes, kernel_size=7, stride=2, padding=3, padding_mode="zeros")
self.relu1 = nn.ReLU(inplace=True)
self.layer1 = self._make_layer(output_dim // 2, stride=1)
self.layer2 = self._make_layer(output_dim // 4 * 3, stride=2)
self.layer3 = self._make_layer(output_dim, stride=2)
self.layer4 = self._make_layer(output_dim, stride=2)
self.conv2 = nn.Conv2d(
output_dim * 3 + output_dim // 4, output_dim * 2, kernel_size=3, padding=1, padding_mode="zeros"
)
self.relu2 = nn.ReLU(inplace=True)
self.conv3 = nn.Conv2d(output_dim * 2, output_dim, kernel_size=1)
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
elif isinstance(m, (nn.InstanceNorm2d)):
if m.weight is not None:
nn.init.constant_(m.weight, 1)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def _make_layer(self, dim, stride=1):
layer1 = ResidualBlock(self.in_planes, dim, self.norm_fn, stride=stride)
layer2 = ResidualBlock(dim, dim, self.norm_fn, stride=1)
layers = (layer1, layer2)
self.in_planes = dim
return nn.Sequential(*layers)
def forward(self, x):
_, _, H, W = x.shape
x = self.conv1(x)
x = self.norm1(x)
x = self.relu1(x)
a = self.layer1(x)
b = self.layer2(a)
c = self.layer3(b)
d = self.layer4(c)
a = _bilinear_intepolate(a, self.stride, H, W)
b = _bilinear_intepolate(b, self.stride, H, W)
c = _bilinear_intepolate(c, self.stride, H, W)
d = _bilinear_intepolate(d, self.stride, H, W)
x = self.conv2(torch.cat([a, b, c, d], dim=1))
x = self.norm2(x)
x = self.relu2(x)
x = self.conv3(x)
return x
class ShallowEncoder(nn.Module):
def __init__(self, input_dim=3, output_dim=32, stride=1, norm_fn="instance"):
super(ShallowEncoder, self).__init__()
self.stride = stride
self.norm_fn = norm_fn
self.in_planes = output_dim
if self.norm_fn == "group":
self.norm1 = nn.GroupNorm(num_groups=8, num_channels=self.in_planes)
self.norm2 = nn.GroupNorm(num_groups=8, num_channels=output_dim * 2)
elif self.norm_fn == "batch":
self.norm1 = nn.BatchNorm2d(self.in_planes)
self.norm2 = nn.BatchNorm2d(output_dim * 2)
elif self.norm_fn == "instance":
self.norm1 = nn.InstanceNorm2d(self.in_planes)
self.norm2 = nn.InstanceNorm2d(output_dim * 2)
elif self.norm_fn == "none":
self.norm1 = nn.Sequential()
self.conv1 = nn.Conv2d(input_dim, self.in_planes, kernel_size=3, stride=2, padding=1, padding_mode="zeros")
self.relu1 = nn.ReLU(inplace=True)
self.layer1 = self._make_layer(output_dim, stride=2)
self.layer2 = self._make_layer(output_dim, stride=2)
self.conv2 = nn.Conv2d(output_dim, output_dim, kernel_size=1)
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
elif isinstance(m, (nn.BatchNorm2d, nn.InstanceNorm2d, nn.GroupNorm)):
if m.weight is not None:
nn.init.constant_(m.weight, 1)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def _make_layer(self, dim, stride=1):
self.in_planes = dim
layer1 = ResidualBlock(self.in_planes, dim, self.norm_fn, stride=stride)
return layer1
def forward(self, x):
_, _, H, W = x.shape
x = self.conv1(x)
x = self.norm1(x)
x = self.relu1(x)
tmp = self.layer1(x)
x = x + F.interpolate(tmp, (x.shape[-2:]), mode="bilinear", align_corners=True)
tmp = self.layer2(tmp)
x = x + F.interpolate(tmp, (x.shape[-2:]), mode="bilinear", align_corners=True)
tmp = None
x = self.conv2(x) + x
x = F.interpolate(x, (H // self.stride, W // self.stride), mode="bilinear", align_corners=True)
return x
def _bilinear_intepolate(x, stride, H, W):
return F.interpolate(x, (H // stride, W // stride), mode="bilinear", align_corners=True)
class EfficientUpdateFormer(nn.Module):
"""
Transformer model that updates track estimates.
"""
def __init__(
self,
space_depth=6,
time_depth=6,
input_dim=320,
hidden_size=384,
num_heads=8,
output_dim=130,
mlp_ratio=4.0,
add_space_attn=True,
num_virtual_tracks=64,
):
super().__init__()
self.out_channels = 2
self.num_heads = num_heads
self.hidden_size = hidden_size
self.add_space_attn = add_space_attn
self.input_transform = torch.nn.Linear(input_dim, hidden_size, bias=True)
self.flow_head = torch.nn.Linear(hidden_size, output_dim, bias=True)
self.num_virtual_tracks = num_virtual_tracks
if self.add_space_attn:
self.virual_tracks = nn.Parameter(torch.randn(1, num_virtual_tracks, 1, hidden_size))
else:
self.virual_tracks = None
self.time_blocks = nn.ModuleList(
[
AttnBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, attn_class=nn.MultiheadAttention)
for _ in range(time_depth)
]
)
if add_space_attn:
self.space_virtual_blocks = nn.ModuleList(
[
AttnBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, attn_class=nn.MultiheadAttention)
for _ in range(space_depth)
]
)
self.space_point2virtual_blocks = nn.ModuleList(
[CrossAttnBlock(hidden_size, hidden_size, num_heads, mlp_ratio=mlp_ratio) for _ in range(space_depth)]
)
self.space_virtual2point_blocks = nn.ModuleList(
[CrossAttnBlock(hidden_size, hidden_size, num_heads, mlp_ratio=mlp_ratio) for _ in range(space_depth)]
)
assert len(self.time_blocks) >= len(self.space_virtual2point_blocks)
self.initialize_weights()
def initialize_weights(self):
def _basic_init(module):
if isinstance(module, nn.Linear):
torch.nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.constant_(module.bias, 0)
def init_weights_vit_timm(module: nn.Module, name: str = ""):
"""ViT weight initialization, original timm impl (for reproducibility)"""
if isinstance(module, nn.Linear):
trunc_normal_(module.weight, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
def forward(self, input_tensor, mask=None):
tokens = self.input_transform(input_tensor)
init_tokens = tokens
B, _, T, _ = tokens.shape
if self.add_space_attn:
virtual_tokens = self.virual_tracks.repeat(B, 1, T, 1)
tokens = torch.cat([tokens, virtual_tokens], dim=1)
_, N, _, _ = tokens.shape
j = 0
for i in range(len(self.time_blocks)):
time_tokens = tokens.contiguous().view(B * N, T, -1) # B N T C -> (B N) T C
time_tokens = self.time_blocks[i](time_tokens)
tokens = time_tokens.view(B, N, T, -1) # (B N) T C -> B N T C
if self.add_space_attn and (i % (len(self.time_blocks) // len(self.space_virtual_blocks)) == 0):
space_tokens = tokens.permute(0, 2, 1, 3).contiguous().view(B * T, N, -1) # B N T C -> (B T) N C
point_tokens = space_tokens[:, : N - self.num_virtual_tracks]
virtual_tokens = space_tokens[:, N - self.num_virtual_tracks :]
virtual_tokens = self.space_virtual2point_blocks[j](virtual_tokens, point_tokens, mask=mask)
virtual_tokens = self.space_virtual_blocks[j](virtual_tokens)
point_tokens = self.space_point2virtual_blocks[j](point_tokens, virtual_tokens, mask=mask)
space_tokens = torch.cat([point_tokens, virtual_tokens], dim=1)
tokens = space_tokens.view(B, T, N, -1).permute(0, 2, 1, 3) # (B T) N C -> B N T C
j += 1
if self.add_space_attn:
tokens = tokens[:, : N - self.num_virtual_tracks]
tokens = tokens + init_tokens
flow = self.flow_head(tokens)
return flow
class CorrBlock:
def __init__(self, fmaps, num_levels=4, radius=4, multiple_track_feats=False, padding_mode="zeros"):
B, S, C, H, W = fmaps.shape
self.S, self.C, self.H, self.W = S, C, H, W
self.padding_mode = padding_mode
self.num_levels = num_levels
self.radius = radius
self.fmaps_pyramid = []
self.multiple_track_feats = multiple_track_feats
self.fmaps_pyramid.append(fmaps)
for i in range(self.num_levels - 1):
fmaps_ = fmaps.reshape(B * S, C, H, W)
fmaps_ = F.avg_pool2d(fmaps_, 2, stride=2)
_, _, H, W = fmaps_.shape
fmaps = fmaps_.reshape(B, S, C, H, W)
self.fmaps_pyramid.append(fmaps)
def sample(self, coords):
r = self.radius
B, S, N, D = coords.shape
assert D == 2
H, W = self.H, self.W
out_pyramid = []
for i in range(self.num_levels):
corrs = self.corrs_pyramid[i] # B, S, N, H, W
*_, H, W = corrs.shape
dx = torch.linspace(-r, r, 2 * r + 1)
dy = torch.linspace(-r, r, 2 * r + 1)
delta = torch.stack(torch.meshgrid(dy, dx, indexing="ij"), axis=-1).to(coords.device)
centroid_lvl = coords.reshape(B * S * N, 1, 1, 2) / 2**i
delta_lvl = delta.view(1, 2 * r + 1, 2 * r + 1, 2)
coords_lvl = centroid_lvl + delta_lvl
corrs = bilinear_sampler(corrs.reshape(B * S * N, 1, H, W), coords_lvl, padding_mode=self.padding_mode)
corrs = corrs.view(B, S, N, -1)
out_pyramid.append(corrs)
out = torch.cat(out_pyramid, dim=-1).contiguous() # B, S, N, LRR*2
return out
def corr(self, targets):
B, S, N, C = targets.shape
if self.multiple_track_feats:
targets_split = targets.split(C // self.num_levels, dim=-1)
B, S, N, C = targets_split[0].shape
assert C == self.C
assert S == self.S
fmap1 = targets
self.corrs_pyramid = []
for i, fmaps in enumerate(self.fmaps_pyramid):
*_, H, W = fmaps.shape
fmap2s = fmaps.view(B, S, C, H * W) # B S C H W -> B S C (H W)
if self.multiple_track_feats:
fmap1 = targets_split[i]
corrs = torch.matmul(fmap1, fmap2s)
corrs = corrs.view(B, S, N, H, W) # B S N (H W) -> B S N H W
corrs = corrs / torch.sqrt(torch.tensor(C).float())
self.corrs_pyramid.append(corrs)
+202
View File
@@ -0,0 +1,202 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import torch
import torch.nn as nn
import torch.nn.functional as F
from functools import partial
from typing import Callable
import collections
from torch import Tensor
from itertools import repeat
# From PyTorch internals
def _ntuple(n):
def parse(x):
if isinstance(x, collections.abc.Iterable) and not isinstance(x, str):
return tuple(x)
return tuple(repeat(x, n))
return parse
def exists(val):
return val is not None
def default(val, d):
return val if exists(val) else d
to_2tuple = _ntuple(2)
class ResidualBlock(nn.Module):
"""
ResidualBlock: construct a block of two conv layers with residual connections
"""
def __init__(self, in_planes, planes, norm_fn="group", stride=1, kernel_size=3):
super(ResidualBlock, self).__init__()
self.conv1 = nn.Conv2d(
in_planes, planes, kernel_size=kernel_size, padding=1, stride=stride, padding_mode="zeros"
)
self.conv2 = nn.Conv2d(planes, planes, kernel_size=kernel_size, padding=1, padding_mode="zeros")
self.relu = nn.ReLU(inplace=True)
num_groups = planes // 8
if norm_fn == "group":
self.norm1 = nn.GroupNorm(num_groups=num_groups, num_channels=planes)
self.norm2 = nn.GroupNorm(num_groups=num_groups, num_channels=planes)
if not stride == 1:
self.norm3 = nn.GroupNorm(num_groups=num_groups, num_channels=planes)
elif norm_fn == "batch":
self.norm1 = nn.BatchNorm2d(planes)
self.norm2 = nn.BatchNorm2d(planes)
if not stride == 1:
self.norm3 = nn.BatchNorm2d(planes)
elif norm_fn == "instance":
self.norm1 = nn.InstanceNorm2d(planes)
self.norm2 = nn.InstanceNorm2d(planes)
if not stride == 1:
self.norm3 = nn.InstanceNorm2d(planes)
elif norm_fn == "none":
self.norm1 = nn.Sequential()
self.norm2 = nn.Sequential()
if not stride == 1:
self.norm3 = nn.Sequential()
else:
raise NotImplementedError
if stride == 1:
self.downsample = None
else:
self.downsample = nn.Sequential(nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride), self.norm3)
def forward(self, x):
y = x
y = self.relu(self.norm1(self.conv1(y)))
y = self.relu(self.norm2(self.conv2(y)))
if self.downsample is not None:
x = self.downsample(x)
return self.relu(x + y)
class Mlp(nn.Module):
"""MLP as used in Vision Transformer, MLP-Mixer and related networks"""
def __init__(
self,
in_features,
hidden_features=None,
out_features=None,
act_layer=nn.GELU,
norm_layer=None,
bias=True,
drop=0.0,
use_conv=False,
):
super().__init__()
out_features = out_features or in_features
hidden_features = hidden_features or in_features
bias = to_2tuple(bias)
drop_probs = to_2tuple(drop)
linear_layer = partial(nn.Conv2d, kernel_size=1) if use_conv else nn.Linear
self.fc1 = linear_layer(in_features, hidden_features, bias=bias[0])
self.act = act_layer()
self.drop1 = nn.Dropout(drop_probs[0])
self.fc2 = linear_layer(hidden_features, out_features, bias=bias[1])
self.drop2 = nn.Dropout(drop_probs[1])
def forward(self, x):
x = self.fc1(x)
x = self.act(x)
x = self.drop1(x)
x = self.fc2(x)
x = self.drop2(x)
return x
class AttnBlock(nn.Module):
def __init__(
self,
hidden_size,
num_heads,
attn_class: Callable[..., nn.Module] = nn.MultiheadAttention,
mlp_ratio=4.0,
**block_kwargs,
):
"""
Self attention block
"""
super().__init__()
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.attn = attn_class(embed_dim=hidden_size, num_heads=num_heads, batch_first=True, **block_kwargs)
mlp_hidden_dim = int(hidden_size * mlp_ratio)
self.mlp = Mlp(in_features=hidden_size, hidden_features=mlp_hidden_dim, drop=0)
def forward(self, x, mask=None):
# Prepare the mask for PyTorch's attention (it expects a different format)
# attn_mask = mask if mask is not None else None
# Normalize before attention
x = self.norm1(x)
# PyTorch's MultiheadAttention returns attn_output, attn_output_weights
# attn_output, _ = self.attn(x, x, x, attn_mask=attn_mask)
attn_output, _ = self.attn(x, x, x)
# Add & Norm
x = x + attn_output
x = x + self.mlp(self.norm2(x))
return x
class CrossAttnBlock(nn.Module):
def __init__(self, hidden_size, context_dim, num_heads=1, mlp_ratio=4.0, **block_kwargs):
"""
Cross attention block
"""
super().__init__()
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.norm_context = nn.LayerNorm(hidden_size)
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
self.cross_attn = nn.MultiheadAttention(
embed_dim=hidden_size, num_heads=num_heads, batch_first=True, **block_kwargs
)
mlp_hidden_dim = int(hidden_size * mlp_ratio)
self.mlp = Mlp(in_features=hidden_size, hidden_features=mlp_hidden_dim, drop=0)
def forward(self, x, context, mask=None):
# Normalize inputs
x = self.norm1(x)
context = self.norm_context(context)
# Apply cross attention
# Note: nn.MultiheadAttention returns attn_output, attn_output_weights
attn_output, _ = self.cross_attn(x, context, context, attn_mask=mask)
# Add & Norm
x = x + attn_output
x = x + self.mlp(self.norm2(x))
return x
@@ -0,0 +1,419 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from functools import partial
from torch import nn, einsum
from einops import rearrange, repeat
from einops.layers.torch import Rearrange, Reduce
from PIL import Image
import os
from typing import Union, Tuple
def refine_track(
images, fine_fnet, fine_tracker, coarse_pred, compute_score=False, pradius=15, sradius=2, fine_iters=6, chunk=40960
):
"""
Refines the tracking of images using a fine track predictor and a fine feature network.
Check https://arxiv.org/abs/2312.04563 for more details.
Args:
images (torch.Tensor): The images to be tracked.
fine_fnet (nn.Module): The fine feature network.
fine_tracker (nn.Module): The fine track predictor.
coarse_pred (torch.Tensor): The coarse predictions of tracks.
compute_score (bool, optional): Whether to compute the score. Defaults to False.
pradius (int, optional): The radius of a patch. Defaults to 15.
sradius (int, optional): The search radius. Defaults to 2.
Returns:
torch.Tensor: The refined tracks.
torch.Tensor, optional: The score.
"""
# coarse_pred shape: BxSxNx2,
# where B is the batch, S is the video/images length, and N is the number of tracks
# now we are going to extract patches with the center at coarse_pred
# Please note that the last dimension indicates x and y, and hence has a dim number of 2
B, S, N, _ = coarse_pred.shape
_, _, _, H, W = images.shape
# Given the raidus of a patch, compute the patch size
psize = pradius * 2 + 1
# Note that we assume the first frame is the query frame
# so the 2D locations of the first frame are the query points
query_points = coarse_pred[:, 0]
# Given 2D positions, we can use grid_sample to extract patches
# but it takes too much memory.
# Instead, we use the floored track xy to sample patches.
# For example, if the query point xy is (128.16, 252.78),
# and the patch size is (31, 31),
# our goal is to extract the content of a rectangle
# with left top: (113.16, 237.78)
# and right bottom: (143.16, 267.78).
# However, we record the floored left top: (113, 237)
# and the offset (0.16, 0.78)
# Then what we need is just unfolding the images like in CNN,
# picking the content at [(113, 237), (143, 267)].
# Such operations are highly optimized at pytorch
# (well if you really want to use interpolation, check the function extract_glimpse() below)
with torch.no_grad():
content_to_extract = images.reshape(B * S, 3, H, W)
C_in = content_to_extract.shape[1]
# Please refer to https://pytorch.org/docs/stable/generated/torch.nn.Unfold.html
# for the detailed explanation of unfold()
# Here it runs sliding windows (psize x psize) to build patches
# The shape changes from
# (B*S)x C_in x H x W to (B*S)x C_in x H_new x W_new x Psize x Psize
# where Psize is the size of patch
content_to_extract = content_to_extract.unfold(2, psize, 1).unfold(3, psize, 1)
# Floor the coarse predictions to get integers and save the fractional/decimal
track_int = coarse_pred.floor().int()
track_frac = coarse_pred - track_int
# Note the points represent the center of patches
# now we get the location of the top left corner of patches
# because the ouput of pytorch unfold are indexed by top left corner
topleft = track_int - pradius
topleft_BSN = topleft.clone()
# clamp the values so that we will not go out of indexes
# NOTE: (VERY IMPORTANT: This operation ASSUMES H=W).
# You need to seperately clamp x and y if H!=W
topleft = topleft.clamp(0, H - psize)
# Reshape from BxSxNx2 -> (B*S)xNx2
topleft = topleft.reshape(B * S, N, 2)
# Prepare batches for indexing, shape: (B*S)xN
batch_indices = torch.arange(B * S)[:, None].expand(-1, N).to(content_to_extract.device)
# extracted_patches: (B*S) x N x C_in x Psize x Psize
extracted_patches = content_to_extract[batch_indices, :, topleft[..., 1], topleft[..., 0]]
if chunk < 0:
# Extract image patches based on top left corners
# Feed patches to fine fent for features
patch_feat = fine_fnet(extracted_patches.reshape(B * S * N, C_in, psize, psize))
else:
patches = extracted_patches.reshape(B * S * N, C_in, psize, psize)
patch_feat_list = []
for p in torch.split(patches, chunk):
patch_feat_list += [fine_fnet(p)]
patch_feat = torch.cat(patch_feat_list, 0)
C_out = patch_feat.shape[1]
# Refine the coarse tracks by fine_tracker
# reshape back to B x S x N x C_out x Psize x Psize
patch_feat = patch_feat.reshape(B, S, N, C_out, psize, psize)
patch_feat = rearrange(patch_feat, "b s n c p q -> (b n) s c p q")
# Prepare for the query points for fine tracker
# They are relative to the patch left top corner,
# instead of the image top left corner now
# patch_query_points: N x 1 x 2
# only 1 here because for each patch we only have 1 query point
patch_query_points = track_frac[:, 0] + pradius
patch_query_points = patch_query_points.reshape(B * N, 2).unsqueeze(1)
# Feed the PATCH query points and tracks into fine tracker
fine_pred_track_lists, _, _, query_point_feat = fine_tracker(
query_points=patch_query_points, fmaps=patch_feat, iters=fine_iters, return_feat=True
)
# relative the patch top left
fine_pred_track = fine_pred_track_lists[-1].clone()
# From (relative to the patch top left) to (relative to the image top left)
for idx in range(len(fine_pred_track_lists)):
fine_level = rearrange(fine_pred_track_lists[idx], "(b n) s u v -> b s n u v", b=B, n=N)
fine_level = fine_level.squeeze(-2)
fine_level = fine_level + topleft_BSN
fine_pred_track_lists[idx] = fine_level
# relative to the image top left
refined_tracks = fine_pred_track_lists[-1].clone()
refined_tracks[:, 0] = query_points
score = None
if compute_score:
score = compute_score_fn(query_point_feat, patch_feat, fine_pred_track, sradius, psize, B, N, S, C_out)
return refined_tracks, score
def refine_track_v0(
images, fine_fnet, fine_tracker, coarse_pred, compute_score=False, pradius=15, sradius=2, fine_iters=6
):
"""
COPIED FROM VGGSfM
Refines the tracking of images using a fine track predictor and a fine feature network.
Check https://arxiv.org/abs/2312.04563 for more details.
Args:
images (torch.Tensor): The images to be tracked.
fine_fnet (nn.Module): The fine feature network.
fine_tracker (nn.Module): The fine track predictor.
coarse_pred (torch.Tensor): The coarse predictions of tracks.
compute_score (bool, optional): Whether to compute the score. Defaults to False.
pradius (int, optional): The radius of a patch. Defaults to 15.
sradius (int, optional): The search radius. Defaults to 2.
Returns:
torch.Tensor: The refined tracks.
torch.Tensor, optional: The score.
"""
# coarse_pred shape: BxSxNx2,
# where B is the batch, S is the video/images length, and N is the number of tracks
# now we are going to extract patches with the center at coarse_pred
# Please note that the last dimension indicates x and y, and hence has a dim number of 2
B, S, N, _ = coarse_pred.shape
_, _, _, H, W = images.shape
# Given the raidus of a patch, compute the patch size
psize = pradius * 2 + 1
# Note that we assume the first frame is the query frame
# so the 2D locations of the first frame are the query points
query_points = coarse_pred[:, 0]
# Given 2D positions, we can use grid_sample to extract patches
# but it takes too much memory.
# Instead, we use the floored track xy to sample patches.
# For example, if the query point xy is (128.16, 252.78),
# and the patch size is (31, 31),
# our goal is to extract the content of a rectangle
# with left top: (113.16, 237.78)
# and right bottom: (143.16, 267.78).
# However, we record the floored left top: (113, 237)
# and the offset (0.16, 0.78)
# Then what we need is just unfolding the images like in CNN,
# picking the content at [(113, 237), (143, 267)].
# Such operations are highly optimized at pytorch
# (well if you really want to use interpolation, check the function extract_glimpse() below)
with torch.no_grad():
content_to_extract = images.reshape(B * S, 3, H, W)
C_in = content_to_extract.shape[1]
# Please refer to https://pytorch.org/docs/stable/generated/torch.nn.Unfold.html
# for the detailed explanation of unfold()
# Here it runs sliding windows (psize x psize) to build patches
# The shape changes from
# (B*S)x C_in x H x W to (B*S)x C_in x H_new x W_new x Psize x Psize
# where Psize is the size of patch
content_to_extract = content_to_extract.unfold(2, psize, 1).unfold(3, psize, 1)
# Floor the coarse predictions to get integers and save the fractional/decimal
track_int = coarse_pred.floor().int()
track_frac = coarse_pred - track_int
# Note the points represent the center of patches
# now we get the location of the top left corner of patches
# because the ouput of pytorch unfold are indexed by top left corner
topleft = track_int - pradius
topleft_BSN = topleft.clone()
# clamp the values so that we will not go out of indexes
# NOTE: (VERY IMPORTANT: This operation ASSUMES H=W).
# You need to seperately clamp x and y if H!=W
topleft = topleft.clamp(0, H - psize)
# Reshape from BxSxNx2 -> (B*S)xNx2
topleft = topleft.reshape(B * S, N, 2)
# Prepare batches for indexing, shape: (B*S)xN
batch_indices = torch.arange(B * S)[:, None].expand(-1, N).to(content_to_extract.device)
# Extract image patches based on top left corners
# extracted_patches: (B*S) x N x C_in x Psize x Psize
extracted_patches = content_to_extract[batch_indices, :, topleft[..., 1], topleft[..., 0]]
# Feed patches to fine fent for features
patch_feat = fine_fnet(extracted_patches.reshape(B * S * N, C_in, psize, psize))
C_out = patch_feat.shape[1]
# Refine the coarse tracks by fine_tracker
# reshape back to B x S x N x C_out x Psize x Psize
patch_feat = patch_feat.reshape(B, S, N, C_out, psize, psize)
patch_feat = rearrange(patch_feat, "b s n c p q -> (b n) s c p q")
# Prepare for the query points for fine tracker
# They are relative to the patch left top corner,
# instead of the image top left corner now
# patch_query_points: N x 1 x 2
# only 1 here because for each patch we only have 1 query point
patch_query_points = track_frac[:, 0] + pradius
patch_query_points = patch_query_points.reshape(B * N, 2).unsqueeze(1)
# Feed the PATCH query points and tracks into fine tracker
fine_pred_track_lists, _, _, query_point_feat = fine_tracker(
query_points=patch_query_points, fmaps=patch_feat, iters=fine_iters, return_feat=True
)
# relative the patch top left
fine_pred_track = fine_pred_track_lists[-1].clone()
# From (relative to the patch top left) to (relative to the image top left)
for idx in range(len(fine_pred_track_lists)):
fine_level = rearrange(fine_pred_track_lists[idx], "(b n) s u v -> b s n u v", b=B, n=N)
fine_level = fine_level.squeeze(-2)
fine_level = fine_level + topleft_BSN
fine_pred_track_lists[idx] = fine_level
# relative to the image top left
refined_tracks = fine_pred_track_lists[-1].clone()
refined_tracks[:, 0] = query_points
score = None
if compute_score:
score = compute_score_fn(query_point_feat, patch_feat, fine_pred_track, sradius, psize, B, N, S, C_out)
return refined_tracks, score
################################## NOTE: NOT USED ##################################
def compute_score_fn(query_point_feat, patch_feat, fine_pred_track, sradius, psize, B, N, S, C_out):
"""
Compute the scores, i.e., the standard deviation of the 2D similarity heatmaps,
given the query point features and reference frame feature maps
"""
from kornia.utils.grid import create_meshgrid
from kornia.geometry.subpix import dsnt
# query_point_feat initial shape: B x N x C_out,
# query_point_feat indicates the feat at the coorponsing query points
# Therefore we don't have S dimension here
query_point_feat = query_point_feat.reshape(B, N, C_out)
# reshape and expand to B x (S-1) x N x C_out
query_point_feat = query_point_feat.unsqueeze(1).expand(-1, S - 1, -1, -1)
# and reshape to (B*(S-1)*N) x C_out
query_point_feat = query_point_feat.reshape(B * (S - 1) * N, C_out)
# Radius and size for computing the score
ssize = sradius * 2 + 1
# Reshape, you know it, so many reshaping operations
patch_feat = rearrange(patch_feat, "(b n) s c p q -> b s n c p q", b=B, n=N)
# Again, we unfold the patches to smaller patches
# so that we can then focus on smaller patches
# patch_feat_unfold shape:
# B x S x N x C_out x (psize - 2*sradius) x (psize - 2*sradius) x ssize x ssize
# well a bit scary, but actually not
patch_feat_unfold = patch_feat.unfold(4, ssize, 1).unfold(5, ssize, 1)
# Do the same stuffs above, i.e., the same as extracting patches
fine_prediction_floor = fine_pred_track.floor().int()
fine_level_floor_topleft = fine_prediction_floor - sradius
# Clamp to ensure the smaller patch is valid
fine_level_floor_topleft = fine_level_floor_topleft.clamp(0, psize - ssize)
fine_level_floor_topleft = fine_level_floor_topleft.squeeze(2)
# Prepare the batch indices and xy locations
batch_indices_score = torch.arange(B)[:, None, None].expand(-1, S, N) # BxSxN
batch_indices_score = batch_indices_score.reshape(-1).to(patch_feat_unfold.device) # B*S*N
y_indices = fine_level_floor_topleft[..., 0].flatten() # Flatten H indices
x_indices = fine_level_floor_topleft[..., 1].flatten() # Flatten W indices
reference_frame_feat = patch_feat_unfold.reshape(
B * S * N, C_out, psize - sradius * 2, psize - sradius * 2, ssize, ssize
)
# Note again, according to pytorch convention
# x_indices cooresponds to [..., 1] and y_indices cooresponds to [..., 0]
reference_frame_feat = reference_frame_feat[batch_indices_score, :, x_indices, y_indices]
reference_frame_feat = reference_frame_feat.reshape(B, S, N, C_out, ssize, ssize)
# pick the frames other than the first one, so we have S-1 frames here
reference_frame_feat = reference_frame_feat[:, 1:].reshape(B * (S - 1) * N, C_out, ssize * ssize)
# Compute similarity
sim_matrix = torch.einsum("mc,mcr->mr", query_point_feat, reference_frame_feat)
softmax_temp = 1.0 / C_out**0.5
heatmap = torch.softmax(softmax_temp * sim_matrix, dim=1)
# 2D heatmaps
heatmap = heatmap.reshape(B * (S - 1) * N, ssize, ssize) # * x ssize x ssize
coords_normalized = dsnt.spatial_expectation2d(heatmap[None], True)[0]
grid_normalized = create_meshgrid(ssize, ssize, normalized_coordinates=True, device=heatmap.device).reshape(
1, -1, 2
)
var = torch.sum(grid_normalized**2 * heatmap.view(-1, ssize * ssize, 1), dim=1) - coords_normalized**2
std = torch.sum(torch.sqrt(torch.clamp(var, min=1e-10)), -1) # clamp needed for numerical stability
score = std.reshape(B, S - 1, N)
# set score as 1 for the query frame
score = torch.cat([torch.ones_like(score[:, 0:1]), score], dim=1)
return score
def extract_glimpse(
tensor: torch.Tensor, size: Tuple[int, int], offsets, mode="bilinear", padding_mode="zeros", debug=False, orib=None
):
B, C, W, H = tensor.shape
h, w = size
xs = torch.arange(0, w, dtype=tensor.dtype, device=tensor.device) - (w - 1) / 2.0
ys = torch.arange(0, h, dtype=tensor.dtype, device=tensor.device) - (h - 1) / 2.0
vy, vx = torch.meshgrid(ys, xs)
grid = torch.stack([vx, vy], dim=-1) # h, w, 2
grid = grid[None]
B, N, _ = offsets.shape
offsets = offsets.reshape((B * N), 1, 1, 2)
offsets_grid = offsets + grid
# normalised grid to [-1, 1]
offsets_grid = (offsets_grid - offsets_grid.new_tensor([W / 2, H / 2])) / offsets_grid.new_tensor([W / 2, H / 2])
# BxCxHxW -> Bx1xCxHxW
tensor = tensor[:, None]
# Bx1xCxHxW -> BxNxCxHxW
tensor = tensor.expand(-1, N, -1, -1, -1)
# BxNxCxHxW -> (B*N)xCxHxW
tensor = tensor.reshape((B * N), C, W, H)
sampled = torch.nn.functional.grid_sample(
tensor, offsets_grid, mode=mode, align_corners=False, padding_mode=padding_mode
)
# NOTE: I am not sure it should be h, w or w, h here
# but okay for sqaures
sampled = sampled.reshape(B, N, C, h, w)
return sampled
+216
View File
@@ -0,0 +1,216 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
# Modified from https://github.com/facebookresearch/PoseDiffusion
# and https://github.com/facebookresearch/co-tracker/tree/main
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Optional, Tuple, Union
from einops import rearrange, repeat
def get_2d_sincos_pos_embed(embed_dim: int, grid_size: Union[int, Tuple[int, int]], return_grid=False) -> torch.Tensor:
"""
This function initializes a grid and generates a 2D positional embedding using sine and cosine functions.
It is a wrapper of get_2d_sincos_pos_embed_from_grid.
Args:
- embed_dim: The embedding dimension.
- grid_size: The grid size.
Returns:
- pos_embed: The generated 2D positional embedding.
"""
if isinstance(grid_size, tuple):
grid_size_h, grid_size_w = grid_size
else:
grid_size_h = grid_size_w = grid_size
grid_h = torch.arange(grid_size_h, dtype=torch.float)
grid_w = torch.arange(grid_size_w, dtype=torch.float)
grid = torch.meshgrid(grid_w, grid_h, indexing="xy")
grid = torch.stack(grid, dim=0)
grid = grid.reshape([2, 1, grid_size_h, grid_size_w])
pos_embed = get_2d_sincos_pos_embed_from_grid(embed_dim, grid)
if return_grid:
return (pos_embed.reshape(1, grid_size_h, grid_size_w, -1).permute(0, 3, 1, 2), grid)
return pos_embed.reshape(1, grid_size_h, grid_size_w, -1).permute(0, 3, 1, 2)
def get_2d_sincos_pos_embed_from_grid(embed_dim: int, grid: torch.Tensor) -> torch.Tensor:
"""
This function generates a 2D positional embedding from a given grid using sine and cosine functions.
Args:
- embed_dim: The embedding dimension.
- grid: The grid to generate the embedding from.
Returns:
- emb: The generated 2D positional embedding.
"""
assert embed_dim % 2 == 0
# use half of dimensions to encode grid_h
emb_h = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[0]) # (H*W, D/2)
emb_w = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[1]) # (H*W, D/2)
emb = torch.cat([emb_h, emb_w], dim=2) # (H*W, D)
return emb
def get_1d_sincos_pos_embed_from_grid(embed_dim: int, pos: torch.Tensor) -> torch.Tensor:
"""
This function generates a 1D positional embedding from a given grid using sine and cosine functions.
Args:
- embed_dim: The embedding dimension.
- pos: The position to generate the embedding from.
Returns:
- emb: The generated 1D positional embedding.
"""
assert embed_dim % 2 == 0
omega = torch.arange(embed_dim // 2, dtype=torch.double)
omega /= embed_dim / 2.0
omega = 1.0 / 10000**omega # (D/2,)
pos = pos.reshape(-1) # (M,)
out = torch.einsum("m,d->md", pos, omega) # (M, D/2), outer product
emb_sin = torch.sin(out) # (M, D/2)
emb_cos = torch.cos(out) # (M, D/2)
emb = torch.cat([emb_sin, emb_cos], dim=1) # (M, D)
return emb[None].float()
def get_2d_embedding(xy: torch.Tensor, C: int, cat_coords: bool = True) -> torch.Tensor:
"""
This function generates a 2D positional embedding from given coordinates using sine and cosine functions.
Args:
- xy: The coordinates to generate the embedding from.
- C: The size of the embedding.
- cat_coords: A flag to indicate whether to concatenate the original coordinates to the embedding.
Returns:
- pe: The generated 2D positional embedding.
"""
B, N, D = xy.shape
assert D == 2
x = xy[:, :, 0:1]
y = xy[:, :, 1:2]
div_term = (torch.arange(0, C, 2, device=xy.device, dtype=torch.float32) * (1000.0 / C)).reshape(1, 1, int(C / 2))
pe_x = torch.zeros(B, N, C, device=xy.device, dtype=torch.float32)
pe_y = torch.zeros(B, N, C, device=xy.device, dtype=torch.float32)
pe_x[:, :, 0::2] = torch.sin(x * div_term)
pe_x[:, :, 1::2] = torch.cos(x * div_term)
pe_y[:, :, 0::2] = torch.sin(y * div_term)
pe_y[:, :, 1::2] = torch.cos(y * div_term)
pe = torch.cat([pe_x, pe_y], dim=2) # (B, N, C*3)
if cat_coords:
pe = torch.cat([xy, pe], dim=2) # (B, N, C*3+3)
return pe
def bilinear_sampler(input, coords, align_corners=True, padding_mode="border"):
r"""Sample a tensor using bilinear interpolation
`bilinear_sampler(input, coords)` samples a tensor :attr:`input` at
coordinates :attr:`coords` using bilinear interpolation. It is the same
as `torch.nn.functional.grid_sample()` but with a different coordinate
convention.
The input tensor is assumed to be of shape :math:`(B, C, H, W)`, where
:math:`B` is the batch size, :math:`C` is the number of channels,
:math:`H` is the height of the image, and :math:`W` is the width of the
image. The tensor :attr:`coords` of shape :math:`(B, H_o, W_o, 2)` is
interpreted as an array of 2D point coordinates :math:`(x_i,y_i)`.
Alternatively, the input tensor can be of size :math:`(B, C, T, H, W)`,
in which case sample points are triplets :math:`(t_i,x_i,y_i)`. Note
that in this case the order of the components is slightly different
from `grid_sample()`, which would expect :math:`(x_i,y_i,t_i)`.
If `align_corners` is `True`, the coordinate :math:`x` is assumed to be
in the range :math:`[0,W-1]`, with 0 corresponding to the center of the
left-most image pixel :math:`W-1` to the center of the right-most
pixel.
If `align_corners` is `False`, the coordinate :math:`x` is assumed to
be in the range :math:`[0,W]`, with 0 corresponding to the left edge of
the left-most pixel :math:`W` to the right edge of the right-most
pixel.
Similar conventions apply to the :math:`y` for the range
:math:`[0,H-1]` and :math:`[0,H]` and to :math:`t` for the range
:math:`[0,T-1]` and :math:`[0,T]`.
Args:
input (Tensor): batch of input images.
coords (Tensor): batch of coordinates.
align_corners (bool, optional): Coordinate convention. Defaults to `True`.
padding_mode (str, optional): Padding mode. Defaults to `"border"`.
Returns:
Tensor: sampled points.
"""
sizes = input.shape[2:]
assert len(sizes) in [2, 3]
if len(sizes) == 3:
# t x y -> x y t to match dimensions T H W in grid_sample
coords = coords[..., [1, 2, 0]]
if align_corners:
coords = coords * torch.tensor([2 / max(size - 1, 1) for size in reversed(sizes)], device=coords.device)
else:
coords = coords * torch.tensor([2 / size for size in reversed(sizes)], device=coords.device)
coords -= 1
return F.grid_sample(input, coords, align_corners=align_corners, padding_mode=padding_mode)
def sample_features4d(input, coords):
r"""Sample spatial features
`sample_features4d(input, coords)` samples the spatial features
:attr:`input` represented by a 4D tensor :math:`(B, C, H, W)`.
The field is sampled at coordinates :attr:`coords` using bilinear
interpolation. :attr:`coords` is assumed to be of shape :math:`(B, R,
2)`, where each sample has the format :math:`(x_i, y_i)`. This uses the
same convention as :func:`bilinear_sampler` with `align_corners=True`.
The output tensor has one feature per point, and has shape :math:`(B,
R, C)`.
Args:
input (Tensor): spatial features.
coords (Tensor): points.
Returns:
Tensor: sampled features.
"""
B, _, _, _ = input.shape
# B R 2 -> B R 1 2
coords = coords.unsqueeze(2)
# B C R 1
feats = bilinear_sampler(input, coords)
return feats.permute(0, 2, 1, 3).view(B, -1, feats.shape[1] * feats.shape[3]) # B C R 1 -> B R C
+326
View File
@@ -0,0 +1,326 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import torch
import numpy as np
from .vggsfm_utils import *
def predict_tracks(
images,
conf=None,
points_3d=None,
masks=None,
max_query_pts=2048,
query_frame_num=5,
keypoint_extractor="aliked+sp",
max_points_num=163840,
fine_tracking=True,
complete_non_vis=True,
):
"""
Predict tracks for the given images and masks.
TODO: support non-square images
TODO: support masks
This function predicts the tracks for the given images and masks using the specified query method
and track predictor. It finds query points, and predicts the tracks, visibility, and scores for the query frames.
Args:
images: Tensor of shape [S, 3, H, W] containing the input images.
conf: Tensor of shape [S, 1, H, W] containing the confidence scores. Default is None.
points_3d: Tensor containing 3D points. Default is None.
masks: Optional tensor of shape [S, 1, H, W] containing masks. Default is None.
max_query_pts: Maximum number of query points. Default is 2048.
query_frame_num: Number of query frames to use. Default is 5.
keypoint_extractor: Method for keypoint extraction. Default is "aliked+sp".
max_points_num: Maximum number of points to process at once. Default is 163840.
fine_tracking: Whether to use fine tracking. Default is True.
complete_non_vis: Whether to augment non-visible frames. Default is True.
Returns:
pred_tracks: Numpy array containing the predicted tracks.
pred_vis_scores: Numpy array containing the visibility scores for the tracks.
pred_confs: Numpy array containing the confidence scores for the tracks.
pred_points_3d: Numpy array containing the 3D points for the tracks.
pred_colors: Numpy array containing the point colors for the tracks. (0, 255)
"""
device = images.device
dtype = images.dtype
tracker = build_vggsfm_tracker().to(device, dtype)
# Find query frames
query_frame_indexes = generate_rank_by_dino(images, query_frame_num=query_frame_num, device=device)
# Add the first image to the front if not already present
if 0 in query_frame_indexes:
query_frame_indexes.remove(0)
query_frame_indexes = [0, *query_frame_indexes]
# TODO: add the functionality to handle the masks
keypoint_extractors = initialize_feature_extractors(
max_query_pts, extractor_method=keypoint_extractor, device=device
)
pred_tracks = []
pred_vis_scores = []
pred_confs = []
pred_points_3d = []
pred_colors = []
fmaps_for_tracker = tracker.process_images_to_fmaps(images)
if fine_tracking:
print("For faster inference, consider disabling fine_tracking")
for query_index in query_frame_indexes:
print(f"Predicting tracks for query frame {query_index}")
pred_track, pred_vis, pred_conf, pred_point_3d, pred_color = _forward_on_query(
query_index,
images,
conf,
points_3d,
fmaps_for_tracker,
keypoint_extractors,
tracker,
max_points_num,
fine_tracking,
device,
)
pred_tracks.append(pred_track)
pred_vis_scores.append(pred_vis)
pred_confs.append(pred_conf)
pred_points_3d.append(pred_point_3d)
pred_colors.append(pred_color)
if complete_non_vis:
pred_tracks, pred_vis_scores, pred_confs, pred_points_3d, pred_colors = _augment_non_visible_frames(
pred_tracks,
pred_vis_scores,
pred_confs,
pred_points_3d,
pred_colors,
images,
conf,
points_3d,
fmaps_for_tracker,
keypoint_extractors,
tracker,
max_points_num,
fine_tracking,
min_vis=500,
non_vis_thresh=0.1,
device=device,
)
pred_tracks = np.concatenate(pred_tracks, axis=1)
pred_vis_scores = np.concatenate(pred_vis_scores, axis=1)
pred_confs = np.concatenate(pred_confs, axis=0) if pred_confs else None
pred_points_3d = np.concatenate(pred_points_3d, axis=0) if pred_points_3d else None
pred_colors = np.concatenate(pred_colors, axis=0) if pred_colors else None
# from vggt.utils.visual_track import visualize_tracks_on_images
# visualize_tracks_on_images(images[None], torch.from_numpy(pred_tracks[None]), torch.from_numpy(pred_vis_scores[None])>0.2, out_dir="track_visuals")
return pred_tracks, pred_vis_scores, pred_confs, pred_points_3d, pred_colors
def _forward_on_query(
query_index,
images,
conf,
points_3d,
fmaps_for_tracker,
keypoint_extractors,
tracker,
max_points_num,
fine_tracking,
device,
):
"""
Process a single query frame for track prediction.
Args:
query_index: Index of the query frame
images: Tensor of shape [S, 3, H, W] containing the input images
conf: Confidence tensor
points_3d: 3D points tensor
fmaps_for_tracker: Feature maps for the tracker
keypoint_extractors: Initialized feature extractors
tracker: VGG-SFM tracker
max_points_num: Maximum number of points to process at once
fine_tracking: Whether to use fine tracking
device: Device to use for computation
Returns:
pred_track: Predicted tracks
pred_vis: Visibility scores for the tracks
pred_conf: Confidence scores for the tracks
pred_point_3d: 3D points for the tracks
pred_color: Point colors for the tracks (0, 255)
"""
frame_num, _, height, width = images.shape
query_image = images[query_index]
query_points = extract_keypoints(query_image, keypoint_extractors, round_keypoints=False)
query_points = query_points[:, torch.randperm(query_points.shape[1], device=device)]
# Extract the color at the keypoint locations
query_points_long = query_points.squeeze(0).round().long()
pred_color = images[query_index][:, query_points_long[:, 1], query_points_long[:, 0]]
pred_color = (pred_color.permute(1, 0).cpu().numpy() * 255).astype(np.uint8)
# Query the confidence and points_3d at the keypoint locations
if (conf is not None) and (points_3d is not None):
assert height == width
assert conf.shape[-2] == conf.shape[-1]
assert conf.shape[:3] == points_3d.shape[:3]
scale = conf.shape[-1] / width
query_points_scaled = (query_points.squeeze(0) * scale).round().long()
query_points_scaled = query_points_scaled.cpu().numpy()
pred_conf = conf[query_index][query_points_scaled[:, 1], query_points_scaled[:, 0]]
pred_point_3d = points_3d[query_index][query_points_scaled[:, 1], query_points_scaled[:, 0]]
# heuristic to remove low confidence points
# should I export this as an input parameter?
valid_mask = pred_conf > 1.2
if valid_mask.sum() > 512:
query_points = query_points[:, valid_mask] # Make sure shape is compatible
pred_conf = pred_conf[valid_mask]
pred_point_3d = pred_point_3d[valid_mask]
pred_color = pred_color[valid_mask]
else:
pred_conf = None
pred_point_3d = None
reorder_index = calculate_index_mappings(query_index, frame_num, device=device)
images_feed, fmaps_feed = switch_tensor_order([images, fmaps_for_tracker], reorder_index, dim=0)
images_feed = images_feed[None] # add batch dimension
fmaps_feed = fmaps_feed[None] # add batch dimension
all_points_num = images_feed.shape[1] * query_points.shape[1]
# Don't need to be scared, this is just chunking to make GPU happy
if all_points_num > max_points_num:
num_splits = (all_points_num + max_points_num - 1) // max_points_num
query_points = torch.chunk(query_points, num_splits, dim=1)
else:
query_points = [query_points]
pred_track, pred_vis, _ = predict_tracks_in_chunks(
tracker, images_feed, query_points, fmaps_feed, fine_tracking=fine_tracking
)
pred_track, pred_vis = switch_tensor_order([pred_track, pred_vis], reorder_index, dim=1)
pred_track = pred_track.squeeze(0).float().cpu().numpy()
pred_vis = pred_vis.squeeze(0).float().cpu().numpy()
return pred_track, pred_vis, pred_conf, pred_point_3d, pred_color
def _augment_non_visible_frames(
pred_tracks: list, # ← running list of np.ndarrays
pred_vis_scores: list, # ← running list of np.ndarrays
pred_confs: list, # ← running list of np.ndarrays for confidence scores
pred_points_3d: list, # ← running list of np.ndarrays for 3D points
pred_colors: list, # ← running list of np.ndarrays for colors
images: torch.Tensor,
conf,
points_3d,
fmaps_for_tracker,
keypoint_extractors,
tracker,
max_points_num: int,
fine_tracking: bool,
*,
min_vis: int = 500,
non_vis_thresh: float = 0.1,
device: torch.device = None,
):
"""
Augment tracking for frames with insufficient visibility.
Args:
pred_tracks: List of numpy arrays containing predicted tracks.
pred_vis_scores: List of numpy arrays containing visibility scores.
pred_confs: List of numpy arrays containing confidence scores.
pred_points_3d: List of numpy arrays containing 3D points.
pred_colors: List of numpy arrays containing point colors.
images: Tensor of shape [S, 3, H, W] containing the input images.
conf: Tensor of shape [S, 1, H, W] containing confidence scores
points_3d: Tensor containing 3D points
fmaps_for_tracker: Feature maps for the tracker
keypoint_extractors: Initialized feature extractors
tracker: VGG-SFM tracker
max_points_num: Maximum number of points to process at once
fine_tracking: Whether to use fine tracking
min_vis: Minimum visibility threshold
non_vis_thresh: Non-visibility threshold
device: Device to use for computation
Returns:
Updated pred_tracks, pred_vis_scores, pred_confs, pred_points_3d, and pred_colors lists.
"""
last_query = -1
final_trial = False
cur_extractors = keypoint_extractors # may be replaced on the final trial
while True:
# Visibility per frame
vis_array = np.concatenate(pred_vis_scores, axis=1)
# Count frames with sufficient visibility using numpy
sufficient_vis_count = (vis_array > non_vis_thresh).sum(axis=-1)
non_vis_frames = np.where(sufficient_vis_count < min_vis)[0].tolist()
if len(non_vis_frames) == 0:
break
print("Processing non visible frames:", non_vis_frames)
# Decide the frames & extractor for this round
if non_vis_frames[0] == last_query:
# Same frame failed twice - final "all-in" attempt
final_trial = True
cur_extractors = initialize_feature_extractors(2048, extractor_method="sp+sift+aliked", device=device)
query_frame_list = non_vis_frames # blast them all at once
else:
query_frame_list = [non_vis_frames[0]] # Process one at a time
last_query = non_vis_frames[0]
# Run the tracker for every selected frame
for query_index in query_frame_list:
new_track, new_vis, new_conf, new_point_3d, new_color = _forward_on_query(
query_index,
images,
conf,
points_3d,
fmaps_for_tracker,
cur_extractors,
tracker,
max_points_num,
fine_tracking,
device,
)
pred_tracks.append(new_track)
pred_vis_scores.append(new_vis)
pred_confs.append(new_conf)
pred_points_3d.append(new_point_3d)
pred_colors.append(new_color)
if final_trial:
break # Stop after final attempt
return pred_tracks, pred_vis_scores, pred_confs, pred_points_3d, pred_colors
+124
View File
@@ -0,0 +1,124 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from functools import partial
from torch import nn, einsum
from einops import rearrange, repeat
from einops.layers.torch import Rearrange, Reduce
from hydra.utils import instantiate
from omegaconf import OmegaConf
from .track_modules.track_refine import refine_track
from .track_modules.blocks import BasicEncoder, ShallowEncoder
from .track_modules.base_track_predictor import BaseTrackerPredictor
class TrackerPredictor(nn.Module):
def __init__(self, **extra_args):
super(TrackerPredictor, self).__init__()
"""
Initializes the tracker predictor.
Both coarse_predictor and fine_predictor are constructed as a BaseTrackerPredictor,
check track_modules/base_track_predictor.py
Both coarse_fnet and fine_fnet are constructed as a 2D CNN network
check track_modules/blocks.py for BasicEncoder and ShallowEncoder
"""
# Define coarse predictor configuration
coarse_stride = 4
self.coarse_down_ratio = 2
# Create networks directly instead of using instantiate
self.coarse_fnet = BasicEncoder(stride=coarse_stride)
self.coarse_predictor = BaseTrackerPredictor(stride=coarse_stride)
# Create fine predictor with stride = 1
self.fine_fnet = ShallowEncoder(stride=1)
self.fine_predictor = BaseTrackerPredictor(
stride=1,
depth=4,
corr_levels=3,
corr_radius=3,
latent_dim=32,
hidden_size=256,
fine=True,
use_spaceatt=False,
)
def forward(
self, images, query_points, fmaps=None, coarse_iters=6, inference=True, fine_tracking=True, fine_chunk=40960
):
"""
Args:
images (torch.Tensor): Images as RGB, in the range of [0, 1], with a shape of B x S x 3 x H x W.
query_points (torch.Tensor): 2D xy of query points, relative to top left, with a shape of B x N x 2.
fmaps (torch.Tensor, optional): Precomputed feature maps. Defaults to None.
coarse_iters (int, optional): Number of iterations for coarse prediction. Defaults to 6.
inference (bool, optional): Whether to perform inference. Defaults to True.
fine_tracking (bool, optional): Whether to perform fine tracking. Defaults to True.
Returns:
tuple: A tuple containing fine_pred_track, coarse_pred_track, pred_vis, and pred_score.
"""
if fmaps is None:
batch_num, frame_num, image_dim, height, width = images.shape
reshaped_image = images.reshape(batch_num * frame_num, image_dim, height, width)
fmaps = self.process_images_to_fmaps(reshaped_image)
fmaps = fmaps.reshape(batch_num, frame_num, -1, fmaps.shape[-2], fmaps.shape[-1])
if inference:
torch.cuda.empty_cache()
# Coarse prediction
coarse_pred_track_lists, pred_vis = self.coarse_predictor(
query_points=query_points, fmaps=fmaps, iters=coarse_iters, down_ratio=self.coarse_down_ratio
)
coarse_pred_track = coarse_pred_track_lists[-1]
if inference:
torch.cuda.empty_cache()
if fine_tracking:
# Refine the coarse prediction
fine_pred_track, pred_score = refine_track(
images, self.fine_fnet, self.fine_predictor, coarse_pred_track, compute_score=False, chunk=fine_chunk
)
if inference:
torch.cuda.empty_cache()
else:
fine_pred_track = coarse_pred_track
pred_score = torch.ones_like(pred_vis)
return fine_pred_track, coarse_pred_track, pred_vis, pred_score
def process_images_to_fmaps(self, images):
"""
This function processes images for inference.
Args:
images (torch.Tensor): The images to be processed with shape S x 3 x H x W.
Returns:
torch.Tensor: The processed feature maps.
"""
if self.coarse_down_ratio > 1:
# whether or not scale down the input images to save memory
fmaps = self.coarse_fnet(
F.interpolate(images, scale_factor=1 / self.coarse_down_ratio, mode="bilinear", align_corners=True)
)
else:
fmaps = self.coarse_fnet(images)
return fmaps
+305
View File
@@ -0,0 +1,305 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import logging
import warnings
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
import pycolmap
import torch
import torch.nn.functional as F
from lightglue import ALIKED, SIFT, SuperPoint
from .vggsfm_tracker import TrackerPredictor
# Suppress verbose logging from dependencies
logging.getLogger("dinov2").setLevel(logging.WARNING)
warnings.filterwarnings("ignore", message="xFormers is available")
warnings.filterwarnings("ignore", message="dinov2")
# Constants
_RESNET_MEAN = [0.485, 0.456, 0.406]
_RESNET_STD = [0.229, 0.224, 0.225]
def build_vggsfm_tracker(model_path=None):
"""
Build and initialize the VGGSfM tracker.
Args:
model_path: Path to the model weights file. If None, weights are downloaded from HuggingFace.
Returns:
Initialized tracker model in eval mode.
"""
tracker = TrackerPredictor()
if model_path is None:
default_url = "https://huggingface.co/facebook/VGGSfM/resolve/main/vggsfm_v2_tracker.pt"
tracker.load_state_dict(torch.hub.load_state_dict_from_url(default_url))
else:
tracker.load_state_dict(torch.load(model_path))
tracker.eval()
return tracker
def generate_rank_by_dino(
images, query_frame_num, image_size=336, model_name="dinov2_vitb14_reg", device="cuda", spatial_similarity=False
):
"""
Generate a ranking of frames using DINO ViT features.
Args:
images: Tensor of shape (S, 3, H, W) with values in range [0, 1]
query_frame_num: Number of frames to select
image_size: Size to resize images to before processing
model_name: Name of the DINO model to use
device: Device to run the model on
spatial_similarity: Whether to use spatial token similarity or CLS token similarity
Returns:
List of frame indices ranked by their representativeness
"""
# Resize images to the target size
images = F.interpolate(images, (image_size, image_size), mode="bilinear", align_corners=False)
# Load DINO model
dino_v2_model = torch.hub.load("facebookresearch/dinov2", model_name)
dino_v2_model.eval()
dino_v2_model = dino_v2_model.to(device)
# Normalize images using ResNet normalization
resnet_mean = torch.tensor(_RESNET_MEAN, device=device).view(1, 3, 1, 1)
resnet_std = torch.tensor(_RESNET_STD, device=device).view(1, 3, 1, 1)
images_resnet_norm = (images - resnet_mean) / resnet_std
with torch.no_grad():
frame_feat = dino_v2_model(images_resnet_norm, is_training=True)
# Process features based on similarity type
if spatial_similarity:
frame_feat = frame_feat["x_norm_patchtokens"]
frame_feat_norm = F.normalize(frame_feat, p=2, dim=1)
# Compute the similarity matrix
frame_feat_norm = frame_feat_norm.permute(1, 0, 2)
similarity_matrix = torch.bmm(frame_feat_norm, frame_feat_norm.transpose(-1, -2))
similarity_matrix = similarity_matrix.mean(dim=0)
else:
frame_feat = frame_feat["x_norm_clstoken"]
frame_feat_norm = F.normalize(frame_feat, p=2, dim=1)
similarity_matrix = torch.mm(frame_feat_norm, frame_feat_norm.transpose(-1, -2))
distance_matrix = 100 - similarity_matrix.clone()
# Ignore self-pairing
similarity_matrix.fill_diagonal_(-100)
similarity_sum = similarity_matrix.sum(dim=1)
# Find the most common frame
most_common_frame_index = torch.argmax(similarity_sum).item()
# Conduct FPS sampling starting from the most common frame
fps_idx = farthest_point_sampling(distance_matrix, query_frame_num, most_common_frame_index)
# Clean up all tensors and models to free memory
del frame_feat, frame_feat_norm, similarity_matrix, distance_matrix
del dino_v2_model
torch.cuda.empty_cache()
return fps_idx
def farthest_point_sampling(distance_matrix, num_samples, most_common_frame_index=0):
"""
Farthest point sampling algorithm to select diverse frames.
Args:
distance_matrix: Matrix of distances between frames
num_samples: Number of frames to select
most_common_frame_index: Index of the first frame to select
Returns:
List of selected frame indices
"""
distance_matrix = distance_matrix.clamp(min=0)
N = distance_matrix.size(0)
# Initialize with the most common frame
selected_indices = [most_common_frame_index]
check_distances = distance_matrix[selected_indices]
while len(selected_indices) < num_samples:
# Find the farthest point from the current set of selected points
farthest_point = torch.argmax(check_distances)
selected_indices.append(farthest_point.item())
check_distances = distance_matrix[farthest_point]
# Mark already selected points to avoid selecting them again
check_distances[selected_indices] = 0
# Break if all points have been selected
if len(selected_indices) == N:
break
return selected_indices
def calculate_index_mappings(query_index, S, device=None):
"""
Construct an order that switches [query_index] and [0]
so that the content of query_index would be placed at [0].
Args:
query_index: Index to swap with 0
S: Total number of elements
device: Device to place the tensor on
Returns:
Tensor of indices with the swapped order
"""
new_order = torch.arange(S)
new_order[0] = query_index
new_order[query_index] = 0
if device is not None:
new_order = new_order.to(device)
return new_order
def switch_tensor_order(tensors, order, dim=1):
"""
Reorder tensors along a specific dimension according to the given order.
Args:
tensors: List of tensors to reorder
order: Tensor of indices specifying the new order
dim: Dimension along which to reorder
Returns:
List of reordered tensors
"""
return [torch.index_select(tensor, dim, order) if tensor is not None else None for tensor in tensors]
def initialize_feature_extractors(max_query_num, det_thres=0.005, extractor_method="aliked", device="cuda"):
"""
Initialize feature extractors that can be reused based on a method string.
Args:
max_query_num: Maximum number of keypoints to extract
det_thres: Detection threshold for keypoint extraction
extractor_method: String specifying which extractors to use (e.g., "aliked", "sp+sift", "aliked+sp+sift")
device: Device to run extraction on
Returns:
Dictionary of initialized extractors
"""
extractors = {}
methods = extractor_method.lower().split("+")
for method in methods:
method = method.strip()
if method == "aliked":
aliked_extractor = ALIKED(max_num_keypoints=max_query_num, detection_threshold=det_thres)
extractors["aliked"] = aliked_extractor.to(device).eval()
elif method == "sp":
sp_extractor = SuperPoint(max_num_keypoints=max_query_num, detection_threshold=det_thres)
extractors["sp"] = sp_extractor.to(device).eval()
elif method == "sift":
sift_extractor = SIFT(max_num_keypoints=max_query_num)
extractors["sift"] = sift_extractor.to(device).eval()
else:
print(f"Warning: Unknown feature extractor '{method}', ignoring.")
if not extractors:
print(f"Warning: No valid extractors found in '{extractor_method}'. Using ALIKED by default.")
aliked_extractor = ALIKED(max_num_keypoints=max_query_num, detection_threshold=det_thres)
extractors["aliked"] = aliked_extractor.to(device).eval()
return extractors
def extract_keypoints(query_image, extractors, round_keypoints=True):
"""
Extract keypoints using pre-initialized feature extractors.
Args:
query_image: Input image tensor (3xHxW, range [0, 1])
extractors: Dictionary of initialized extractors
Returns:
Tensor of keypoint coordinates (1xNx2)
"""
query_points = None
with torch.no_grad():
for extractor_name, extractor in extractors.items():
query_points_data = extractor.extract(query_image, invalid_mask=None)
extractor_points = query_points_data["keypoints"]
if round_keypoints:
extractor_points = extractor_points.round()
if query_points is not None:
query_points = torch.cat([query_points, extractor_points], dim=1)
else:
query_points = extractor_points
return query_points
def predict_tracks_in_chunks(
track_predictor, images_feed, query_points_list, fmaps_feed, fine_tracking, num_splits=None, fine_chunk=40960
):
"""
Process a list of query points to avoid memory issues.
Args:
track_predictor (object): The track predictor object used for predicting tracks.
images_feed (torch.Tensor): A tensor of shape (B, T, C, H, W) representing a batch of images.
query_points_list (list or tuple): A list/tuple of tensors, each of shape (B, Ni, 2) representing chunks of query points.
fmaps_feed (torch.Tensor): A tensor of feature maps for the tracker.
fine_tracking (bool): Whether to perform fine tracking.
num_splits (int, optional): Ignored when query_points_list is provided. Kept for backward compatibility.
Returns:
tuple: A tuple containing the concatenated predicted tracks, visibility, and scores.
"""
# If query_points_list is not a list or tuple but a single tensor, handle it like the old version for backward compatibility
if not isinstance(query_points_list, (list, tuple)):
query_points = query_points_list
if num_splits is None:
num_splits = 1
query_points_list = torch.chunk(query_points, num_splits, dim=1)
# Ensure query_points_list is a list for iteration (as torch.chunk returns a tuple)
if isinstance(query_points_list, tuple):
query_points_list = list(query_points_list)
fine_pred_track_list = []
pred_vis_list = []
pred_score_list = []
for split_points in query_points_list:
# Feed into track predictor for each split
fine_pred_track, _, pred_vis, pred_score = track_predictor(
images_feed, split_points, fmaps=fmaps_feed, fine_tracking=fine_tracking, fine_chunk=fine_chunk
)
fine_pred_track_list.append(fine_pred_track)
pred_vis_list.append(pred_vis)
pred_score_list.append(pred_score)
# Concatenate the results from all splits
fine_pred_track = torch.cat(fine_pred_track_list, dim=2)
pred_vis = torch.cat(pred_vis_list, dim=2)
if pred_score is not None:
pred_score = torch.cat(pred_score_list, dim=2)
else:
pred_score = None
return fine_pred_track, pred_vis, pred_score
+3 -16
View File
@@ -50,12 +50,7 @@ class CameraHead(nn.Module):
# Build the trunk using a sequence of transformer blocks.
self.trunk = nn.Sequential(
*[
Block(
dim=dim_in,
num_heads=num_heads,
mlp_ratio=mlp_ratio,
init_values=init_values,
)
Block(dim=dim_in, num_heads=num_heads, mlp_ratio=mlp_ratio, init_values=init_values)
for _ in range(trunk_depth)
]
)
@@ -73,12 +68,7 @@ class CameraHead(nn.Module):
# Adaptive layer normalization without affine parameters.
self.adaln_norm = nn.LayerNorm(dim_in, elementwise_affine=False, eps=1e-6)
self.pose_branch = Mlp(
in_features=dim_in,
hidden_features=dim_in // 2,
out_features=self.target_dim,
drop=0,
)
self.pose_branch = Mlp(in_features=dim_in, hidden_features=dim_in // 2, out_features=self.target_dim, drop=0)
def forward(self, aggregated_tokens_list: list, num_iterations: int = 4) -> list:
"""
@@ -144,10 +134,7 @@ class CameraHead(nn.Module):
# Apply final activation functions for translation, quaternion, and field-of-view.
activated_pose = activate_pose(
pred_pose_enc,
trans_act=self.trans_act,
quat_act=self.quat_act,
fl_act=self.fl_act,
pred_pose_enc, trans_act=self.trans_act, quat_act=self.quat_act, fl_act=self.fl_act
)
pred_pose_enc_list.append(activated_pose)
+2 -15
View File
@@ -67,16 +67,7 @@ class DPTHead(nn.Module):
# Projection layers for each output channel from tokens.
self.projects = nn.ModuleList(
[
nn.Conv2d(
in_channels=dim_in,
out_channels=oc,
kernel_size=1,
stride=1,
padding=0,
)
for oc in out_channels
]
[nn.Conv2d(in_channels=dim_in, out_channels=oc, kernel_size=1, stride=1, padding=0) for oc in out_channels]
)
# Resize layers for upsampling feature maps.
@@ -95,11 +86,7 @@ class DPTHead(nn.Module):
]
)
self.scratch = _make_scratch(
out_channels,
features,
expand=False,
)
self.scratch = _make_scratch(out_channels, features, expand=False)
# Attach additional modules to scratch.
self.scratch.stem_transpose = None
+1 -5
View File
@@ -99,10 +99,6 @@ class TrackHead(nn.Module):
iters = self.iters
# Perform tracking using the extracted features
coord_preds, vis_scores, conf_scores = self.tracker(
query_points=query_points,
fmaps=feature_maps,
iters=iters,
)
coord_preds, vis_scores, conf_scores = self.tracker(query_points=query_points, fmaps=feature_maps, iters=iters)
return coord_preds, vis_scores, conf_scores
+2 -12
View File
@@ -56,12 +56,7 @@ class EfficientUpdateFormer(nn.Module):
self.time_blocks = nn.ModuleList(
[
AttnBlock(
hidden_size,
num_heads,
mlp_ratio=mlp_ratio,
attn_class=nn.MultiheadAttention,
)
AttnBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, attn_class=nn.MultiheadAttention)
for _ in range(time_depth)
]
)
@@ -69,12 +64,7 @@ class EfficientUpdateFormer(nn.Module):
if add_space_attn:
self.space_virtual_blocks = nn.ModuleList(
[
AttnBlock(
hidden_size,
num_heads,
mlp_ratio=mlp_ratio,
attn_class=nn.MultiheadAttention,
)
AttnBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, attn_class=nn.MultiheadAttention)
for _ in range(space_depth)
]
)
+4 -18
View File
@@ -45,20 +45,9 @@ class ResidualBlock(nn.Module):
super(ResidualBlock, self).__init__()
self.conv1 = nn.Conv2d(
in_planes,
planes,
kernel_size=kernel_size,
padding=1,
stride=stride,
padding_mode="zeros",
)
self.conv2 = nn.Conv2d(
planes,
planes,
kernel_size=kernel_size,
padding=1,
padding_mode="zeros",
in_planes, planes, kernel_size=kernel_size, padding=1, stride=stride, padding_mode="zeros"
)
self.conv2 = nn.Conv2d(planes, planes, kernel_size=kernel_size, padding=1, padding_mode="zeros")
self.relu = nn.ReLU(inplace=True)
num_groups = planes // 8
@@ -92,10 +81,7 @@ class ResidualBlock(nn.Module):
if stride == 1:
self.downsample = None
else:
self.downsample = nn.Sequential(
nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride),
self.norm3,
)
self.downsample = nn.Sequential(nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride), self.norm3)
def forward(self, x):
y = x
@@ -151,7 +137,7 @@ class AttnBlock(nn.Module):
num_heads,
attn_class: Callable[..., nn.Module] = nn.MultiheadAttention,
mlp_ratio=4.0,
**block_kwargs
**block_kwargs,
):
"""
Self attention block
+1 -4
View File
@@ -36,10 +36,7 @@ def get_2d_sincos_pos_embed(embed_dim: int, grid_size: Union[int, Tuple[int, int
grid = grid.reshape([2, 1, grid_size_h, grid_size_w])
pos_embed = get_2d_sincos_pos_embed_from_grid(embed_dim, grid)
if return_grid:
return (
pos_embed.reshape(1, grid_size_h, grid_size_w, -1).permute(0, 3, 1, 2),
grid,
)
return (pos_embed.reshape(1, grid_size_h, grid_size_w, -1).permute(0, 3, 1, 2), grid)
return pos_embed.reshape(1, grid_size_h, grid_size_w, -1).permute(0, 3, 1, 2)
+1 -6
View File
@@ -58,12 +58,7 @@ class Attention(nn.Module):
k = self.rope(k, pos)
if self.fused_attn:
x = F.scaled_dot_product_attention(
q,
k,
v,
dropout_p=self.attn_drop.p if self.training else 0.0,
)
x = F.scaled_dot_product_attention(q, k, v, dropout_p=self.attn_drop.p if self.training else 0.0)
else:
q = q * self.scale
attn = q @ k.transpose(-2, -1)
+6 -18
View File
@@ -67,11 +67,7 @@ class Block(nn.Module):
self.norm2 = norm_layer(dim)
mlp_hidden_dim = int(dim * mlp_ratio)
self.mlp = ffn_layer(
in_features=dim,
hidden_features=mlp_hidden_dim,
act_layer=act_layer,
drop=drop,
bias=ffn_bias,
in_features=dim, hidden_features=mlp_hidden_dim, act_layer=act_layer, drop=drop, bias=ffn_bias
)
self.ls2 = LayerScale(dim, init_values=init_values) if init_values else nn.Identity()
self.drop_path2 = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
@@ -88,15 +84,10 @@ class Block(nn.Module):
if self.training and self.sample_drop_ratio > 0.1:
# the overhead is compensated only for a drop path rate larger than 0.1
x = drop_add_residual_stochastic_depth(
x,
pos=pos,
residual_func=attn_residual_func,
sample_drop_ratio=self.sample_drop_ratio,
x, pos=pos, residual_func=attn_residual_func, sample_drop_ratio=self.sample_drop_ratio
)
x = drop_add_residual_stochastic_depth(
x,
residual_func=ffn_residual_func,
sample_drop_ratio=self.sample_drop_ratio,
x, residual_func=ffn_residual_func, sample_drop_ratio=self.sample_drop_ratio
)
elif self.training and self.sample_drop_ratio > 0.0:
x = x + self.drop_path1(attn_residual_func(x, pos=pos))
@@ -108,10 +99,7 @@ class Block(nn.Module):
def drop_add_residual_stochastic_depth(
x: Tensor,
residual_func: Callable[[Tensor], Tensor],
sample_drop_ratio: float = 0.0,
pos=None,
x: Tensor, residual_func: Callable[[Tensor], Tensor], sample_drop_ratio: float = 0.0, pos=None
) -> Tensor:
# 1) extract subset using permutation
b, n, d = x.shape
@@ -226,13 +214,13 @@ class NestedTensorBlock(Block):
x_list,
residual_func=attn_residual_func,
sample_drop_ratio=self.sample_drop_ratio,
scaling_vector=self.ls1.gamma if isinstance(self.ls1, LayerScale) else None,
scaling_vector=(self.ls1.gamma if isinstance(self.ls1, LayerScale) else None),
)
x_list = drop_add_residual_stochastic_depth_list(
x_list,
residual_func=ffn_residual_func,
sample_drop_ratio=self.sample_drop_ratio,
scaling_vector=self.ls2.gamma if isinstance(self.ls1, LayerScale) else None,
scaling_vector=(self.ls2.gamma if isinstance(self.ls1, LayerScale) else None),
)
return x_list
else:
+1 -6
View File
@@ -13,12 +13,7 @@ from torch import nn
class LayerScale(nn.Module):
def __init__(
self,
dim: int,
init_values: Union[float, Tensor] = 1e-5,
inplace: bool = False,
) -> None:
def __init__(self, dim: int, init_values: Union[float, Tensor] = 1e-5, inplace: bool = False) -> None:
super().__init__()
self.inplace = inplace
self.gamma = nn.Parameter(init_values * torch.ones(dim))
+1 -4
View File
@@ -47,10 +47,7 @@ class PatchEmbed(nn.Module):
image_HW = make_2tuple(img_size)
patch_HW = make_2tuple(patch_size)
patch_grid_size = (
image_HW[0] // patch_HW[0],
image_HW[1] // patch_HW[1],
)
patch_grid_size = (image_HW[0] // patch_HW[0], image_HW[1] // patch_HW[1])
self.img_size = image_HW
self.patch_size = patch_HW
+1 -6
View File
@@ -64,9 +64,4 @@ class SwiGLUFFNFused(SwiGLU):
out_features = out_features or in_features
hidden_features = hidden_features or in_features
hidden_features = (int(hidden_features * 2 / 3) + 7) // 8 * 8
super().__init__(
in_features=in_features,
hidden_features=hidden_features,
out_features=out_features,
bias=bias,
)
super().__init__(in_features=in_features, hidden_features=hidden_features, out_features=out_features, bias=bias)
+1 -8
View File
@@ -224,14 +224,7 @@ class DinoVisionTransformer(nn.Module):
x = x + self.interpolate_pos_encoding(x, w, h)
if self.register_tokens is not None:
x = torch.cat(
(
x[:, :1],
self.register_tokens.expand(x.shape[0], -1, -1),
x[:, 1:],
),
dim=1,
)
x = torch.cat((x[:, :1], self.register_tokens.expand(x.shape[0], -1, -1), x[:, 1:]), dim=1)
return x
+3 -13
View File
@@ -133,15 +133,8 @@ class Aggregator(nn.Module):
nn.init.normal_(self.register_token, std=1e-6)
# Register normalization constants as buffers
for name, value in (
("_resnet_mean", _RESNET_MEAN),
("_resnet_std", _RESNET_STD),
):
self.register_buffer(
name,
torch.FloatTensor(value).view(1, 1, 3, 1, 1),
persistent=False,
)
for name, value in (("_resnet_mean", _RESNET_MEAN), ("_resnet_std", _RESNET_STD)):
self.register_buffer(name, torch.FloatTensor(value).view(1, 1, 3, 1, 1), persistent=False)
def __build_patch_embed__(
self,
@@ -184,10 +177,7 @@ class Aggregator(nn.Module):
if hasattr(self.patch_embed, "mask_token"):
self.patch_embed.mask_token.requires_grad_(False)
def forward(
self,
images: torch.Tensor,
) -> Tuple[List[torch.Tensor], int]:
def forward(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
"""
Args:
images (torch.Tensor): Input images with shape [B, S, 3, H, W], in range [0, 1].
+1 -5
View File
@@ -24,11 +24,7 @@ class VGGT(nn.Module, PyTorchModelHubMixin):
self.depth_head = DPTHead(dim_in=2 * embed_dim, output_dim=2, activation="exp", conf_activation="expp1")
self.track_head = TrackHead(dim_in=2 * embed_dim, patch_size=patch_size)
def forward(
self,
images: torch.Tensor,
query_points: torch.Tensor = None,
):
def forward(self, images: torch.Tensor, query_points: torch.Tensor = None):
"""
Forward pass of the VGGT model.
+1 -4
View File
@@ -42,10 +42,7 @@ def unproject_depth_map_to_point_map(
def depth_to_world_coords_points(
depth_map: np.ndarray,
extrinsic: np.ndarray,
intrinsic: np.ndarray,
eps=1e-8,
depth_map: np.ndarray, extrinsic: np.ndarray, intrinsic: np.ndarray, eps=1e-8
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Convert a depth map to world coordinates.
+60
View File
@@ -0,0 +1,60 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
def randomly_limit_trues(mask: np.ndarray, max_trues: int) -> np.ndarray:
"""
If mask has more than max_trues True values,
randomly keep only max_trues of them and set the rest to False.
"""
# 1D positions of all True entries
true_indices = np.flatnonzero(mask) # shape = (N_true,)
# if already within budget, return as-is
if true_indices.size <= max_trues:
return mask
# randomly pick which True positions to keep
sampled_indices = np.random.choice(true_indices, size=max_trues, replace=False) # shape = (max_trues,)
# build new flat mask: True only at sampled positions
limited_flat_mask = np.zeros(mask.size, dtype=bool)
limited_flat_mask[sampled_indices] = True
# restore original shape
return limited_flat_mask.reshape(mask.shape)
def create_pixel_coordinate_grid(num_frames, height, width):
"""
Creates a grid of pixel coordinates and frame indices for all frames.
Returns:
tuple: A tuple containing:
- points_xyf (numpy.ndarray): Array of shape (num_frames, height, width, 3)
with x, y coordinates and frame indices
- y_coords (numpy.ndarray): Array of y coordinates for all frames
- x_coords (numpy.ndarray): Array of x coordinates for all frames
- f_coords (numpy.ndarray): Array of frame indices for all frames
"""
# Create coordinate grids for a single frame
y_grid, x_grid = np.indices((height, width), dtype=np.float32)
x_grid = x_grid[np.newaxis, :, :]
y_grid = y_grid[np.newaxis, :, :]
# Broadcast to all frames
x_coords = np.broadcast_to(x_grid, (num_frames, height, width))
y_coords = np.broadcast_to(y_grid, (num_frames, height, width))
# Create frame indices and broadcast
f_idx = np.arange(num_frames, dtype=np.float32)[:, np.newaxis, np.newaxis]
f_coords = np.broadcast_to(f_idx, (num_frames, height, width))
# Stack coordinates and frame indices
points_xyf = np.stack((x_coords, y_coords, f_coords), axis=-1)
return points_xyf
+90 -6
View File
@@ -7,6 +7,91 @@
import torch
from PIL import Image
from torchvision import transforms as TF
import numpy as np
def load_and_preprocess_images_square(image_path_list, target_size=1024):
"""
Load and preprocess images by center padding to square and resizing to target size.
Also returns the position information of original pixels after transformation.
Args:
image_path_list (list): List of paths to image files
target_size (int, optional): Target size for both width and height. Defaults to 518.
Returns:
tuple: (
torch.Tensor: Batched tensor of preprocessed images with shape (N, 3, target_size, target_size),
torch.Tensor: Array of shape (N, 5) containing [x1, y1, x2, y2, width, height] for each image
)
Raises:
ValueError: If the input list is empty
"""
# Check for empty list
if len(image_path_list) == 0:
raise ValueError("At least 1 image is required")
images = []
original_coords = [] # Renamed from position_info to be more descriptive
to_tensor = TF.ToTensor()
for image_path in image_path_list:
# Open image
img = Image.open(image_path)
# If there's an alpha channel, blend onto white background
if img.mode == "RGBA":
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img)
# Convert to RGB
img = img.convert("RGB")
# Get original dimensions
width, height = img.size
# Make the image square by padding the shorter dimension
max_dim = max(width, height)
# Calculate padding
left = (max_dim - width) // 2
top = (max_dim - height) // 2
# Calculate scale factor for resizing
scale = target_size / max_dim
# Calculate final coordinates of original image in target space
x1 = left * scale
y1 = top * scale
x2 = (left + width) * scale
y2 = (top + height) * scale
# Store original image coordinates and scale
original_coords.append(np.array([x1, y1, x2, y2, width, height]))
# Create a new black square image and paste original
square_img = Image.new("RGB", (max_dim, max_dim), (0, 0, 0))
square_img.paste(img, (left, top))
# Resize to target size
square_img = square_img.resize((target_size, target_size), Image.Resampling.BICUBIC)
# Convert to tensor
img_tensor = to_tensor(square_img)
images.append(img_tensor)
# Stack all images
images = torch.stack(images)
original_coords = torch.from_numpy(np.array(original_coords)).float()
# Add additional dimension if single image to ensure correct shape
if len(image_path_list) == 1:
if images.dim() == 3:
images = images.unsqueeze(0)
original_coords = original_coords.unsqueeze(0)
return images, original_coords
def load_and_preprocess_images(image_path_list, mode="crop"):
@@ -39,7 +124,7 @@ def load_and_preprocess_images(image_path_list, mode="crop"):
# Check for empty list
if len(image_path_list) == 0:
raise ValueError("At least 1 image is required")
# Validate mode
if mode not in ["crop", "pad"]:
raise ValueError("Mode must be either 'crop' or 'pad'")
@@ -51,7 +136,6 @@ def load_and_preprocess_images(image_path_list, mode="crop"):
# First process all images and collect their shapes
for image_path in image_path_list:
# Open image
img = Image.open(image_path)
@@ -66,7 +150,7 @@ def load_and_preprocess_images(image_path_list, mode="crop"):
img = img.convert("RGB")
width, height = img.size
if mode == "pad":
# Make the largest dimension 518px while maintaining aspect ratio
if width >= height:
@@ -89,18 +173,18 @@ def load_and_preprocess_images(image_path_list, mode="crop"):
if mode == "crop" and new_height > target_size:
start_y = (new_height - target_size) // 2
img = img[:, start_y : start_y + target_size, :]
# For pad mode, pad to make a square of target_size x target_size
if mode == "pad":
h_padding = target_size - img.shape[1]
w_padding = target_size - img.shape[2]
if h_padding > 0 or w_padding > 0:
pad_top = h_padding // 2
pad_bottom = h_padding - pad_top
pad_left = w_padding // 2
pad_right = w_padding - pad_left
# Pad with white (value=1.0)
img = torch.nn.functional.pad(
img, (pad_left, pad_right, pad_top, pad_bottom), mode="constant", value=1.0
+2 -8
View File
@@ -9,10 +9,7 @@ from .rotation import quat_to_mat, mat_to_quat
def extri_intri_to_pose_encoding(
extrinsics,
intrinsics,
image_size_hw=None, # e.g., (256, 512)
pose_encoding_type="absT_quaR_FoV",
extrinsics, intrinsics, image_size_hw=None, pose_encoding_type="absT_quaR_FoV" # e.g., (256, 512)
):
"""Convert camera extrinsics and intrinsics to a compact pose encoding.
@@ -63,10 +60,7 @@ def extri_intri_to_pose_encoding(
def pose_encoding_to_extri_intri(
pose_encoding,
image_size_hw=None, # e.g., (256, 512)
pose_encoding_type="absT_quaR_FoV",
build_intrinsics=True,
pose_encoding, image_size_hw=None, pose_encoding_type="absT_quaR_FoV", build_intrinsics=True # e.g., (256, 512)
):
"""Convert a pose encoding back to camera extrinsics and intrinsics.
+1 -7
View File
@@ -63,13 +63,7 @@ def mat_to_quat(matrix: torch.Tensor) -> torch.Tensor:
q_abs = _sqrt_positive_part(
torch.stack(
[
1.0 + m00 + m11 + m22,
1.0 + m00 - m11 - m22,
1.0 - m00 + m11 - m22,
1.0 - m00 - m11 + m22,
],
dim=-1,
[1.0 + m00 + m11 + m22, 1.0 + m00 - m11 - m22, 1.0 - m00 + m11 - m22, 1.0 - m00 - m11 + m22], dim=-1
)
)
-596
View File
@@ -1,596 +0,0 @@
import os
import argparse
import numpy as np
import torch
import glob
import struct
from scipy.spatial.transform import Rotation
import sys
from PIL import Image
import cv2
import requests
import tempfile
sys.path.append("vggt/")
from vggt.models.vggt import VGGT
from vggt.utils.load_fn import load_and_preprocess_images
from vggt.utils.pose_enc import pose_encoding_to_extri_intri
from vggt.utils.geometry import unproject_depth_map_to_point_map
def load_model(device=None):
"""Load and initialize the VGGT model."""
if device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
model = VGGT.from_pretrained("facebook/VGGT-1B")
# model = VGGT()
# _URL = "https://huggingface.co/facebook/VGGT-1B/resolve/main/model.pt"
# model.load_state_dict(torch.hub.load_state_dict_from_url(_URL))
model.eval()
model = model.to(device)
return model, device
def process_images(image_dir, model, device):
"""Process images with VGGT and return predictions."""
image_names = glob.glob(os.path.join(image_dir, "*"))
image_names = sorted([f for f in image_names if f.lower().endswith(('.png', '.jpg', '.jpeg'))])
print(f"Found {len(image_names)} images")
if len(image_names) == 0:
raise ValueError(f"No images found in {image_dir}")
original_images = []
for img_path in image_names:
img = Image.open(img_path).convert('RGB')
original_images.append(np.array(img))
images = load_and_preprocess_images(image_names).to(device)
print(f"Preprocessed images shape: {images.shape}")
print("Running inference...")
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16
with torch.no_grad():
with torch.cuda.amp.autocast(dtype=dtype):
predictions = model(images)
print("Converting pose encoding to camera parameters...")
extrinsic, intrinsic = pose_encoding_to_extri_intri(predictions["pose_enc"], images.shape[-2:])
predictions["extrinsic"] = extrinsic
predictions["intrinsic"] = intrinsic
for key in predictions.keys():
if isinstance(predictions[key], torch.Tensor):
predictions[key] = predictions[key].cpu().numpy().squeeze(0) # remove batch dimension
print("Computing 3D points from depth maps...")
depth_map = predictions["depth"] # (S, H, W, 1)
world_points = unproject_depth_map_to_point_map(depth_map, predictions["extrinsic"], predictions["intrinsic"])
predictions["world_points_from_depth"] = world_points
predictions["original_images"] = original_images
S, H, W = world_points.shape[:3]
normalized_images = np.zeros((S, H, W, 3), dtype=np.float32)
for i, img in enumerate(original_images):
resized_img = cv2.resize(img, (W, H))
normalized_images[i] = resized_img / 255.0
predictions["images"] = normalized_images
return predictions, image_names
def extrinsic_to_colmap_format(extrinsics):
"""Convert extrinsic matrices to COLMAP format (quaternion + translation)."""
num_cameras = extrinsics.shape[0]
quaternions = []
translations = []
for i in range(num_cameras):
# VGGT's extrinsic is camera-to-world (R|t) format
R = extrinsics[i, :3, :3]
t = extrinsics[i, :3, 3]
# Convert rotation matrix to quaternion
# COLMAP quaternion format is [qw, qx, qy, qz]
rot = Rotation.from_matrix(R)
quat = rot.as_quat() # scipy returns [x, y, z, w]
quat = np.array([quat[3], quat[0], quat[1], quat[2]]) # Convert to [w, x, y, z]
quaternions.append(quat)
translations.append(t)
return np.array(quaternions), np.array(translations)
def download_file_from_url(url, filename):
"""Downloads a file from a URL, handling redirects."""
try:
response = requests.get(url, allow_redirects=False)
response.raise_for_status()
if response.status_code == 302:
redirect_url = response.headers["Location"]
response = requests.get(redirect_url, stream=True)
response.raise_for_status()
else:
response = requests.get(url, stream=True)
response.raise_for_status()
with open(filename, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Downloaded {filename} successfully.")
return True
except requests.exceptions.RequestException as e:
print(f"Error downloading file: {e}")
return False
def segment_sky(image_path, onnx_session, mask_filename=None):
"""
Segments sky from an image using an ONNX model.
"""
image = cv2.imread(image_path)
result_map = run_skyseg(onnx_session, [320, 320], image)
result_map_original = cv2.resize(result_map, (image.shape[1], image.shape[0]))
# Fix: Invert the mask so that 255 = non-sky, 0 = sky
# The model outputs low values for sky, high values for non-sky
output_mask = np.zeros_like(result_map_original)
output_mask[result_map_original < 32] = 255 # Use threshold of 32
if mask_filename is not None:
os.makedirs(os.path.dirname(mask_filename), exist_ok=True)
cv2.imwrite(mask_filename, output_mask)
return output_mask
def run_skyseg(onnx_session, input_size, image):
"""
Runs sky segmentation inference using ONNX model.
"""
import copy
temp_image = copy.deepcopy(image)
resize_image = cv2.resize(temp_image, dsize=(input_size[0], input_size[1]))
x = cv2.cvtColor(resize_image, cv2.COLOR_BGR2RGB)
x = np.array(x, dtype=np.float32)
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
x = (x / 255 - mean) / std
x = x.transpose(2, 0, 1)
x = x.reshape(-1, 3, input_size[0], input_size[1]).astype("float32")
input_name = onnx_session.get_inputs()[0].name
output_name = onnx_session.get_outputs()[0].name
onnx_result = onnx_session.run([output_name], {input_name: x})
onnx_result = np.array(onnx_result).squeeze()
min_value = np.min(onnx_result)
max_value = np.max(onnx_result)
onnx_result = (onnx_result - min_value) / (max_value - min_value)
onnx_result *= 255
onnx_result = onnx_result.astype("uint8")
return onnx_result
def filter_and_prepare_points(predictions, conf_threshold, mask_sky=False, mask_black_bg=False,
mask_white_bg=False, stride=1, prediction_mode="Depthmap and Camera Branch"):
"""
Filter points based on confidence and prepare for COLMAP format.
Implementation matches the conventions in the original VGGT code.
"""
if "Pointmap" in prediction_mode:
print("Using Pointmap Branch")
if "world_points" in predictions:
pred_world_points = predictions["world_points"]
pred_world_points_conf = predictions.get("world_points_conf", np.ones_like(pred_world_points[..., 0]))
else:
print("Warning: world_points not found in predictions, falling back to depth-based points")
pred_world_points = predictions["world_points_from_depth"]
pred_world_points_conf = predictions.get("depth_conf", np.ones_like(pred_world_points[..., 0]))
else:
print("Using Depthmap and Camera Branch")
pred_world_points = predictions["world_points_from_depth"]
pred_world_points_conf = predictions.get("depth_conf", np.ones_like(pred_world_points[..., 0]))
colors_rgb = predictions["images"]
S, H, W = pred_world_points.shape[:3]
if colors_rgb.shape[:3] != (S, H, W):
print(f"Reshaping colors_rgb from {colors_rgb.shape} to match {(S, H, W, 3)}")
reshaped_colors = np.zeros((S, H, W, 3), dtype=np.float32)
for i in range(S):
if i < len(colors_rgb):
reshaped_colors[i] = cv2.resize(colors_rgb[i], (W, H))
colors_rgb = reshaped_colors
colors_rgb = (colors_rgb * 255).astype(np.uint8)
if mask_sky:
print("Applying sky segmentation mask")
try:
import onnxruntime
with tempfile.TemporaryDirectory() as temp_dir:
print(f"Created temporary directory for sky segmentation: {temp_dir}")
temp_images_dir = os.path.join(temp_dir, "images")
sky_masks_dir = os.path.join(temp_dir, "sky_masks")
os.makedirs(temp_images_dir, exist_ok=True)
os.makedirs(sky_masks_dir, exist_ok=True)
image_list = []
for i, img in enumerate(colors_rgb):
img_path = os.path.join(temp_images_dir, f"image_{i:04d}.png")
image_list.append(img_path)
cv2.imwrite(img_path, cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
skyseg_path = os.path.join(temp_dir, "skyseg.onnx")
if not os.path.exists("skyseg.onnx"):
print("Downloading skyseg.onnx...")
download_success = download_file_from_url(
"https://huggingface.co/JianyuanWang/skyseg/resolve/main/skyseg.onnx",
skyseg_path
)
if not download_success:
print("Failed to download skyseg model, skipping sky filtering")
mask_sky = False
else:
import shutil
shutil.copy("skyseg.onnx", skyseg_path)
if mask_sky:
skyseg_session = onnxruntime.InferenceSession(skyseg_path)
sky_mask_list = []
for img_path in image_list:
mask_path = os.path.join(sky_masks_dir, os.path.basename(img_path))
sky_mask = segment_sky(img_path, skyseg_session, mask_path)
if sky_mask.shape[0] != H or sky_mask.shape[1] != W:
sky_mask = cv2.resize(sky_mask, (W, H))
sky_mask_list.append(sky_mask)
sky_mask_array = np.array(sky_mask_list)
sky_mask_binary = (sky_mask_array > 0.1).astype(np.float32)
pred_world_points_conf = pred_world_points_conf * sky_mask_binary
print(f"Applied sky mask, shape: {sky_mask_binary.shape}")
except (ImportError, Exception) as e:
print(f"Error in sky segmentation: {e}")
mask_sky = False
vertices_3d = pred_world_points.reshape(-1, 3)
conf = pred_world_points_conf.reshape(-1)
colors_rgb_flat = colors_rgb.reshape(-1, 3)
if len(conf) != len(colors_rgb_flat):
print(f"WARNING: Shape mismatch between confidence ({len(conf)}) and colors ({len(colors_rgb_flat)})")
min_size = min(len(conf), len(colors_rgb_flat))
conf = conf[:min_size]
vertices_3d = vertices_3d[:min_size]
colors_rgb_flat = colors_rgb_flat[:min_size]
if conf_threshold == 0.0:
conf_thres_value = 0.0
else:
conf_thres_value = np.percentile(conf, conf_threshold)
print(f"Using confidence threshold: {conf_threshold}% (value: {conf_thres_value:.4f})")
conf_mask = (conf >= conf_thres_value) & (conf > 1e-5)
if mask_black_bg:
print("Filtering black background")
black_bg_mask = colors_rgb_flat.sum(axis=1) >= 16
conf_mask = conf_mask & black_bg_mask
if mask_white_bg:
print("Filtering white background")
white_bg_mask = ~((colors_rgb_flat[:, 0] > 240) & (colors_rgb_flat[:, 1] > 240) & (colors_rgb_flat[:, 2] > 240))
conf_mask = conf_mask & white_bg_mask
filtered_vertices = vertices_3d[conf_mask]
filtered_colors = colors_rgb_flat[conf_mask]
if len(filtered_vertices) == 0:
print("Warning: No points remaining after filtering. Using default point.")
filtered_vertices = np.array([[0, 0, 0]])
filtered_colors = np.array([[200, 200, 200]])
print(f"Filtered to {len(filtered_vertices)} points")
points3D = []
point_indices = {}
image_points2D = [[] for _ in range(len(pred_world_points))]
print(f"Preparing points for COLMAP format with stride {stride}...")
total_points = 0
for img_idx in range(S):
for y in range(0, H, stride):
for x in range(0, W, stride):
flat_idx = img_idx * H * W + y * W + x
if flat_idx >= len(conf):
continue
if conf[flat_idx] < conf_thres_value or conf[flat_idx] <= 1e-5:
continue
if mask_black_bg and colors_rgb_flat[flat_idx].sum() < 16:
continue
if mask_white_bg and all(colors_rgb_flat[flat_idx] > 240):
continue
point3D = vertices_3d[flat_idx]
rgb = colors_rgb_flat[flat_idx]
if not np.all(np.isfinite(point3D)):
continue
point_hash = hash_point(point3D, scale=100)
if point_hash not in point_indices:
point_idx = len(points3D)
point_indices[point_hash] = point_idx
point_entry = {
"id": point_idx,
"xyz": point3D,
"rgb": rgb,
"error": 1.0,
"track": [(img_idx, len(image_points2D[img_idx]))]
}
points3D.append(point_entry)
total_points += 1
else:
point_idx = point_indices[point_hash]
points3D[point_idx]["track"].append((img_idx, len(image_points2D[img_idx])))
image_points2D[img_idx].append((x, y, point_indices[point_hash]))
print(f"Prepared {len(points3D)} 3D points with {sum(len(pts) for pts in image_points2D)} observations for COLMAP")
return points3D, image_points2D
def hash_point(point, scale=100):
"""Create a hash for a 3D point by quantizing coordinates."""
quantized = tuple(np.round(point * scale).astype(int))
return hash(quantized)
def write_colmap_cameras_txt(file_path, intrinsics, image_width, image_height):
"""Write camera intrinsics to COLMAP cameras.txt format."""
with open(file_path, '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(f"# Number of cameras: {len(intrinsics)}\n")
for i, intrinsic in enumerate(intrinsics):
camera_id = i + 1 # COLMAP uses 1-indexed camera IDs
model = "PINHOLE"
fx = intrinsic[0, 0]
fy = intrinsic[1, 1]
cx = intrinsic[0, 2]
cy = intrinsic[1, 2]
f.write(f"{camera_id} {model} {image_width} {image_height} {fx} {fy} {cx} {cy}\n")
def write_colmap_images_txt(file_path, quaternions, translations, image_points2D, image_names):
"""Write camera poses and keypoints to COLMAP images.txt format."""
with open(file_path, '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")
num_points = sum(len(points) for points in image_points2D)
avg_points = num_points / len(image_points2D) if image_points2D else 0
f.write(f"# Number of images: {len(quaternions)}, mean observations per image: {avg_points:.1f}\n")
for i in range(len(quaternions)):
image_id = i + 1
camera_id = i + 1
qw, qx, qy, qz = quaternions[i]
tx, ty, tz = translations[i]
f.write(f"{image_id} {qw} {qx} {qy} {qz} {tx} {ty} {tz} {camera_id} {os.path.basename(image_names[i])}\n")
points_line = " ".join([f"{x} {y} {point3d_id+1}" for x, y, point3d_id in image_points2D[i]])
f.write(f"{points_line}\n")
def write_colmap_points3D_txt(file_path, points3D):
"""Write 3D points and tracks to COLMAP points3D.txt format."""
with open(file_path, '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")
avg_track_length = sum(len(point["track"]) for point in points3D) / len(points3D) if points3D else 0
f.write(f"# Number of points: {len(points3D)}, mean track length: {avg_track_length:.4f}\n")
for point in points3D:
point_id = point["id"] + 1
x, y, z = point["xyz"]
r, g, b = point["rgb"]
error = point["error"]
track = " ".join([f"{img_id+1} {point2d_idx}" for img_id, point2d_idx in point["track"]])
f.write(f"{point_id} {x} {y} {z} {int(r)} {int(g)} {int(b)} {error} {track}\n")
def write_colmap_cameras_bin(file_path, intrinsics, image_width, image_height):
"""Write camera intrinsics to COLMAP cameras.bin format."""
with open(file_path, 'wb') as fid:
# Write number of cameras (uint64)
fid.write(struct.pack('<Q', len(intrinsics)))
for i, intrinsic in enumerate(intrinsics):
camera_id = i + 1
model_id = 1
fx = float(intrinsic[0, 0])
fy = float(intrinsic[1, 1])
cx = float(intrinsic[0, 2])
cy = float(intrinsic[1, 2])
# Camera ID (uint32)
fid.write(struct.pack('<I', camera_id))
# Model ID (uint32)
fid.write(struct.pack('<I', model_id))
# Width (uint64)
fid.write(struct.pack('<Q', image_width))
# Height (uint64)
fid.write(struct.pack('<Q', image_height))
# Parameters (double)
fid.write(struct.pack('<dddd', fx, fy, cx, cy))
def write_colmap_images_bin(file_path, quaternions, translations, image_points2D, image_names):
"""Write camera poses and keypoints to COLMAP images.bin format."""
with open(file_path, 'wb') as fid:
# Write number of images (uint64)
fid.write(struct.pack('<Q', len(quaternions)))
for i in range(len(quaternions)):
image_id = i + 1
camera_id = i + 1
qw, qx, qy, qz = quaternions[i].astype(float)
tx, ty, tz = translations[i].astype(float)
image_name = os.path.basename(image_names[i]).encode()
points = image_points2D[i]
# Image ID (uint32)
fid.write(struct.pack('<I', image_id))
# Quaternion (double): qw, qx, qy, qz
fid.write(struct.pack('<dddd', qw, qx, qy, qz))
# Translation (double): tx, ty, tz
fid.write(struct.pack('<ddd', tx, ty, tz))
# Camera ID (uint32)
fid.write(struct.pack('<I', camera_id))
# Image name
for char in image_name:
fid.write(struct.pack('<c', bytes([char])))
fid.write(struct.pack('<c', b'\x00'))
# Write number of 2D points (uint64)
fid.write(struct.pack('<Q', len(points)))
# Write 2D points: x, y, point3D_id
for x, y, point3d_id in points:
fid.write(struct.pack('<dd', float(x), float(y)))
fid.write(struct.pack('<Q', point3d_id + 1))
def write_colmap_points3D_bin(file_path, points3D):
"""Write 3D points and tracks to COLMAP points3D.bin format."""
with open(file_path, 'wb') as fid:
# Write number of points (uint64)
fid.write(struct.pack('<Q', len(points3D)))
for point in points3D:
point_id = point["id"] + 1
x, y, z = point["xyz"].astype(float)
r, g, b = point["rgb"].astype(np.uint8)
error = float(point["error"])
track = point["track"]
# Point ID (uint64)
fid.write(struct.pack('<Q', point_id))
# Position (double): x, y, z
fid.write(struct.pack('<ddd', x, y, z))
# Color (uint8): r, g, b
fid.write(struct.pack('<BBB', int(r), int(g), int(b)))
# Error (double)
fid.write(struct.pack('<d', error))
# Track: list of (image_id, point2D_idx)
fid.write(struct.pack('<Q', len(track)))
for img_id, point2d_idx in track:
fid.write(struct.pack('<II', img_id + 1, point2d_idx))
def main():
parser = argparse.ArgumentParser(description="Convert images to COLMAP format using VGGT")
parser.add_argument("--image_dir", type=str, required=True,
help="Directory containing input images")
parser.add_argument("--output_dir", type=str, default="colmap_output",
help="Directory to save COLMAP files")
parser.add_argument("--conf_threshold", type=float, default=50.0,
help="Confidence threshold (0-100%) for including points")
parser.add_argument("--mask_sky", action="store_true",
help="Filter out points likely to be sky")
parser.add_argument("--mask_black_bg", action="store_true",
help="Filter out points with very dark/black color")
parser.add_argument("--mask_white_bg", action="store_true",
help="Filter out points with very bright/white color")
parser.add_argument("--binary", action="store_true",
help="Output binary COLMAP files instead of text")
parser.add_argument("--stride", type=int, default=1,
help="Stride for point sampling (higher = fewer points)")
parser.add_argument("--prediction_mode", type=str, default="Depthmap and Camera Branch",
choices=["Depthmap and Camera Branch", "Pointmap Branch"],
help="Which prediction branch to use")
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
model, device = load_model()
predictions, image_names = process_images(args.image_dir, model, device)
print("Converting camera parameters to COLMAP format...")
quaternions, translations = extrinsic_to_colmap_format(predictions["extrinsic"])
print(f"Filtering points with confidence threshold {args.conf_threshold}% and stride {args.stride}...")
points3D, image_points2D = filter_and_prepare_points(
predictions,
args.conf_threshold,
mask_sky=args.mask_sky,
mask_black_bg=args.mask_black_bg,
mask_white_bg=args.mask_white_bg,
stride=args.stride,
prediction_mode=args.prediction_mode
)
height, width = predictions["depth"].shape[1:3]
print(f"Writing {'binary' if args.binary else 'text'} COLMAP files to {args.output_dir}...")
if args.binary:
write_colmap_cameras_bin(
os.path.join(args.output_dir, "cameras.bin"),
predictions["intrinsic"], width, height)
write_colmap_images_bin(
os.path.join(args.output_dir, "images.bin"),
quaternions, translations, image_points2D, image_names)
write_colmap_points3D_bin(
os.path.join(args.output_dir, "points3D.bin"),
points3D)
else:
write_colmap_cameras_txt(
os.path.join(args.output_dir, "cameras.txt"),
predictions["intrinsic"], width, height)
write_colmap_images_txt(
os.path.join(args.output_dir, "images.txt"),
quaternions, translations, image_points2D, image_names)
write_colmap_points3D_txt(
os.path.join(args.output_dir, "points3D.txt"),
points3D)
print(f"COLMAP files successfully written to {args.output_dir}")
if __name__ == "__main__":
main()
+1 -6
View File
@@ -215,12 +215,7 @@ def predictions_to_glb(
return scene_3d
def integrate_camera_into_scene(
scene: trimesh.Scene,
transform: np.ndarray,
face_colors: tuple,
scene_scale: float,
):
def integrate_camera_into_scene(scene: trimesh.Scene, transform: np.ndarray, face_colors: tuple, scene_scale: float):
"""
Integrates a fake camera mesh into the 3D scene.