3dai / trellis /utils /postprocessing_utils.py
Bobby
Fix dark 3D AI exports: set metallicFactor so GLB materials are not fully metallic
0f78268
Raw History Blame Contribute Delete
43.3 kB
from typing import *
import time
import numpy as np
import torch
import utils3d
import nvdiffrast.torch as dr
from tqdm import tqdm
import trimesh
import trimesh.visual
import xatlas
import pyvista as pv
from pymeshfix import _meshfix
import igraph
import cv2
from PIL import Image
from .random_utils import sphere_hammersley_sequence
from .render_utils import render_multiview, render_multiview_gpu
from ..renderers import GaussianRenderer
from ..representations import Strivec, Gaussian, MeshExtractResult
from api_spz.core.exceptions import CancelledException
BAKE_SYNC_FREE = True
TEXTURE_OPT_FUSED_ADAM = True
def postprocess_mesh(
vertices: Union[np.ndarray, torch.Tensor],
faces: Union[np.ndarray, torch.Tensor],
simplify: bool = True,
simplify_ratio: float = 0.9,
fill_holes: bool = True,
fill_holes_max_hole_size: float = 0.04,
fill_holes_max_hole_nbe: int = 32,
fill_holes_resolution: int = 1024,
fill_holes_num_views: int = 1000,
debug: bool = False,
verbose: bool = False,
decimate_backend: str = "pyvista",
timings: Optional[dict] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Postprocess a mesh by simplifying, removing invisible faces, and removing isolated pieces.
Maintains data on GPU where possible and uses fp16 precision.
Args:
vertices: Vertices of the mesh. Shape (V, 3). Can be numpy array or torch tensor.
faces: Faces of the mesh. Shape (F, 3). Can be numpy array or torch tensor.
simplify: Whether to simplify the mesh, using quadric edge collapse.
simplify_ratio: Ratio of faces to keep after simplification.
fill_holes: Whether to fill holes in the mesh.
fill_holes_max_hole_size: Maximum area of a hole to fill.
fill_holes_max_hole_nbe: Maximum number of boundary edges of a hole to fill.
fill_holes_resolution: Resolution of the rasterization.
fill_holes_num_views: Number of views to rasterize the mesh.
verbose: Whether to print progress.
Returns:
Tuple[torch.Tensor, torch.Tensor]: Processed vertices and faces as torch tensors on GPU
"""
if verbose:
tqdm.write(f'Before postprocess: {vertices.shape[0]} vertices, {faces.shape[0]} faces')
# Convert inputs to torch tensors if needed and ensure float32
if isinstance(vertices, np.ndarray):
vertices = torch.from_numpy(vertices).float()
if isinstance(faces, np.ndarray):
faces = torch.from_numpy(faces)
# Ensure tensors are on GPU and in float32
vertices = vertices.cuda().float()
faces = faces.cuda()
started = time.perf_counter()
if timings is not None:
timings["faces_in"] = int(faces.shape[0])
# Simplify
if simplify and simplify_ratio > 0 and decimate_backend == "cumesh":
vertices, faces = _decimate_on_gpu(vertices, faces, int(faces.shape[0] * (1 - simplify_ratio)))
elif simplify and simplify_ratio > 0:
vertices_cpu = vertices.cpu().numpy() # Already float32
faces_cpu = faces.cpu().numpy()
mesh = pv.PolyData(vertices_cpu, np.concatenate([np.full((faces_cpu.shape[0], 1), 3), faces_cpu], axis=1))
mesh = mesh.decimate(simplify_ratio, progress_bar=verbose)
vertices = torch.tensor(mesh.points, device='cuda') # Will stay float32
faces = torch.tensor(mesh.faces.reshape(-1, 4)[:, 1:], device='cuda', dtype=torch.int32)
if verbose:
tqdm.write(f'After decimate: {vertices.shape[0]} vertices, {faces.shape[0]} faces')
started = mark_stage(timings, "decimate", started)
if timings is not None:
timings["faces_decimated"] = int(faces.shape[0])
# Remove invisible faces - already operates on GPU
if fill_holes:
vertices, faces = _fill_holes(
vertices, faces,
max_hole_size=fill_holes_max_hole_size,
max_hole_nbe=fill_holes_max_hole_nbe,
resolution=fill_holes_resolution,
num_views=fill_holes_num_views,
debug=debug,
verbose=verbose,
timings=timings,
)
if verbose:
tqdm.write(f'After remove invisible faces: {vertices.shape[0]} vertices, {faces.shape[0]} faces')
mark_stage(timings, "fill_holes", started)
return vertices, faces
def mark_stage(timings: Optional[dict], name: str, started: float) -> float:
if timings is None:
return started
torch.cuda.synchronize()
now = time.perf_counter()
timings[name] = round(now - started, 3)
return now
DECIMATE_START_THRESHOLD = 1e-6
DECIMATE_THRESHOLD_GROWTH = 2.0
DECIMATE_MIN_PROGRESS = 1e-2
DECIMATE_MAX_THRESHOLD = 1e3
def _decimate_on_gpu(vertices: torch.Tensor, faces: torch.Tensor, target_faces: int) -> Tuple[torch.Tensor, torch.Tensor]:
import cumesh
mesh = cumesh.CuMesh()
mesh.init(vertices.float().contiguous(), faces.int().contiguous())
remaining, threshold = faces.shape[0], DECIMATE_START_THRESHOLD
while remaining > target_faces and threshold < DECIMATE_MAX_THRESHOLD:
_, left = mesh.cu_mesh.simplify_step(1e-2, 1e-3, threshold, False)
if (remaining - left) / remaining < DECIMATE_MIN_PROGRESS:
threshold *= DECIMATE_THRESHOLD_GROWTH
remaining = left
vertices, faces = mesh.read()
return vertices.float(), faces.int()
def parametrize_mesh(vertices: Union[np.ndarray, torch.Tensor], faces: Union[np.ndarray, torch.Tensor], backend="xatlas"):
"""
Parametrize a mesh to a texture space, using xatlas.
Args:
vertices: Vertices of the mesh. Shape (V, 3). Can be numpy array or torch tensor.
faces: Faces of the mesh. Shape (F, 3). Can be numpy array or torch tensor.
Returns:
Tuple[np.ndarray, np.ndarray, np.ndarray]: Remapped vertices, faces, and UV coordinates
"""
if backend == "cumesh":
import cumesh
mesh = cumesh.CuMesh()
mesh.init(
torch.as_tensor(vertices, device="cuda", dtype=torch.float32).contiguous(),
torch.as_tensor(faces, device="cuda", dtype=torch.int32).contiguous(),
)
return tuple(value.cpu().numpy() for value in mesh.uv_unwrap())
if backend != "xatlas":
raise ValueError(f"Unknown UV backend: {backend}")
# Convert to numpy if needed
if isinstance(vertices, torch.Tensor):
vertices = vertices.detach().cpu().numpy()
if isinstance(faces, torch.Tensor):
faces = faces.detach().cpu().numpy()
# Ensure correct dtypes for xatlas
vertices = vertices.astype(np.float32)
faces = faces.astype(np.uint32)
# Run parametrization
vmapping, indices, uvs = xatlas.parametrize(vertices, faces)
# Apply remapping
vertices = vertices[vmapping]
faces = indices
return vertices, faces, uvs
GUTTER_DILATION_STEPS = 8
def _pull_push_fill(texture: torch.Tensor, covered: torch.Tensor) -> torch.Tensor:
values = (texture * covered[..., None]).permute(2, 0, 1)[None]
weights = covered.float()[None, None]
for _ in range(GUTTER_DILATION_STEPS):
value_sum = torch.nn.functional.avg_pool2d(values, 3, 1, 1)
weight_sum = torch.nn.functional.avg_pool2d(weights, 3, 1, 1)
reached = (weight_sum > 0) & (weights == 0)
values = torch.where(reached, value_sum / weight_sum.clamp_min(1e-8), values)
weights = torch.where(reached, torch.ones_like(weights), weights)
edge_filled = values[0].permute(1, 2, 0)
covered = weights[0, 0] > 0
pyramid = [(values, weights)]
while min(values.shape[-2:]) > 1:
values = torch.nn.functional.avg_pool2d(values, 2, ceil_mode=True)
weights = torch.nn.functional.avg_pool2d(weights, 2, ceil_mode=True)
pyramid.append((values, weights))
filled = values / weights.clamp_min(1e-8)
for values, weights in reversed(pyramid[:-1]):
coarse = torch.nn.functional.interpolate(filled, size=values.shape[-2:], mode="bilinear", align_corners=False)
filled = torch.where(weights > 0, values / weights.clamp_min(1e-8), coarse)
return torch.where(covered[..., None], edge_filled, filled[0].permute(1, 2, 0))
def fill_texture_gutters(texture: torch.Tensor, covered: torch.Tensor, method: str = "gpu") -> np.ndarray:
"""Fill texels outside the UV charts so bilinear and mip sampling at chart borders stays on-color."""
if method == "cv2":
image = np.clip(texture.cpu().numpy() * 255, 0, 255).astype(np.uint8)
hole = (~covered).cpu().numpy().astype(np.uint8)
return cv2.inpaint(image, hole, 3, cv2.INPAINT_TELEA)
if method != "gpu":
raise ValueError(f"Unknown gutter fill: {method}")
if covered.any():
texture = _pull_push_fill(texture.float(), covered)
return np.clip(texture.cpu().numpy() * 255, 0, 255).astype(np.uint8)
def bake_texture(
vertices: np.array,
faces: np.array,
uvs: np.array,
observations: List[np.array],
masks: List[np.array],
extrinsics: List[np.array],
intrinsics: List[np.array],
texture_size: int = 2048,
near: float = 0.1,
far: float = 10.0,
mode: Literal['fast', 'opt'] = 'opt',
lambda_tv: float = 1e-2,
verbose: bool = False,
cancel_event=None,
optimization_steps: int = 1000,
gutter_fill: str = "gpu",
texture_init: str = "zeros",
texture_lr: float = 1e-2,
timings: Optional[dict] = None,
):
"""
Bake texture to a mesh from multiple observations.
Args:
vertices (np.array): Vertices of the mesh. Shape (V, 3).
faces (np.array): Faces of the mesh. Shape (F, 3).
uvs (np.array): UV coordinates of the mesh. Shape (V, 2).
observations (List[np.array]): List of observations. Each observation is a 2D image. Shape (H, W, 3).
masks (List[np.array]): List of masks. Each mask is a 2D image. Shape (H, W).
extrinsics (List[np.array]): List of extrinsics. Shape (4, 4).
intrinsics (List[np.array]): List of intrinsics. Shape (3, 3).
texture_size (int): Size of the texture.
near (float): Near plane of the camera.
far (float): Far plane of the camera.
mode (Literal['fast', 'opt']): Mode of texture baking.
lambda_tv (float): Weight of total variation loss in optimization.
verbose (bool): Whether to print progress.
"""
vertices = torch.tensor(vertices).cuda()
faces = torch.tensor(faces.astype(np.int32)).cuda()
uvs = torch.tensor(uvs).cuda()
started = time.perf_counter()
if isinstance(observations, torch.Tensor):
observations = list((observations.double() / 255.0).half())
masks = list(masks)
else:
observations = [torch.tensor(obs / 255.0, dtype=torch.float16).cuda() for obs in observations] # Keep observations as float16 if desired for memory
masks = [torch.tensor(m>0).bool().cuda() for m in masks]
views = [utils3d.torch.extrinsics_to_view(torch.tensor(extr).cuda()) for extr in extrinsics]
projections = [utils3d.torch.intrinsics_to_perspective(torch.tensor(intr).cuda(), near, far) for intr in intrinsics]
if mode == 'fast' or texture_init == 'fast':
texture = torch.zeros((texture_size * texture_size, 3), dtype=torch.float32).cuda()
texture_weights = torch.zeros((texture_size * texture_size), dtype=torch.float32).cuda()
rastctx = utils3d.torch.RastContext(backend='cuda')
for observation, view, projection, mask_in in tqdm(zip(observations, views, projections, masks), total=len(observations), disable=not verbose, desc='Texture baking (fast)'):
if cancel_event and cancel_event.is_set():
raise CancelledException(f"Cancelled the texture baking (fast).")
with torch.no_grad():
rast = utils3d.torch.rasterize_triangle_faces(
rastctx, vertices[None], faces, observation.shape[1], observation.shape[0], uv=uvs[None], view=view, projection=projection
)
uv_map = rast['uv'][0].detach().flip(0)
mask = rast['mask'][0].detach().bool() & mask_in
# nearest neighbor interpolation
uv_map = (uv_map * texture_size).floor().long()
obs = observation[mask]
uv_map = uv_map[mask]
idx = uv_map[:, 0] + (texture_size - uv_map[:, 1] - 1) * texture_size
texture = texture.scatter_add(0, idx.view(-1, 1).expand(-1, 3), obs.float())
texture_weights = texture_weights.scatter_add(0, idx, torch.ones((obs.shape[0]), dtype=torch.float32, device=texture.device))
mask = texture_weights > 0
texture[mask] /= texture_weights[mask][:, None]
texture = texture.reshape(texture_size, texture_size, 3)
covered = mask.reshape(texture_size, texture_size)
if mode == 'fast':
return fill_texture_gutters(texture, covered, gutter_fill)
initial_texture = texture.flip(0)
if mode == 'opt':
rastctx = utils3d.torch.RastContext(backend='cuda')
observations = [observations.flip(0) for observations in observations]
masks = [m.flip(0) for m in masks]
_uv = []
_uv_dr = []
for observation, view, projection in tqdm(zip(observations, views, projections), total=len(views), disable=not verbose, desc='Texture baking (opt): UV'):
if cancel_event and cancel_event.is_set():
raise CancelledException(f"Cancelled the texture baking (opt).")
with torch.no_grad():
rast = utils3d.torch.rasterize_triangle_faces(
rastctx, vertices[None], faces, observation.shape[1], observation.shape[0], uv=uvs[None], view=view, projection=projection
)
_uv.append(rast['uv'].detach())
_uv_dr.append(rast['uv_dr'].detach())
if BAKE_SYNC_FREE:
pixels = [mask.reshape(-1).nonzero().squeeze(1) for mask in masks]
targets = [obs.reshape(-1, 3).index_select(0, idx) for obs, idx in zip(observations, pixels)]
initial = initial_texture[None] if texture_init == 'fast' else torch.zeros((1, texture_size, texture_size, 3))
texture = torch.nn.Parameter(initial.to(dtype=torch.float32, device='cuda').contiguous())
optimizer = torch.optim.Adam([texture], betas=(0.5, 0.9), lr=texture_lr, fused=TEXTURE_OPT_FUSED_ADAM or None)
started = mark_stage(timings, "texture_prep", started)
def exp_anealing(optimizer, step, total_steps, start_lr, end_lr):
return start_lr * (end_lr / start_lr) ** (step / total_steps)
def cosine_anealing(optimizer, step, total_steps, start_lr, end_lr):
return end_lr + 0.5 * (start_lr - end_lr) * (1 + np.cos(np.pi * step / total_steps))
def tv_loss(texture):
return torch.nn.functional.l1_loss(texture[:, :-1, :, :], texture[:, 1:, :, :]) + \
torch.nn.functional.l1_loss(texture[:, :, :-1, :], texture[:, :, 1:, :])
max_mip_level = (texture_size & -texture_size).bit_length() - 1 if texture_size & (texture_size - 1) else None
total_steps = optimization_steps
with tqdm(total=total_steps, disable=not verbose, desc='Texture baking (opt): optimizing') as pbar:
for step in range(total_steps):
optimizer.zero_grad()
selected = np.random.randint(0, len(views))
uv, uv_dr, observation, mask = _uv[selected], _uv_dr[selected], observations[selected], masks[selected]
render = dr.texture(texture, uv, uv_dr, max_mip_level=max_mip_level)[0]
if BAKE_SYNC_FREE:
loss = torch.nn.functional.l1_loss(render.reshape(-1, 3).index_select(0, pixels[selected]), targets[selected])
else:
loss = torch.nn.functional.l1_loss(render[mask], observation[mask])
if lambda_tv > 0:
loss += lambda_tv * tv_loss(texture)
loss.backward()
optimizer.step()
# annealing
optimizer.param_groups[0]['lr'] = cosine_anealing(optimizer, step, total_steps, texture_lr, 1e-5)
if verbose:
pbar.set_postfix({'loss': loss.item()})
pbar.update()
if cancel_event and cancel_event.is_set():
raise CancelledException(f"Cancelled texture optimization at step {step}/{total_steps}.")
started = mark_stage(timings, "texture_opt", started)
covered = utils3d.torch.rasterize_triangle_faces(
rastctx, (uvs * 2 - 1)[None], faces, texture_size, texture_size
)['mask'][0].detach() > 0
texture = fill_texture_gutters(texture[0].flip(0).detach(), covered, gutter_fill)
mark_stage(timings, "texture_gutter", started)
else:
raise ValueError(f'Unknown mode: {mode}')
return texture
def to_glb(
app_rep: Union[Strivec, Gaussian],
mesh: MeshExtractResult,
simplify: float = 0.95,
fill_holes: bool = True,
fill_holes_max_size: float = 0.04,
texture_size: int = 1024,
debug: bool = False,
verbose: bool = False,
cancel_event = None,
fill_holes_num_views: int = 1000,
texture_optimization_steps: int = 1000,
uv_backend: str = "xatlas",
texture_mode: str = "opt",
render_views: int = 30,
render_resolution: int = 1024,
gutter_fill: str = "gpu",
decimate_backend: str = "pyvista",
timings: Optional[dict] = None,
texture_init: str = "zeros",
texture_lr: float = 1e-2,
) -> trimesh.Trimesh:
"""
Convert a generated asset to a glb file.
Args:
app_rep (Union[Strivec, Gaussian]): Appearance representation.
mesh (MeshExtractResult): Extracted mesh.
simplify (float): Ratio of faces to remove in simplification.
fill_holes (bool): Whether to fill holes in the mesh.
fill_holes_max_size (float): Maximum area of a hole to fill.
texture_size (int): Size of the texture.
debug (bool): Whether to print debug information.
verbose (bool): Whether to print progress.
"""
vertices = mesh.vertices
faces = mesh.faces
vertices, faces = postprocess_mesh(
vertices, faces,
simplify=simplify > 0,
simplify_ratio=simplify,
fill_holes=fill_holes,
fill_holes_max_hole_size=fill_holes_max_size,
fill_holes_max_hole_nbe=int(250 * np.sqrt(1-simplify)),
fill_holes_resolution=1024,
fill_holes_num_views=fill_holes_num_views,
debug=debug,
verbose=verbose,
decimate_backend=decimate_backend,
timings=timings,
)
started = time.perf_counter()
# parametrize mesh (converts to CPU numpy internally)
vertices, faces, uvs = parametrize_mesh(vertices, faces, backend=uv_backend)
started = mark_stage(timings, "uv", started)
# bake texture
if BAKE_SYNC_FREE:
observations, extrinsics, intrinsics = render_multiview_gpu(app_rep, resolution=render_resolution, nviews=render_views)
masks = (observations > 0).any(dim=-1)
else:
observations, extrinsics, intrinsics = render_multiview(app_rep, resolution=render_resolution, nviews=render_views)
masks = [np.any(observation > 0, axis=-1) for observation in observations]
extrinsics = [extrinsics[i].cpu().numpy() for i in range(len(extrinsics))]
intrinsics = [intrinsics[i].cpu().numpy() for i in range(len(intrinsics))]
started = mark_stage(timings, "render", started)
texture = bake_texture(
vertices, faces, uvs,
observations, masks, extrinsics, intrinsics,
texture_size=texture_size, mode=texture_mode,
lambda_tv=0.01,
verbose=verbose,
cancel_event=cancel_event,
optimization_steps=texture_optimization_steps,
gutter_fill=gutter_fill,
texture_init=texture_init,
texture_lr=texture_lr,
timings=timings,
)
mark_stage(timings, "texture", started)
if timings is not None:
timings["faces"] = int(faces.shape[0])
texture = Image.fromarray(texture)
# rotate mesh (from z-up to y-up)
vertices = vertices @ np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]])
material = trimesh.visual.material.PBRMaterial(
metallicFactor=0.0,
roughnessFactor=1.0,
baseColorTexture=texture,
baseColorFactor=np.array([255, 255, 255, 255], dtype=np.uint8)
)
mesh = trimesh.Trimesh(vertices, faces, visual=trimesh.visual.TextureVisuals(uv=uvs, material=material))
return mesh
def simplify_gs(
gs: Gaussian,
simplify: float = 0.95,
verbose: bool = True,
):
"""
Simplify 3D Gaussians with aggressive pruning
NOTE: this function is not used in the current implementation for the unsatisfactory performance.
Args:
gs (Gaussian): 3D Gaussian.
simplify (float): Ratio of Gaussians to remove in simplification.
"""
# if simplify <= 0:
# return gs
try:
# Move everything to CUDA and ensure float32 precision
device = torch.device('cuda')
# Convert all gaussian parameters to float32
gs._features_dc = gs._features_dc.float()
if gs._features_rest is not None:
gs._features_rest = gs._features_rest.float()
gs._opacity = gs._opacity.float()
gs._rotation = gs._rotation.float()
gs._scaling = gs._scaling.float()
gs._xyz = gs._xyz.float()
# Get initial opacity and ensure proper dimensions
initial_opacity = gs.get_opacity.squeeze()
if initial_opacity.dim() == 0:
initial_opacity = initial_opacity.unsqueeze(0)
# More aggressive initial pruning
with torch.no_grad():
opacity_threshold = 0.1 # Increased from 0.05
initial_mask = initial_opacity > opacity_threshold
# Handle case where no points meet threshold
if not initial_mask.any():
num_keep = max(int(0.1 * initial_opacity.shape[0]), 1)
_, top_indices = initial_opacity.topk(num_keep)
initial_mask = torch.zeros_like(initial_mask, dtype=torch.bool)
initial_mask[top_indices] = True
# Apply mask and ensure at least one point remains
if initial_mask.sum() == 0:
max_idx = torch.argmax(initial_opacity)
initial_mask[max_idx] = True
gs._xyz = gs._xyz[initial_mask]
gs._rotation = gs._rotation[initial_mask]
gs._scaling = gs._scaling[initial_mask]
gs._opacity = gs._opacity[initial_mask]
gs._features_dc = gs._features_dc[initial_mask]
gs._features_rest = gs._features_rest[initial_mask] if gs._features_rest is not None else None
if verbose:
print(f"Initial pruning: kept {initial_mask.sum().item()} points out of {len(initial_mask)}")
# Early return if too few points
if gs._xyz.shape[0] < 2:
if verbose:
print("Too few points remain after initial pruning, returning original gaussian")
return gs
# Render multiview observations with reduced views
observations, extrinsics, intrinsics = render_multiview(gs, resolution=512, nviews=30)
observations = [torch.tensor(obs / 255.0, dtype=torch.float32, device=device).permute(2, 0, 1) for obs in observations]
extrinsics = [e.float() for e in extrinsics]
intrinsics = [i.float() for i in intrinsics]
# Initialize renderer with smaller resolution
renderer = GaussianRenderer({
"resolution": 512,
"near": 0.8,
"far": 1.6,
"ssaa": 1,
"bg_color": (0,0,0),
})
# Clone Gaussian parameters
new_gs = Gaussian(**gs.init_params)
new_gs._features_dc = gs._features_dc.clone().to(device, dtype=torch.float32)
new_gs._features_rest = gs._features_rest.clone().to(device, dtype=torch.float32) if gs._features_rest is not None else None
new_gs._opacity = torch.nn.Parameter(gs._opacity.clone().to(device, dtype=torch.float32))
new_gs._rotation = torch.nn.Parameter(gs._rotation.clone().to(device, dtype=torch.float32))
new_gs._scaling = torch.nn.Parameter(gs._scaling.clone().to(device, dtype=torch.float32))
new_gs._xyz = torch.nn.Parameter(gs._xyz.clone().to(device, dtype=torch.float32))
# Get initial point count and set target
current_points = new_gs._xyz.shape[0]
target_ratio = max(0.1, 1 - simplify * 1.2) # Ensure we keep at least 10% of points
num_target = max(int(target_ratio * current_points), 2) # Ensure at least 2 points remain
if verbose:
print(f"Starting optimization with {current_points} points, target: {num_target}")
# Optimization parameters
start_lr = [5e-4, 5e-3, 0.025, 0.1]
end_lr = [5e-6, 5e-5, 0.00025, 0.001]
optimizer = torch.optim.Adam([
{"params": new_gs._xyz, "lr": start_lr[0]},
{"params": new_gs._rotation, "lr": start_lr[1]},
{"params": new_gs._scaling, "lr": start_lr[2]},
{"params": new_gs._opacity, "lr": start_lr[3]},
], lr=start_lr[0])
def cosine_anealing(optimizer, step, total_steps, start_lr, end_lr):
return end_lr + 0.5 * (start_lr - end_lr) * (1 + np.cos(np.pi * step / total_steps))
_zeta = new_gs.get_opacity.clone().detach().squeeze().float()
if _zeta.dim() == 0:
_zeta = _zeta.unsqueeze(0)
_lambda = torch.zeros_like(_zeta, dtype=torch.float32)
_delta = 1e-6
_interval = 5
total_steps = 500
with tqdm(total=total_steps, disable=not verbose, desc='Simplifying Gaussian') as pbar:
for i in range(total_steps):
try:
# More frequent pruning
if i % 50 == 0 and new_gs._xyz.shape[0] > 2: # Only prune if we have enough points
with torch.cuda.amp.autocast(enabled=False):
opacity = new_gs.get_opacity.squeeze()
if opacity.dim() == 0:
opacity = opacity.unsqueeze(0)
mask = opacity > opacity_threshold
if not mask.any(): # If all would be pruned, keep top points
num_keep = max(int(0.1 * len(mask)), 2)
_, top_indices = opacity.topk(min(num_keep, len(mask)))
mask = torch.zeros_like(mask, dtype=torch.bool)
mask[top_indices] = True
# Ensure we keep at least 2 points
if mask.sum() < 2:
_, top_indices = opacity.topk(2)
mask = torch.zeros_like(mask, dtype=torch.bool)
mask[top_indices] = True
# Apply mask
new_gs._xyz = torch.nn.Parameter(new_gs._xyz[mask].float())
new_gs._rotation = torch.nn.Parameter(new_gs._rotation[mask].float())
new_gs._scaling = torch.nn.Parameter(new_gs._scaling[mask].float())
new_gs._opacity = torch.nn.Parameter(new_gs._opacity[mask].float())
new_gs._features_dc = new_gs._features_dc[mask].float()
new_gs._features_rest = new_gs._features_rest[mask].float() if new_gs._features_rest is not None else None
# Update optimization variables
_zeta = _zeta[mask].float()
_lambda = _lambda[mask].float()
# Update optimizer state
optimizer = torch.optim.Adam([
{"params": new_gs._xyz, "lr": start_lr[0]},
{"params": new_gs._rotation, "lr": start_lr[1]},
{"params": new_gs._scaling, "lr": start_lr[2]},
{"params": new_gs._opacity, "lr": start_lr[3]},
], lr=start_lr[0])
with torch.cuda.amp.autocast(enabled=False):
opacity = new_gs.get_opacity.squeeze().float()
if opacity.dim() == 0:
opacity = opacity.unsqueeze(0)
current_points = opacity.shape[0]
# Sparsification
if i % _interval == 0 and current_points > 2:
_zeta = _lambda + opacity.detach()
if current_points > num_target:
k = min(num_target, current_points - 2) # Keep at least 2 points
if k > 0:
index = _zeta.topk(k)[1]
_m = torch.ones_like(_zeta, dtype=torch.bool)
_m[index] = 0
_zeta[_m] = 0
_lambda = _lambda + opacity.detach() - _zeta
# Sample random view
view_idx = np.random.randint(len(observations))
observation = observations[view_idx].float()
extrinsic = extrinsics[view_idx].float()
intrinsic = intrinsics[view_idx].float()
# Render and compute loss
color = renderer.render(new_gs, extrinsic, intrinsic)['color'].float()
rgb_loss = torch.nn.functional.l1_loss(color, observation)
loss = rgb_loss + _delta * torch.sum(torch.pow(_lambda + opacity - _zeta, 2))
# Optimization step
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Update learning rates
for j in range(len(optimizer.param_groups)):
optimizer.param_groups[j]['lr'] = cosine_anealing(optimizer, i, total_steps, start_lr[j], end_lr[j])
# Update progress bar
if not torch.isnan(rgb_loss).any():
pbar.set_postfix({
'loss': rgb_loss.item(),
'num': current_points,
'lambda': _lambda.mean().item()
})
pbar.update()
except RuntimeError as e:
if "out of memory" in str(e):
torch.cuda.empty_cache()
continue
else:
raise e
# Final pruning with safety check
with torch.no_grad():
opacity = new_gs.get_opacity.squeeze()
if opacity.dim() == 0:
opacity = opacity.unsqueeze(0)
final_mask = opacity > opacity_threshold
if not final_mask.any() or final_mask.sum() < 2:
num_keep = max(int(0.1 * len(opacity)), 2)
_, top_indices = opacity.topk(min(num_keep, len(opacity)))
final_mask = torch.zeros_like(final_mask, dtype=torch.bool)
final_mask[top_indices] = True
new_gs._xyz = new_gs._xyz.data[final_mask].float()
new_gs._rotation = new_gs._rotation.data[final_mask].float()
new_gs._scaling = new_gs._scaling.data[final_mask].float()
new_gs._opacity = new_gs._opacity.data[final_mask].float()
new_gs._features_dc = new_gs._features_dc[final_mask].float()
new_gs._features_rest = new_gs._features_rest[final_mask].float() if new_gs._features_rest is not None else None
if verbose:
print(f"Final number of points: {final_mask.sum().item()}")
return new_gs
except Exception as e:
print(f"Error in simplify_gs: {str(e)}")
print(f"Error details: {str(e.__class__.__name__)}")
import traceback
traceback.print_exc()
return gs # Return original gaussian if simplification fails
def _visible_outer_faces(visibility: torch.Tensor, components: List[torch.Tensor]) -> torch.Tensor:
"""Per component, mark faces seen more often than its 75th visibility percentile clamped to [0.25, 0.5]."""
sizes = torch.tensor([len(c) for c in components], device=visibility.device)
members = torch.cat(components).long()
component = torch.repeat_interleave(torch.arange(len(components), device=visibility.device), sizes)
order = torch.argsort(component.double() * 2 + visibility[members].double())
ranked = visibility[members][order]
starts = torch.cumsum(sizes, 0) - sizes
position = 0.75 * (sizes - 1).float()
low = position.floor().long()
high = position.ceil().long()
quantile = ranked[starts + low] + (ranked[starts + high] - ranked[starts + low]) * (position - low.float())
threshold = quantile.clamp(min=0.25).clamp(max=0.5)
outer = torch.zeros_like(visibility, dtype=torch.bool)
outer[members] = visibility[members] > threshold[component]
return outer
@torch.no_grad()
def _fill_holes(
verts,
faces,
max_hole_size=0.04,
max_hole_nbe=32,
resolution=128,
num_views=500,
debug=False,
verbose=False,
timings=None,
):
"""
Rasterize a mesh from multiple views and remove invisible faces.
Also includes postprocessing to:
1. Remove connected components that are have low visibility.
2. Mincut to remove faces at the inner side of the mesh connected to the outer side with a small hole.
Args:
verts (torch.Tensor): Vertices of the mesh. Shape (V, 3).
faces (torch.Tensor): Faces of the mesh. Shape (F, 3).
max_hole_size (float): Maximum area of a hole to fill.
resolution (int): Resolution of the rasterization.
num_views (int): Number of views to rasterize the mesh.
verbose (bool): Whether to print progress.
"""
started = time.perf_counter()
# Construct cameras
yaws = []
pitchs = []
for i in range(num_views):
y, p = sphere_hammersley_sequence(i, num_views)
yaws.append(y)
pitchs.append(p)
yaws = torch.tensor(yaws).cuda()
pitchs = torch.tensor(pitchs).cuda()
radius = 2.0
fov = torch.deg2rad(torch.tensor(40)).cuda()
projection = utils3d.torch.perspective_from_fov_xy(fov, fov, 1, 3)
views = []
if BAKE_SYNC_FREE:
origs = torch.stack([torch.sin(yaws) * torch.cos(pitchs), torch.cos(yaws) * torch.cos(pitchs), torch.sin(pitchs)], dim=-1).float() * radius
else:
origs = [torch.tensor([
torch.sin(yaw) * torch.cos(pitch),
torch.cos(yaw) * torch.cos(pitch),
torch.sin(pitch),
]).cuda().float() * radius for (yaw, pitch) in zip(yaws, pitchs)]
for orig in origs:
view = utils3d.torch.view_look_at(orig, torch.tensor([0, 0, 0]).float().cuda(), torch.tensor([0, 0, 1]).float().cuda())
views.append(view)
views = torch.stack(views, dim=0)
# Rasterize
visblity = torch.zeros(faces.shape[0], dtype=torch.int32, device=verts.device)
rastctx = utils3d.torch.RastContext(backend='cuda')
for i in tqdm(range(views.shape[0]), total=views.shape[0], disable=not verbose, desc='Rasterizing'):
view = views[i]
buffers = utils3d.torch.rasterize_triangle_faces(
rastctx, verts[None], faces, resolution, resolution, view=view, projection=projection
)
if BAKE_SYNC_FREE:
face_id = torch.where(buffers['mask'][0] > 0.95, buffers['face_id'][0], 0).long().reshape(-1)
seen = torch.zeros(faces.shape[0] + 1, dtype=torch.bool, device=verts.device)
seen[face_id] = True
visblity += seen[1:]
continue
face_id = buffers['face_id'][0][buffers['mask'][0] > 0.95] - 1
face_id = torch.unique(face_id).long()
visblity[face_id] += 1
visblity = visblity.float() / num_views
started = mark_stage(timings, "holes_raster", started)
# Mincut
## construct outer faces
edges, face2edge, edge_degrees = utils3d.torch.compute_edges(faces)
boundary_edge_indices = torch.nonzero(edge_degrees == 1).reshape(-1)
connected_components = utils3d.torch.compute_connected_components(faces, edges, face2edge)
outer_face_indices = _visible_outer_faces(visblity, connected_components).nonzero().reshape(-1)
## construct inner faces
inner_face_indices = torch.nonzero(visblity == 0).reshape(-1)
if verbose:
tqdm.write(f'Found {inner_face_indices.shape[0]} invisible faces')
if inner_face_indices.shape[0] == 0:
return verts, faces
## Construct dual graph (faces as nodes, edges as edges)
dual_edges, dual_edge2edge = utils3d.torch.compute_dual_graph(face2edge)
dual_edge2edge = edges[dual_edge2edge]
dual_edges_weights = torch.norm(verts[dual_edge2edge[:, 0]] - verts[dual_edge2edge[:, 1]], dim=1)
if verbose:
tqdm.write(f'Dual graph: {dual_edges.shape[0]} edges')
## solve mincut problem
### construct main graph
g = igraph.Graph()
g.add_vertices(faces.shape[0])
g.add_edges(dual_edges.cpu().numpy())
g.es['weight'] = dual_edges_weights.cpu().numpy()
### source and target
g.add_vertex('s')
g.add_vertex('t')
### connect invisible faces to source
source, target = faces.shape[0], faces.shape[0] + 1
inner = inner_face_indices.cpu().numpy()
g.add_edges(np.stack([inner, np.full_like(inner, source)], axis=1), attributes={'weight': np.ones(len(inner), dtype=np.float32)})
### connect outer faces to target
outer = outer_face_indices.cpu().numpy()
g.add_edges(np.stack([outer, np.full_like(outer, target)], axis=1), attributes={'weight': np.ones(len(outer), dtype=np.float32)})
### solve mincut
cut = g.mincut('s', 't', (np.array(g.es['weight']) * 1000).tolist())
remove_face_indices = torch.tensor([v for v in cut.partition[0] if v < faces.shape[0]], dtype=torch.long, device=faces.device)
started = mark_stage(timings, "holes_mincut", started)
if verbose:
tqdm.write(f'Mincut solved, start checking the cut')
### check if the cut is valid with each connected component
to_remove_cc = utils3d.torch.compute_connected_components(faces[remove_face_indices])
if debug:
tqdm.write(f'Number of connected components of the cut: {len(to_remove_cc)}')
valid_remove_cc = []
cutting_edges = []
for cc in to_remove_cc:
#### check if the connected component has low visibility
visblity_median = visblity[remove_face_indices[cc]].median()
if debug:
tqdm.write(f'visblity_median: {visblity_median}')
if visblity_median > 0.25:
continue
#### check if the cuting loop is small enough
cc_edge_indices, cc_edges_degree = torch.unique(face2edge[remove_face_indices[cc]], return_counts=True)
cc_boundary_edge_indices = cc_edge_indices[cc_edges_degree == 1]
cc_new_boundary_edge_indices = cc_boundary_edge_indices[~torch.isin(cc_boundary_edge_indices, boundary_edge_indices)]
if len(cc_new_boundary_edge_indices) > 0:
cc_new_boundary_edge_cc = utils3d.torch.compute_edge_connected_components(edges[cc_new_boundary_edge_indices])
cc_new_boundary_edges_cc_center = [verts[edges[cc_new_boundary_edge_indices[edge_cc]]].mean(dim=1).mean(dim=0) for edge_cc in cc_new_boundary_edge_cc]
cc_new_boundary_edges_cc_area = []
for i, edge_cc in enumerate(cc_new_boundary_edge_cc):
_e1 = verts[edges[cc_new_boundary_edge_indices[edge_cc]][:, 0]] - cc_new_boundary_edges_cc_center[i]
_e2 = verts[edges[cc_new_boundary_edge_indices[edge_cc]][:, 1]] - cc_new_boundary_edges_cc_center[i]
cc_new_boundary_edges_cc_area.append(torch.norm(torch.cross(_e1, _e2, dim=-1), dim=1).sum() * 0.5)
if debug:
cutting_edges.append(cc_new_boundary_edge_indices)
tqdm.write(f'Area of the cutting loop: {cc_new_boundary_edges_cc_area}')
if any([l > max_hole_size for l in cc_new_boundary_edges_cc_area]):
continue
valid_remove_cc.append(cc)
if debug:
face_v = verts[faces].mean(dim=1).cpu().numpy()
vis_dual_edges = dual_edges.cpu().numpy()
vis_colors = np.zeros((faces.shape[0], 3), dtype=np.uint8)
vis_colors[inner_face_indices.cpu().numpy()] = [0, 0, 255]
vis_colors[outer_face_indices.cpu().numpy()] = [0, 255, 0]
vis_colors[remove_face_indices.cpu().numpy()] = [255, 0, 255]
if len(valid_remove_cc) > 0:
vis_colors[remove_face_indices[torch.cat(valid_remove_cc)].cpu().numpy()] = [255, 0, 0]
utils3d.io.write_ply('dbg_dual.ply', face_v, edges=vis_dual_edges, vertex_colors=vis_colors)
vis_verts = verts.cpu().numpy()
vis_edges = edges[torch.cat(cutting_edges)].cpu().numpy()
utils3d.io.write_ply('dbg_cut.ply', vis_verts, edges=vis_edges)
if len(valid_remove_cc) > 0:
remove_face_indices = remove_face_indices[torch.cat(valid_remove_cc)]
mask = torch.ones(faces.shape[0], dtype=torch.bool, device=faces.device)
mask[remove_face_indices] = 0
faces = faces[mask]
faces, verts = utils3d.torch.remove_unreferenced_vertices(faces, verts)
if verbose:
tqdm.write(f'Removed {(~mask).sum()} faces by mincut')
else:
if verbose:
tqdm.write(f'Removed 0 faces by mincut')
if timings is not None:
timings["faces_cut"] = int(faces.shape[0])
started = mark_stage(timings, "holes_check", started)
mesh = _meshfix.PyTMesh()
mesh.load_array(verts.cpu().numpy(), faces.cpu().numpy())
mesh.fill_small_boundaries(nbe=max_hole_nbe, refine=True)
verts, faces = mesh.return_arrays()
verts, faces = torch.tensor(verts, device='cuda', dtype=torch.float32), torch.tensor(faces, device='cuda', dtype=torch.int32)
mark_stage(timings, "holes_meshfix", started)
return verts, faces