Merge branch 'main' of github.com:nerfstudio-project/nerfstudio into brent/splat_export_world_frame

This commit is contained in:
Brent Yi committed 2024-10-03 18:05:24 -07:00
commit 3921dc250a
16 files changed
+333 -346

No files matched your search

+3 -1
View File
@@ -47,6 +47,7 @@ jobs:
- name: Build and push Docker image
id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
if:
with:
context: .
file: ./Dockerfile
@@ -55,8 +56,9 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
if: ${{ github.event_name != 'pull_request' }}
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: ${{ github.event_name != 'pull_request' }}
push-to-registry: true
+2 -1
View File
@@ -577,7 +577,8 @@ def auto_orient_and_center_poses(
oriented_poses = transform @ poses
if oriented_poses.mean(dim=0)[2, 1] < 0:
oriented_poses[:, 1:3] = -1 * oriented_poses[:, 1:3]
oriented_poses[1:3, :] = -1 * oriented_poses[1:3, :]
transform[1:3, :] = -1 * transform[1:3, :]
elif method in ("up", "vertical"):
up = torch.mean(poses[:, :3, 1], dim=0)
up = up / torch.linalg.norm(up)
+1 -4
View File
@@ -40,7 +40,6 @@ from nerfstudio.data.dataparsers.sitcoms3d_dataparser import Sitcoms3DDataParser
from nerfstudio.data.datasets.depth_dataset import DepthDataset
from nerfstudio.data.datasets.sdf_dataset import SDFDataset
from nerfstudio.data.datasets.semantic_dataset import SemanticDataset
from nerfstudio.data.pixel_samplers import PairPixelSamplerConfig
from nerfstudio.engine.optimizers import AdamOptimizerConfig, RAdamOptimizerConfig
from nerfstudio.engine.schedulers import (
CosineDecaySchedulerConfig,
@@ -222,7 +221,6 @@ method_configs["depth-nerfacto"] = TrainerConfig(
pipeline=VanillaPipelineConfig(
datamanager=VanillaDataManagerConfig(
_target=VanillaDataManager[DepthDataset],
pixel_sampler=PairPixelSamplerConfig(),
dataparser=NerfstudioDataParserConfig(),
train_num_rays_per_batch=4096,
eval_num_rays_per_batch=4096,
@@ -663,8 +661,7 @@ method_configs["splatfacto-big"] = TrainerConfig(
),
model=SplatfactoModelConfig(
cull_alpha_thresh=0.005,
continue_cull_post_densification=False,
densify_grad_thresh=0.0006,
densify_grad_thresh=0.0005,
),
),
optimizers={
@@ -41,6 +41,8 @@ class DepthDataset(InputDataset):
scale_factor: The scaling factor for the dataparser outputs.
"""
exclude_batch_keys_from_device = InputDataset.exclude_batch_keys_from_device + ["depth_image"]
def __init__(self, dataparser_outputs: DataparserOutputs, scale_factor: float = 1.0):
super().__init__(dataparser_outputs, scale_factor)
# if there are no depth images than we want to generate them all with zoe depth
+104 -38
View File
@@ -78,6 +78,61 @@ class PixelSampler:
"""
self.num_rays_per_batch = num_rays_per_batch
def rejection_sample_mask(
self,
mask: Tensor,
num_samples: int,
num_images: int,
image_height: int,
image_width: int,
device: Union[torch.device, str],
) -> Int[Tensor, "batch_size 3"]:
"""
Samples pixels within a mask using rejection sampling.
Args:
mask: mask of possible pixels in an image to sample from.
num_samples: number of samples.
num_images: number of images to sample over.
image_height: the height of the image.
image_width: the width of the image.
device: device that the samples should be on.
"""
indices = (
torch.rand((num_samples, 3), device=device)
* torch.tensor([num_images, image_height, image_width], device=device)
).long()
num_valid = 0
for _ in range(self.config.max_num_iterations):
c, y, x = (i.flatten() for i in torch.split(indices, 1, dim=-1))
chosen_indices_validity = mask.squeeze()[c, y, x].bool()
num_valid = int(torch.sum(chosen_indices_validity).item())
if num_valid == num_samples:
break
else:
replacement_indices = (
torch.rand((num_samples - num_valid, 3), device=device)
* torch.tensor([num_images, image_height, image_width], device=device)
).long()
indices[~chosen_indices_validity] = replacement_indices
if num_valid != num_samples:
warnings.warn(
"""
Masked sampling failed, mask is either empty or mostly empty.
Reverting behavior to non-rejection sampling. Consider setting
pipeline.datamanager.pixel-sampler.rejection-sample-mask to False
or increasing pipeline.datamanager.pixel-sampler.max-num-iterations
"""
)
self.config.rejection_sample_mask = False
nonzero_indices = torch.nonzero(mask.squeeze(), as_tuple=False).to(device)
chosen_indices = random.sample(range(len(nonzero_indices)), k=num_samples)
indices = nonzero_indices[chosen_indices]
return indices
def sample_method(
self,
batch_size: int,
@@ -95,44 +150,25 @@ class PixelSampler:
num_images: number of images to sample over
mask: mask of possible pixels in an image to sample from.
"""
indices = (
torch.rand((batch_size, 3), device=device)
* torch.tensor([num_images, image_height, image_width], device=device)
).long()
if isinstance(mask, torch.Tensor) and not self.config.ignore_mask:
if self.config.rejection_sample_mask:
num_valid = 0
for _ in range(self.config.max_num_iterations):
c, y, x = (i.flatten() for i in torch.split(indices, 1, dim=-1))
chosen_indices_validity = mask[..., 0][c, y, x].bool()
num_valid = int(torch.sum(chosen_indices_validity).item())
if num_valid == batch_size:
break
else:
replacement_indices = (
torch.rand((batch_size - num_valid, 3), device=device)
* torch.tensor([num_images, image_height, image_width], device=device)
).long()
indices[~chosen_indices_validity] = replacement_indices
if num_valid != batch_size:
warnings.warn(
"""
Masked sampling failed, mask is either empty or mostly empty.
Reverting behavior to non-rejection sampling. Consider setting
pipeline.datamanager.pixel-sampler.rejection-sample-mask to False
or increasing pipeline.datamanager.pixel-sampler.max-num-iterations
"""
)
self.config.rejection_sample_mask = False
nonzero_indices = torch.nonzero(mask[..., 0], as_tuple=False)
chosen_indices = random.sample(range(len(nonzero_indices)), k=batch_size)
indices = nonzero_indices[chosen_indices]
indices = self.rejection_sample_mask(
mask=mask,
num_samples=batch_size,
num_images=num_images,
image_height=image_height,
image_width=image_width,
device=device,
)
else:
nonzero_indices = torch.nonzero(mask[..., 0], as_tuple=False)
chosen_indices = random.sample(range(len(nonzero_indices)), k=batch_size)
indices = nonzero_indices[chosen_indices]
else:
indices = (
torch.rand((batch_size, 3), device=device)
* torch.tensor([num_images, image_height, image_width], device=device)
).long()
return indices
@@ -384,6 +420,10 @@ class PatchPixelSamplerConfig(PixelSamplerConfig):
patch_size: int = 32
"""Side length of patch. This must be consistent in the method
config in order for samples to be reshaped into patches correctly."""
rejection_sample_mask: bool = True
"""Whether or not to use rejection sampling when sampling images with masks"""
max_num_iterations: int = 100
"""If rejection sampling masks, the maximum number of times to sample"""
class PatchPixelSampler(PixelSampler):
@@ -422,9 +462,20 @@ class PatchPixelSampler(PixelSampler):
sub_bs = batch_size // (self.config.patch_size**2)
half_patch_size = int(self.config.patch_size / 2)
m = erode_mask(mask.permute(0, 3, 1, 2).float(), pixel_radius=half_patch_size)
nonzero_indices = torch.nonzero(m[:, 0], as_tuple=False).to(device)
chosen_indices = random.sample(range(len(nonzero_indices)), k=sub_bs)
indices = nonzero_indices[chosen_indices]
if self.config.rejection_sample_mask:
indices = self.rejection_sample_mask(
mask=m,
num_samples=sub_bs,
num_images=num_images,
image_height=image_height,
image_width=image_width,
device=device,
)
else:
nonzero_indices = torch.nonzero(m[:, 0], as_tuple=False).to(device)
chosen_indices = random.sample(range(len(nonzero_indices)), k=sub_bs)
indices = nonzero_indices[chosen_indices]
indices = (
indices.view(sub_bs, 1, 1, 3)
@@ -473,6 +524,10 @@ class PairPixelSamplerConfig(PixelSamplerConfig):
"""Target class to instantiate."""
radius: int = 2
"""max distance between pairs of pixels."""
rejection_sample_mask: bool = True
"""Whether or not to use rejection sampling when sampling images with masks"""
max_num_iterations: int = 100
"""If rejection sampling masks, the maximum number of times to sample"""
class PairPixelSampler(PixelSampler): # pylint: disable=too-few-public-methods
@@ -508,9 +563,20 @@ class PairPixelSampler(PixelSampler): # pylint: disable=too-few-public-methods
if isinstance(mask, Tensor) and not self.config.ignore_mask:
m = erode_mask(mask.permute(0, 3, 1, 2).float(), pixel_radius=self.radius)
nonzero_indices = torch.nonzero(m[:, 0], as_tuple=False).to(device)
chosen_indices = random.sample(range(len(nonzero_indices)), k=rays_to_sample)
indices = nonzero_indices[chosen_indices]
if self.config.rejection_sample_mask:
indices = self.rejection_sample_mask(
mask=m,
num_samples=rays_to_sample,
num_images=num_images,
image_height=image_height,
image_width=image_width,
device=device,
)
else:
nonzero_indices = torch.nonzero(m[:, 0], as_tuple=False).to(device)
chosen_indices = random.sample(range(len(nonzero_indices)), k=rays_to_sample)
indices = nonzero_indices[chosen_indices]
else:
s = (rays_to_sample, 1)
ns = torch.randint(0, num_images, s, dtype=torch.long, device=device)
+2 -2
View File
@@ -78,10 +78,10 @@ def get_depth_image_from_path(
Depth image torch tensor with shape [height, width, 1].
"""
if filepath.suffix == ".npy":
image = np.load(filepath) * scale_factor
image = np.load(filepath).astype(np.float32) * scale_factor
image = cv2.resize(image, (width, height), interpolation=interpolation)
else:
image = cv2.imread(str(filepath.absolute()), cv2.IMREAD_ANYDEPTH)
image = image.astype(np.float64) * scale_factor
image = image.astype(np.float32) * scale_factor
image = cv2.resize(image, (width, height), interpolation=interpolation)
return torch.from_numpy(image[:, :, np.newaxis])
+8 -14
View File
@@ -28,9 +28,7 @@ def dilate(tensor: Float[Tensor, "bs 1 H W"], kernel_size=3) -> Float[Tensor, "b
Args:
kernel_size: Size of the pooling region. Dilates/contracts 1 pixel if kernel_size is 3.
"""
unique_vals = torch.unique(tensor)
if any(val not in (0, 1) for val in unique_vals) or tensor.dtype != torch.float32:
if not (tensor.dtype == torch.float32 and torch.all((tensor == 0) | (tensor == 1))):
raise ValueError("Input tensor should contain only values 0 and 1, and should have dtype torch.float32.")
return torch.nn.functional.max_pool2d(tensor, kernel_size=kernel_size, stride=1, padding=(kernel_size - 1) // 2)
@@ -42,22 +40,18 @@ def erode(tensor: Float[Tensor, "bs 1 H W"], kernel_size=3) -> Float[Tensor, "bs
Args:
kernel_size: Size of the pooling region. Erodes/expands 1 pixel if kernel_size is 3.
"""
unique_vals = torch.unique(tensor)
if any(val not in (0, 1) for val in unique_vals) or tensor.dtype != torch.float32:
if not (tensor.dtype == torch.float32 and torch.all((tensor == 0) | (tensor == 1))):
raise ValueError("Input tensor should contain only values 0 and 1, and should have dtype torch.float32.")
x = 1 - dilate(1 - tensor, kernel_size=kernel_size)
# set edge pixels to 0
# Invert the tensor, dilate, and re-invert
eroded = 1 - dilate(1 - tensor, kernel_size=kernel_size)
# Use padding to control edge effects (set edge pixels to 0)
p = (kernel_size - 1) // 2
x[:, :, :p, :] *= 0
x[:, :, :, :p] *= 0
x[:, :, -p:, :] *= 0
x[:, :, :, -p:] *= 0
return x
return torch.nn.functional.pad(eroded[:, :, p:-p, p:-p], pad=(p, p, p, p), mode="constant", value=0)
def erode_mask(tensor: Float[Tensor, "bs 1 H W"], pixel_radius: int = 1):
def erode_mask(tensor: Float[Tensor, "bs 1 H W"], pixel_radius: int = 1) -> Float[Tensor, "bs 1 H W"]:
"""Erode a mask. Expands 1 values to nearby pixels with a max pooling operation.
A pixel radius of 1 will also extend the 1s along the diagonal.
+2 -1
View File
@@ -300,7 +300,8 @@ class Trainer:
# Do not perform evaluation if there are no validation images
if self.pipeline.datamanager.eval_dataset:
self.eval_iteration(step)
with self.train_lock:
self.eval_iteration(step)
if step_check(step, self.config.steps_per_save):
self.save_checkpoint(step)
+5 -5
View File
@@ -165,11 +165,11 @@ def generate_point_cloud(
if crop_obb is not None:
mask = crop_obb.within(point)
point = point[mask]
rgb = rgb[mask]
view_direction = view_direction[mask]
if normal is not None:
normal = normal[mask]
point = point[mask]
rgb = rgb[mask]
view_direction = view_direction[mask]
if normal is not None:
normal = normal[mask]
points.append(point)
rgbs.append(rgb)
+2 -1
View File
@@ -49,7 +49,8 @@ class DepthNerfactoModelConfig(NerfactoModelConfig):
sigma_decay_rate: float = 0.99985
"""Rate of exponential decay."""
depth_loss_type: DepthLossType = DepthLossType.DS_NERF
"""Depth loss type."""
"""Depth loss type. Note that `PairPixelSampler` has to be used for `DepthLossType.SPARSENERF_RANKING`
to work as expected."""
class DepthNerfactoModel(NerfactoModel):
+52 -269
View File
@@ -25,12 +25,12 @@ from typing import Dict, List, Literal, Optional, Tuple, Type, Union
import numpy as np
import torch
from gsplat.strategy import DefaultStrategy
try:
from gsplat.rendering import rasterization
except ImportError:
print("Please install gsplat>=1.0.0")
from gsplat.cuda_legacy._wrapper import num_sh_bases
from pytorch_msssim import SSIM
from torch.nn import Parameter
@@ -46,6 +46,14 @@ from nerfstudio.utils.misc import torch_compile
from nerfstudio.utils.rich_utils import CONSOLE
def num_sh_bases(degree: int) -> int:
"""
Returns the number of spherical harmonic bases for a given degree.
"""
assert degree <= 4, "We don't support degree greater than 4."
return (degree + 1) ** 2
def quat_to_rotmat(quat):
assert quat.shape[-1] == 4, quat.shape
w, x, y, z = torch.unbind(quat, dim=-1)
@@ -154,12 +162,12 @@ class SplatfactoModelConfig(ModelConfig):
"""threshold of opacity for culling gaussians. One can set it to a lower value (e.g. 0.005) for higher quality."""
cull_scale_thresh: float = 0.5
"""threshold of scale for culling huge gaussians"""
continue_cull_post_densification: bool = True
"""If True, continue to cull gaussians post refinement"""
reset_alpha_every: int = 30
"""Every this many refinement steps, reset the alpha"""
densify_grad_thresh: float = 0.0008
"""threshold of positional gradient norm for densifying gaussians"""
use_absgrad: bool = True
"""Whether to use absgrad to densify gaussians, if False, will use grad rather than absgrad"""
densify_size_thresh: float = 0.01
"""below this size, gaussians are *duplicated*, otherwise split"""
n_split_samples: int = 2
@@ -235,8 +243,6 @@ class SplatfactoModel(Model):
means = torch.nn.Parameter(self.seed_points[0]) # (Location, Color)
else:
means = torch.nn.Parameter((torch.rand((self.config.num_random, 3)) - 0.5) * self.config.random_scale)
self.xys_grad_norm = None
self.max_2Dsize = None
distances, _ = self.k_nearest_sklearn(means.data, 3)
distances = torch.from_numpy(distances)
# find the average of the three nearest neighbors for each point and use that as the scale
@@ -305,6 +311,26 @@ class SplatfactoModel(Model):
grid_W=self.config.grid_shape[2],
)
# Strategy for GS densification
self.strategy = DefaultStrategy(
prune_opa=self.config.cull_alpha_thresh,
grow_grad2d=self.config.densify_grad_thresh,
grow_scale3d=self.config.densify_size_thresh,
grow_scale2d=self.config.split_screen_size,
prune_scale3d=self.config.cull_scale_thresh,
prune_scale2d=self.config.cull_screen_size,
refine_scale2d_stop_iter=self.config.stop_screen_size_at,
refine_start_iter=self.config.warmup_length,
refine_stop_iter=self.config.stop_split_at,
reset_every=self.config.reset_alpha_every * self.config.refine_every,
refine_every=self.config.refine_every,
pause_refine_after_reset=self.num_train_data + self.config.refine_every,
absgrad=self.config.use_absgrad,
revised_opacity=False,
verbose=True,
)
self.strategy_state = self.strategy.initialize_state(scene_scale=1.0)
@property
def colors(self):
if self.config.sh_degree > 0:
@@ -386,87 +412,6 @@ class SplatfactoModel(Model):
# Exclude the point itself from the result and return
return distances[:, 1:].astype(np.float32), indices[:, 1:].astype(np.float32)
def remove_from_optim(self, optimizer, deleted_mask, new_params):
"""removes the deleted_mask from the optimizer provided"""
assert len(new_params) == 1
# assert isinstance(optimizer, torch.optim.Adam), "Only works with Adam"
param = optimizer.param_groups[0]["params"][0]
param_state = optimizer.state[param]
del optimizer.state[param]
# Modify the state directly without deleting and reassigning.
if "exp_avg" in param_state:
param_state["exp_avg"] = param_state["exp_avg"][~deleted_mask]
param_state["exp_avg_sq"] = param_state["exp_avg_sq"][~deleted_mask]
# Update the parameter in the optimizer's param group.
del optimizer.param_groups[0]["params"][0]
del optimizer.param_groups[0]["params"]
optimizer.param_groups[0]["params"] = new_params
optimizer.state[new_params[0]] = param_state
def remove_from_all_optim(self, optimizers, deleted_mask):
param_groups = self.get_gaussian_param_groups()
for group, param in param_groups.items():
self.remove_from_optim(optimizers.optimizers[group], deleted_mask, param)
torch.cuda.empty_cache()
def dup_in_optim(self, optimizer, dup_mask, new_params, n=2):
"""adds the parameters to the optimizer"""
param = optimizer.param_groups[0]["params"][0]
param_state = optimizer.state[param]
if "exp_avg" in param_state:
repeat_dims = (n,) + tuple(1 for _ in range(param_state["exp_avg"].dim() - 1))
param_state["exp_avg"] = torch.cat(
[
param_state["exp_avg"],
torch.zeros_like(param_state["exp_avg"][dup_mask.squeeze()]).repeat(*repeat_dims),
],
dim=0,
)
param_state["exp_avg_sq"] = torch.cat(
[
param_state["exp_avg_sq"],
torch.zeros_like(param_state["exp_avg_sq"][dup_mask.squeeze()]).repeat(*repeat_dims),
],
dim=0,
)
del optimizer.state[param]
optimizer.state[new_params[0]] = param_state
optimizer.param_groups[0]["params"] = new_params
del param
def dup_in_all_optim(self, optimizers, dup_mask, n):
param_groups = self.get_gaussian_param_groups()
for group, param in param_groups.items():
self.dup_in_optim(optimizers.optimizers[group], dup_mask, param, n)
def after_train(self, step: int):
assert step == self.step
# to save some training time, we no longer need to update those stats post refinement
if self.step >= self.config.stop_split_at:
return
with torch.no_grad():
# keep track of a moving average of grad norms
visible_mask = (self.radii > 0).flatten()
grads = self.xys.absgrad[0][visible_mask].norm(dim=-1) # type: ignore
# print(f"grad norm min {grads.min().item()} max {grads.max().item()} mean {grads.mean().item()} size {grads.shape}")
if self.xys_grad_norm is None:
self.xys_grad_norm = torch.zeros(self.num_points, device=self.device, dtype=torch.float32)
self.vis_counts = torch.ones(self.num_points, device=self.device, dtype=torch.float32)
assert self.vis_counts is not None
self.vis_counts[visible_mask] += 1
self.xys_grad_norm[visible_mask] += grads
# update the max screen size, as a ratio of number of pixels
if self.max_2Dsize is None:
self.max_2Dsize = torch.zeros_like(self.radii, dtype=torch.float32)
newradii = self.radii.detach()[visible_mask]
self.max_2Dsize[visible_mask] = torch.maximum(
self.max_2Dsize[visible_mask],
newradii / float(max(self.last_size[0], self.last_size[1])),
)
def set_crop(self, crop_box: Optional[OrientedBox]):
self.crop_box = crop_box
@@ -474,199 +419,39 @@ class SplatfactoModel(Model):
assert background_color.shape == (3,)
self.background_color = background_color
def refinement_after(self, optimizers: Optimizers, step):
def step_post_backward(self, step):
assert step == self.step
if self.step <= self.config.warmup_length:
return
with torch.no_grad():
# Offset all the opacity reset logic by refine_every so that we don't
# save checkpoints right when the opacity is reset (saves every 2k)
# then cull
# only split/cull if we've seen every image since opacity reset
reset_interval = self.config.reset_alpha_every * self.config.refine_every
do_densification = (
self.step < self.config.stop_split_at
and self.step % reset_interval > self.num_train_data + self.config.refine_every
)
if do_densification:
# then we densify
assert self.xys_grad_norm is not None and self.vis_counts is not None and self.max_2Dsize is not None
avg_grad_norm = (self.xys_grad_norm / self.vis_counts) * 0.5 * max(self.last_size[0], self.last_size[1])
high_grads = (avg_grad_norm > self.config.densify_grad_thresh).squeeze()
splits = (self.scales.exp().max(dim=-1).values > self.config.densify_size_thresh).squeeze()
splits &= high_grads
if self.step < self.config.stop_screen_size_at:
splits |= (self.max_2Dsize > self.config.split_screen_size).squeeze()
nsamps = self.config.n_split_samples
split_params = self.split_gaussians(splits, nsamps)
dups = (self.scales.exp().max(dim=-1).values <= self.config.densify_size_thresh).squeeze()
dups &= high_grads
dup_params = self.dup_gaussians(dups)
for name, param in self.gauss_params.items():
self.gauss_params[name] = torch.nn.Parameter(
torch.cat([param.detach(), split_params[name], dup_params[name]], dim=0)
)
# append zeros to the max_2Dsize tensor
self.max_2Dsize = torch.cat(
[
self.max_2Dsize,
torch.zeros_like(split_params["scales"][:, 0]),
torch.zeros_like(dup_params["scales"][:, 0]),
],
dim=0,
)
split_idcs = torch.where(splits)[0]
self.dup_in_all_optim(optimizers, split_idcs, nsamps)
dup_idcs = torch.where(dups)[0]
self.dup_in_all_optim(optimizers, dup_idcs, 1)
# After a guassian is split into two new gaussians, the original one should also be pruned.
splits_mask = torch.cat(
(
splits,
torch.zeros(
nsamps * splits.sum() + dups.sum(),
device=self.device,
dtype=torch.bool,
),
)
)
deleted_mask = self.cull_gaussians(splits_mask)
elif self.step >= self.config.stop_split_at and self.config.continue_cull_post_densification:
deleted_mask = self.cull_gaussians()
else:
# if we donot allow culling post refinement, no more gaussians will be pruned.
deleted_mask = None
if deleted_mask is not None:
self.remove_from_all_optim(optimizers, deleted_mask)
if self.step < self.config.stop_split_at and self.step % reset_interval == self.config.refine_every:
# Reset value is set to be twice of the cull_alpha_thresh
reset_value = self.config.cull_alpha_thresh * 2.0
self.opacities.data = torch.clamp(
self.opacities.data,
max=torch.logit(torch.tensor(reset_value, device=self.device)).item(),
)
# reset the exp of optimizer
optim = optimizers.optimizers["opacities"]
param = optim.param_groups[0]["params"][0]
param_state = optim.state[param]
param_state["exp_avg"] = torch.zeros_like(param_state["exp_avg"])
param_state["exp_avg_sq"] = torch.zeros_like(param_state["exp_avg_sq"])
self.xys_grad_norm = None
self.vis_counts = None
self.max_2Dsize = None
def cull_gaussians(self, extra_cull_mask: Optional[torch.Tensor] = None):
"""
This function deletes gaussians with under a certain opacity threshold
extra_cull_mask: a mask indicates extra gaussians to cull besides existing culling criterion
"""
n_bef = self.num_points
# cull transparent ones
culls = (torch.sigmoid(self.opacities) < self.config.cull_alpha_thresh).squeeze()
below_alpha_count = torch.sum(culls).item()
toobigs_count = 0
if extra_cull_mask is not None:
culls = culls | extra_cull_mask
if self.step > self.config.refine_every * self.config.reset_alpha_every:
# cull huge ones
toobigs = (torch.exp(self.scales).max(dim=-1).values > self.config.cull_scale_thresh).squeeze()
if self.step < self.config.stop_screen_size_at:
# cull big screen space
if self.max_2Dsize is not None:
toobigs = toobigs | (self.max_2Dsize > self.config.cull_screen_size).squeeze()
culls = culls | toobigs
toobigs_count = torch.sum(toobigs).item()
for name, param in self.gauss_params.items():
self.gauss_params[name] = torch.nn.Parameter(param[~culls])
CONSOLE.log(
f"Culled {n_bef - self.num_points} gaussians "
f"({below_alpha_count} below alpha thresh, {toobigs_count} too bigs, {self.num_points} remaining)"
self.strategy.step_post_backward(
params=self.gauss_params,
optimizers=self.optimizers,
state=self.strategy_state,
step=self.step,
info=self.info,
packed=False,
)
return culls
def split_gaussians(self, split_mask, samps):
"""
This function splits gaussians that are too large
"""
n_splits = split_mask.sum().item()
CONSOLE.log(f"Splitting {split_mask.sum().item()/self.num_points} gaussians: {n_splits}/{self.num_points}")
centered_samples = torch.randn((samps * n_splits, 3), device=self.device) # Nx3 of axis-aligned scales
scaled_samples = (
torch.exp(self.scales[split_mask].repeat(samps, 1)) * centered_samples
) # how these scales are rotated
quats = self.quats[split_mask] / self.quats[split_mask].norm(dim=-1, keepdim=True) # normalize them first
rots = quat_to_rotmat(quats.repeat(samps, 1)) # how these scales are rotated
rotated_samples = torch.bmm(rots, scaled_samples[..., None]).squeeze()
new_means = rotated_samples + self.means[split_mask].repeat(samps, 1)
# step 2, sample new colors
new_features_dc = self.features_dc[split_mask].repeat(samps, 1)
new_features_rest = self.features_rest[split_mask].repeat(samps, 1, 1)
# step 3, sample new opacities
new_opacities = self.opacities[split_mask].repeat(samps, 1)
# step 4, sample new scales
size_fac = 1.6
new_scales = torch.log(torch.exp(self.scales[split_mask]) / size_fac).repeat(samps, 1)
self.scales[split_mask] = torch.log(torch.exp(self.scales[split_mask]) / size_fac)
# step 5, sample new quats
new_quats = self.quats[split_mask].repeat(samps, 1)
out = {
"means": new_means,
"features_dc": new_features_dc,
"features_rest": new_features_rest,
"opacities": new_opacities,
"scales": new_scales,
"quats": new_quats,
}
for name, param in self.gauss_params.items():
if name not in out:
out[name] = param[split_mask].repeat(samps, 1)
return out
def dup_gaussians(self, dup_mask):
"""
This function duplicates gaussians that are too small
"""
n_dups = dup_mask.sum().item()
CONSOLE.log(f"Duplicating {dup_mask.sum().item()/self.num_points} gaussians: {n_dups}/{self.num_points}")
new_dups = {}
for name, param in self.gauss_params.items():
new_dups[name] = param[dup_mask]
return new_dups
def get_training_callbacks(
self, training_callback_attributes: TrainingCallbackAttributes
) -> List[TrainingCallback]:
cbs = []
cbs.append(TrainingCallback([TrainingCallbackLocation.BEFORE_TRAIN_ITERATION], self.step_cb))
# The order of these matters
cbs.append(
TrainingCallback(
[TrainingCallbackLocation.AFTER_TRAIN_ITERATION],
self.after_train,
[TrainingCallbackLocation.BEFORE_TRAIN_ITERATION],
self.step_cb,
args=[training_callback_attributes.optimizers],
)
)
cbs.append(
TrainingCallback(
[TrainingCallbackLocation.AFTER_TRAIN_ITERATION],
self.refinement_after,
update_every_num_iters=self.config.refine_every,
args=[training_callback_attributes.optimizers],
self.step_post_backward,
)
)
return cbs
def step_cb(self, step):
def step_cb(self, optimizers: Optimizers, step):
self.step = step
self.optimizers = optimizers.optimizers
def get_gaussian_param_groups(self) -> Dict[str, List[Parameter]]:
# Here we explicitly use the means, scales as parameters so that the user can override this function and
@@ -788,7 +573,6 @@ class SplatfactoModel(Model):
colors_crop = torch.cat((features_dc_crop[:, None, :], features_rest_crop), dim=1)
BLOCK_WIDTH = 16 # this controls the tile size of rasterization, 16 is a good default
camera_scale_fac = self._get_downscale_factor()
camera.rescale_output_resolution(1 / camera_scale_fac)
viewmat = get_viewmat(optimized_camera_to_world)
@@ -812,9 +596,9 @@ class SplatfactoModel(Model):
colors_crop = torch.sigmoid(colors_crop).squeeze(1) # [N, 1, 3] -> [N, 3]
sh_degree_to_use = None
render, alpha, info = rasterization(
render, alpha, self.info = rasterization(
means=means_crop,
quats=quats_crop / quats_crop.norm(dim=-1, keepdim=True),
quats=quats_crop, # rasterization does normalization internally
scales=torch.exp(scales_crop),
opacities=torch.sigmoid(opacities_crop).squeeze(-1),
colors=colors_crop,
@@ -822,22 +606,21 @@ class SplatfactoModel(Model):
Ks=K, # [1, 3, 3]
width=W,
height=H,
tile_size=BLOCK_WIDTH,
packed=False,
near_plane=0.01,
far_plane=1e10,
render_mode=render_mode,
sh_degree=sh_degree_to_use,
sparse_grad=False,
absgrad=True,
absgrad=self.strategy.absgrad,
rasterize_mode=self.config.rasterize_mode,
# set some threshold to disregrad small gaussians for faster rendering.
# radius_clip=3.0,
)
if self.training and info["means2d"].requires_grad:
info["means2d"].retain_grad()
self.xys = info["means2d"] # [1, N, 2]
self.radii = info["radii"][0] # [N]
if self.training:
self.strategy.step_pre_backward(
self.gauss_params, self.optimizers, self.strategy_state, self.step, self.info
)
alpha = alpha[:, ...]
background = self._get_background_color()
+12 -4
View File
@@ -15,6 +15,7 @@
"""Helper utils for processing data into the nerfstudio format."""
import math
import random
import re
import shutil
import sys
@@ -126,6 +127,7 @@ def convert_video_to_images(
verbose: bool = False,
image_prefix: str = "frame_",
keep_image_dir: bool = False,
random_seed: Optional[int] = None,
) -> Tuple[List[str], int]:
"""Converts a video into a sequence of images.
@@ -138,6 +140,7 @@ def convert_video_to_images(
verbose: If True, logs the output of the command.
image_prefix: Prefix to use for the image filenames.
keep_image_dir: If True, don't delete the output directory if it already exists.
random_seed: If set, the seed used to choose the frames of the video
Returns:
A tuple containing summary of the conversion and the number of extracted frames.
"""
@@ -178,8 +181,6 @@ def convert_video_to_images(
start_y = crop_factor[0]
crop_cmd = f"crop=w=iw*{width}:h=ih*{height}:x=iw*{start_x}:y=ih*{start_y},"
spacing = num_frames // num_frames_target
downscale_chains = [f"[t{i}]scale=iw/{2**i}:ih/{2**i}[out{i}]" for i in range(num_downscales + 1)]
downscale_dirs = [Path(str(image_dir) + (f"_{2**i}" if i > 0 else "")) for i in range(num_downscales + 1)]
downscale_paths = [downscale_dirs[i] / f"{image_prefix}%05d.png" for i in range(num_downscales + 1)]
@@ -196,8 +197,15 @@ def convert_video_to_images(
ffmpeg_cmd += " -vsync vfr"
if spacing > 1:
CONSOLE.print("Number of frames to extract:", math.ceil(num_frames / spacing))
# Evenly distribute frame selection if random seed does not exist
spacing = num_frames // num_frames_target
if random_seed:
random.seed(random_seed)
frame_indices = sorted(random.sample(range(num_frames), num_frames_target))
select_cmd = "select='" + "+".join([f"eq(n\,{idx})" for idx in frame_indices]) + "',setpts=N/TB,"
CONSOLE.print(f"Extracting {num_frames_target} frames using seed {random_seed} random selection.")
elif spacing > 1:
CONSOLE.print(f"Extracting {math.ceil(num_frames / spacing)} frames in evenly spaced intervals")
select_cmd = f"thumbnail={spacing},setpts=N/TB,"
else:
CONSOLE.print("[bold red]Can't satisfy requested number of frames. Extracting all frames.")
@@ -16,7 +16,7 @@
import shutil
from dataclasses import dataclass
from typing import Literal
from typing import Literal, Optional
from nerfstudio.process_data import equirect_utils, process_data_utils
from nerfstudio.process_data.colmap_converter_to_nerfstudio_dataset import ColmapConverterToNerfstudioDataset
@@ -41,6 +41,10 @@ class VideoToNerfstudioDataset(ColmapConverterToNerfstudioDataset):
"""Feature matching method to use. Vocab tree is recommended for a balance of speed
and accuracy. Exhaustive is slower but more accurate. Sequential is faster but
should only be used for videos."""
random_seed: Optional[int] = None
"""Random seed to select video frames for training set"""
eval_random_seed: Optional[int] = None
"""Random seed to select video frames for eval set"""
def main(self) -> None:
"""Process video into a nerfstudio dataset."""
@@ -59,6 +63,7 @@ class VideoToNerfstudioDataset(ColmapConverterToNerfstudioDataset):
num_downscales=0,
crop_factor=(0.0, 0.0, 0.0, 0.0),
verbose=self.verbose,
random_seed=self.random_seed,
)
else:
# If we're not dealing with equirects we can downscale in one step.
@@ -71,6 +76,7 @@ class VideoToNerfstudioDataset(ColmapConverterToNerfstudioDataset):
verbose=self.verbose,
image_prefix="frame_train_" if self.eval_data is not None else "frame_",
keep_image_dir=False,
random_seed=self.random_seed,
)
if self.eval_data is not None:
summary_log_eval, num_extracted_frames_eval = process_data_utils.convert_video_to_images(
@@ -82,6 +88,7 @@ class VideoToNerfstudioDataset(ColmapConverterToNerfstudioDataset):
verbose=self.verbose,
image_prefix="frame_eval_",
keep_image_dir=True,
random_seed=self.eval_random_seed,
)
summary_log += summary_log_eval
num_extracted_frames += num_extracted_frames_eval
+16 -3
View File
@@ -73,8 +73,13 @@ def validate_pipeline(normal_method: str, normal_output_name: str, pipeline: Pip
directions = torch.ones_like(origins)
pixel_area = torch.ones_like(origins[..., :1])
camera_indices = torch.zeros_like(origins[..., :1])
metadata = {"directions_norm": torch.linalg.vector_norm(directions, dim=-1, keepdim=True)}
ray_bundle = RayBundle(
origins=origins, directions=directions, pixel_area=pixel_area, camera_indices=camera_indices
origins=origins,
directions=directions,
pixel_area=pixel_area,
camera_indices=camera_indices,
metadata=metadata,
)
outputs = pipeline.model(ray_bundle)
if normal_output_name not in outputs:
@@ -551,7 +556,7 @@ class ExportGaussianSplat(Exporter):
if not self.output_dir.exists():
self.output_dir.mkdir(parents=True)
_, pipeline, _, _ = eval_setup(self.load_config)
_, pipeline, _, _ = eval_setup(self.load_config, test_mode="inference")
assert isinstance(pipeline.model, SplatfactoModel)
@@ -659,9 +664,17 @@ class ExportGaussianSplat(Exporter):
n_after = np.sum(select)
if n_after < n_before:
CONSOLE.print(f"{n_before - n_after} NaN/Inf elements in {k}")
nan_count = np.sum(select) - n
# filter gaussians that have opacities < 1/255, because they are skipped in cuda rasterization
low_opacity_gaussians = (map_to_tensors["opacity"]).squeeze(axis=-1) < -5.5373 # logit(1/255)
lowopa_count = np.sum(low_opacity_gaussians)
select[low_opacity_gaussians] = 0
if np.sum(select) < n:
CONSOLE.print(f"values have NaN/Inf in map_to_tensors, only export {np.sum(select)}/{n}")
CONSOLE.print(
f"{nan_count} Gaussians have NaN/Inf and {lowopa_count} have low opacity, only export {np.sum(select)}/{n}"
)
for k, t in map_to_tensors.items():
map_to_tensors[k] = map_to_tensors[k][select]
count = np.sum(select)
+1 -1
View File
@@ -62,7 +62,7 @@ dependencies = [
"xatlas",
"trimesh>=3.20.2",
"timm==0.6.7",
"gsplat==1.0.0",
"gsplat==1.3.0",
"pytorch-msssim",
"pathos",
"packaging",
+113 -1
View File
@@ -2,13 +2,21 @@
Test misc data utils
"""
import os
import re
from pathlib import Path
from unittest import mock
import cv2
import numpy as np
from PIL import Image
from pyquaternion import Quaternion
from scipy.spatial.transform import Rotation
# TODO(1480) use pycolmap instead of colmap_parsing_utils
# import pycolmap
from nerfstudio.data.utils.colmap_parsing_utils import qvec2rotmat
from nerfstudio.process_data.process_data_utils import convert_video_to_images
def test_scalar_first_scalar_last_quaternions():
@@ -39,7 +47,7 @@ def test_scalar_first_scalar_last_quaternions():
# Expected Rotation matrix
# fmt: off
R_expected = np.array(
R_expected = np.array(
[
[ 0.81379768, -0.44096961, 0.37852231],
[ 0.46984631, 0.88256412, 0.01802831],
@@ -61,3 +69,107 @@ def test_scalar_first_scalar_last_quaternions():
# R = pycolmap.qvec_to_rotmat(wxyz)
R = qvec2rotmat(wxyz)
assert np.allclose(R, R_expected)
def test_process_video_conversion_with_seed(tmp_path: Path):
"""
Test convert_video_to_images by creating a mock video and ensuring correct frame extraction with seed.
"""
# Inner functions needed for the unit tests
def create_mock_video(video_path: Path, frame_dir: Path, num_frames=10, frame_rate=1):
"""Creates a mock video from a series of frames using OpenCV."""
first_frame = cv2.imread(str(frame_dir / "frame_0.png"))
height, width, _ = first_frame.shape
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
out = cv2.VideoWriter(str(video_path), fourcc, frame_rate, (width, height))
for i in range(num_frames):
frame_path = frame_dir / f"frame_{i}.png"
frame = cv2.imread(str(frame_path))
out.write(frame)
out.release()
def extract_frame_numbers(ffmpeg_command: str):
"""Extracts the frame numbers from the ffmpeg command"""
pattern = r"eq\(n\\,(\d+)\)"
matches = re.findall(pattern, ffmpeg_command)
frame_numbers = [int(match) for match in matches]
return frame_numbers
# Create a video directory with path video
video_dir = tmp_path / "video"
video_dir.mkdir(exist_ok=True)
# Set parameters for mock video
video_path = video_dir / "mock_video.mp4"
num_frames = 10
frame_height = 150
frame_width = 100
frame_rate = 1
# Create the mock video
for i in range(num_frames):
img = Image.new("RGB", (frame_width, frame_height), (0, 0, 0))
img.save(video_dir / f"frame_{i}.png")
create_mock_video(video_path, video_dir, num_frames=num_frames, frame_rate=frame_rate)
# Call convert_video_to_images
image_output_dir = tmp_path / "extracted_images"
num_frames_target = 5
num_downscales = 1
crop_factor = (0.0, 0.0, 0.0, 0.0)
# Mock missing COLMAP and ffmpeg in the dev env
old_path = os.environ.get("PATH", "")
os.environ["PATH"] = str(tmp_path / "mocked_bin") + f":{old_path}"
(tmp_path / "mocked_bin").mkdir()
(tmp_path / "mocked_bin" / "colmap").touch(mode=0o777)
(tmp_path / "mocked_bin" / "ffmpeg").touch(mode=0o777)
# Return value of 10 for the get_num_frames_in_video run_command call
with mock.patch("nerfstudio.process_data.process_data_utils.run_command", return_value="10") as mock_run_func:
summary_log, extracted_frame_count = convert_video_to_images(
video_path=video_path,
image_dir=image_output_dir,
num_frames_target=num_frames_target,
num_downscales=num_downscales,
crop_factor=crop_factor,
verbose=False,
random_seed=42,
)
assert mock_run_func.call_count == 2, f"Expected 2 calls, but got {mock_run_func.call_count}"
first_frames = extract_frame_numbers(mock_run_func.call_args[0][0])
assert len(first_frames) == 5, f"Expected 5 frames, but got {len(first_frames)}"
summary_log, extracted_frame_count = convert_video_to_images(
video_path=video_path,
image_dir=image_output_dir,
num_frames_target=num_frames_target,
num_downscales=num_downscales,
crop_factor=crop_factor,
verbose=False,
random_seed=42,
)
assert mock_run_func.call_count == 4, f"Expected 4 total calls, but got {mock_run_func.call_count}"
second_frames = extract_frame_numbers(mock_run_func.call_args[0][0])
assert len(second_frames) == 5, f"Expected 5 frames, but got {len(first_frames)}"
assert first_frames == second_frames
summary_log, extracted_frame_count = convert_video_to_images(
video_path=video_path,
image_dir=image_output_dir,
num_frames_target=num_frames_target,
num_downscales=num_downscales,
crop_factor=crop_factor,
verbose=False,
random_seed=52,
)
assert mock_run_func.call_count == 6, f"Expected 6 total calls, but got {mock_run_func.call_count}"
third_frames = extract_frame_numbers(mock_run_func.call_args[0][0])
assert len(third_frames) == 5, f"Expected 5 frames, but got {len(first_frames)}"
assert first_frames != third_frames