From 194b5d4e46a5c026359d9207f6e88988ef2e92ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=C3=A1=C5=A1=20Kulh=C3=A1nek?= Date: Thu, 12 Sep 2024 15:43:36 +0200 Subject: [PATCH 01/13] Fix docker attestation fail on forked repo PRs (#3423) --- .github/workflows/build_docker_image.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_docker_image.yml b/.github/workflows/build_docker_image.yml index 97cb77282..3dc60a322 100644 --- a/.github/workflows/build_docker_image.yml +++ b/.github/workflows/build_docker_image.yml @@ -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 From 7a0b8cbc89c0500984b016e3d73201f372ba8ef3 Mon Sep 17 00:00:00 2001 From: "J.Y." <132313008+jb-ye@users.noreply.github.com> Date: Fri, 13 Sep 2024 07:10:37 -0400 Subject: [PATCH 02/13] use gsplat strategy interface to simplify splatfacto (#3376) * use gsplat strategy interface to simplify splatfacto * change splatfacto-big densify_grad_thresh to 0.0005 * upgrade gsplat version * filter low opacities when exporting * remove num_sh_bases --------- Co-authored-by: Jianbo Ye Co-authored-by: Justin Kerr --- nerfstudio/configs/method_configs.py | 3 +- nerfstudio/models/splatfacto.py | 321 +++++---------------------- nerfstudio/scripts/exporter.py | 10 +- pyproject.toml | 2 +- 4 files changed, 63 insertions(+), 273 deletions(-) diff --git a/nerfstudio/configs/method_configs.py b/nerfstudio/configs/method_configs.py index e77ab130c..c9bc9f770 100644 --- a/nerfstudio/configs/method_configs.py +++ b/nerfstudio/configs/method_configs.py @@ -663,8 +663,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={ diff --git a/nerfstudio/models/splatfacto.py b/nerfstudio/models/splatfacto.py index 61d9eda19..28b8f0a1d 100644 --- a/nerfstudio/models/splatfacto.py +++ b/nerfstudio/models/splatfacto.py @@ -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() diff --git a/nerfstudio/scripts/exporter.py b/nerfstudio/scripts/exporter.py index 970b5a9c7..a76f6629f 100644 --- a/nerfstudio/scripts/exporter.py +++ b/nerfstudio/scripts/exporter.py @@ -620,9 +620,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) diff --git a/pyproject.toml b/pyproject.toml index 027ef1374..8614d92b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 106d427e804d65066d516dda9c2699f9bd9a9a8a Mon Sep 17 00:00:00 2001 From: Maximum Wilder-Smith Date: Mon, 16 Sep 2024 19:57:21 +0200 Subject: [PATCH 03/13] Minor update to generate splat files in inference mode (#3432) changed gaussian splat export to inference mode --- nerfstudio/scripts/exporter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nerfstudio/scripts/exporter.py b/nerfstudio/scripts/exporter.py index a76f6629f..ca019850f 100644 --- a/nerfstudio/scripts/exporter.py +++ b/nerfstudio/scripts/exporter.py @@ -547,7 +547,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) From 27b8e14798a49ab763d978bcb732916896618fc9 Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:44:31 -0400 Subject: [PATCH 04/13] Fix crop obb masking in pointcloud generation (#3433) --- nerfstudio/exporter/exporter_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nerfstudio/exporter/exporter_utils.py b/nerfstudio/exporter/exporter_utils.py index b87078bc9..9435a3b91 100644 --- a/nerfstudio/exporter/exporter_utils.py +++ b/nerfstudio/exporter/exporter_utils.py @@ -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) From 3e638da63571d391ef36c5906b1532555c1ca7db Mon Sep 17 00:00:00 2001 From: Anthony Tafoya <87080582+Anthony-Tafoya@users.noreply.github.com> Date: Tue, 17 Sep 2024 08:32:09 -0700 Subject: [PATCH 05/13] Adding Random Seed for Frame Processing (#3416) * Adding Random Seed for Frame Processing * Added Unit Tests * Updating Unit Tests for Ffmpeg * Make Logs More Detailed --------- Co-authored-by: Anthony-Tafoya Co-authored-by: J.Y. <132313008+jb-ye@users.noreply.github.com> --- nerfstudio/process_data/process_data_utils.py | 16 ++- .../video_to_nerfstudio_dataset.py | 9 +- tests/process_data/test_misc.py | 114 +++++++++++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/nerfstudio/process_data/process_data_utils.py b/nerfstudio/process_data/process_data_utils.py index b5b2391a0..3c9013abe 100644 --- a/nerfstudio/process_data/process_data_utils.py +++ b/nerfstudio/process_data/process_data_utils.py @@ -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.") diff --git a/nerfstudio/process_data/video_to_nerfstudio_dataset.py b/nerfstudio/process_data/video_to_nerfstudio_dataset.py index af17e7d6b..51a8a0b76 100644 --- a/nerfstudio/process_data/video_to_nerfstudio_dataset.py +++ b/nerfstudio/process_data/video_to_nerfstudio_dataset.py @@ -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 diff --git a/tests/process_data/test_misc.py b/tests/process_data/test_misc.py index 1b2404b51..23fc3453c 100644 --- a/tests/process_data/test_misc.py +++ b/tests/process_data/test_misc.py @@ -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 From 516fd7c9ac73e28db6a522df57d7b22e7b0d6756 Mon Sep 17 00:00:00 2001 From: Aayush Gupta <19579293+aayushg55@users.noreply.github.com> Date: Tue, 17 Sep 2024 12:31:42 -0700 Subject: [PATCH 06/13] Fix splatfacto crash in eval when using viewer (#3430) --- nerfstudio/engine/trainer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nerfstudio/engine/trainer.py b/nerfstudio/engine/trainer.py index 644d62a5b..cac31cf50 100644 --- a/nerfstudio/engine/trainer.py +++ b/nerfstudio/engine/trainer.py @@ -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) From e6941aca4746a7970ac08f6761af5d49d6785b65 Mon Sep 17 00:00:00 2001 From: Aayush Gupta <19579293+aayushg55@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:28:18 -0700 Subject: [PATCH 07/13] Zipnerf point cloud and tsdf mesh export support (#3429) Co-authored-by: J.Y. <132313008+jb-ye@users.noreply.github.com> --- nerfstudio/scripts/exporter.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nerfstudio/scripts/exporter.py b/nerfstudio/scripts/exporter.py index ca019850f..650eaebff 100644 --- a/nerfstudio/scripts/exporter.py +++ b/nerfstudio/scripts/exporter.py @@ -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: From 01d3f73e2c520c03e90c8a417fcf2a74b79e56df Mon Sep 17 00:00:00 2001 From: "J.Y." <132313008+jb-ye@users.noreply.github.com> Date: Tue, 24 Sep 2024 21:01:43 -0400 Subject: [PATCH 08/13] Fix #3417 (#3441) --- nerfstudio/cameras/camera_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nerfstudio/cameras/camera_utils.py b/nerfstudio/cameras/camera_utils.py index 3e8785033..13626f8f2 100644 --- a/nerfstudio/cameras/camera_utils.py +++ b/nerfstudio/cameras/camera_utils.py @@ -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) From 6da94443fd7ba6114197944a20a8b2de7c1b838d Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Sat, 28 Sep 2024 19:51:05 -0400 Subject: [PATCH 09/13] Change depth from float64 to float32 (#3453) --- nerfstudio/data/utils/data_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nerfstudio/data/utils/data_utils.py b/nerfstudio/data/utils/data_utils.py index 11ce74d9d..51d57da67 100644 --- a/nerfstudio/data/utils/data_utils.py +++ b/nerfstudio/data/utils/data_utils.py @@ -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]) From 4c7297c75a9459488d672510af246caed35438b8 Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Mon, 30 Sep 2024 20:31:49 -0400 Subject: [PATCH 10/13] Speed up PairPixelSampler (#3452) --- nerfstudio/data/pixel_samplers.py | 121 +++++++++++++----- nerfstudio/data/utils/pixel_sampling_utils.py | 22 ++-- 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/nerfstudio/data/pixel_samplers.py b/nerfstudio/data/pixel_samplers.py index 080b4016a..a0f1dd67c 100644 --- a/nerfstudio/data/pixel_samplers.py +++ b/nerfstudio/data/pixel_samplers.py @@ -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 @@ -473,6 +509,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 +548,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) diff --git a/nerfstudio/data/utils/pixel_sampling_utils.py b/nerfstudio/data/utils/pixel_sampling_utils.py index d49aa22f1..ae77b3287 100644 --- a/nerfstudio/data/utils/pixel_sampling_utils.py +++ b/nerfstudio/data/utils/pixel_sampling_utils.py @@ -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. From 56556d22fa0955b79270568e2aae92f18e071af4 Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:54:28 -0400 Subject: [PATCH 11/13] Rejection sample mask for patch pixel sampler (#3457) Use mask rejection sampling in PatchPixelSampler --- nerfstudio/data/pixel_samplers.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/nerfstudio/data/pixel_samplers.py b/nerfstudio/data/pixel_samplers.py index a0f1dd67c..63b4a35b0 100644 --- a/nerfstudio/data/pixel_samplers.py +++ b/nerfstudio/data/pixel_samplers.py @@ -420,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): @@ -458,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) From 334a1c8afeaccb233f164d830b554b2aa44eb3c1 Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:59:31 -0400 Subject: [PATCH 12/13] Make PixelSampler the depth-nerfacto default (#3454) * Make PixelSampler the depth-nerfacto default * Add note about PairPixelSampler and DepthLossType.SPARSENERF_RANKING --- nerfstudio/configs/method_configs.py | 2 -- nerfstudio/models/depth_nerfacto.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/nerfstudio/configs/method_configs.py b/nerfstudio/configs/method_configs.py index c9bc9f770..9decb99ab 100644 --- a/nerfstudio/configs/method_configs.py +++ b/nerfstudio/configs/method_configs.py @@ -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, diff --git a/nerfstudio/models/depth_nerfacto.py b/nerfstudio/models/depth_nerfacto.py index 1a47ab163..ee66a8114 100644 --- a/nerfstudio/models/depth_nerfacto.py +++ b/nerfstudio/models/depth_nerfacto.py @@ -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): From 866a069777e422b704be620e05a53349d8e789e0 Mon Sep 17 00:00:00 2001 From: Nicholas Pfaff <53228351+nepfaff@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:39:26 -0400 Subject: [PATCH 13/13] Keep depth_image on CPU (#3449) Co-authored-by: J.Y. <132313008+jb-ye@users.noreply.github.com> --- nerfstudio/data/datasets/depth_dataset.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nerfstudio/data/datasets/depth_dataset.py b/nerfstudio/data/datasets/depth_dataset.py index f2f1ab86a..d4e22e3b6 100644 --- a/nerfstudio/data/datasets/depth_dataset.py +++ b/nerfstudio/data/datasets/depth_dataset.py @@ -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