Introducción a la dispersión gaussiana tridimensional para ingenieros de Python (parte 3) | por Derek Austin | Jul, 2024

Parte 3 de nuestro tutorial de salpicaduras gaussianas, que muestra cómo representar salpicaduras en una imagen 2D

Finalmente, llegamos a la fase más intrigante del proceso de salpicadura gaussiana: ¡la representación! Este paso es posiblemente el más crucial, ya que determina el realismo de nuestro modelo. Sin embargo, también podría ser el más simple. parte 1 y parte 2 En nuestra serie, demostramos cómo transformar símbolos sin procesar en un formato listo para renderizar, pero ahora tenemos que hacer el trabajo y renderizar en un conjunto fijo de píxeles. Los autores han desarrollado un motor de renderizado rápido con CUDA, que puede resultar un poco complicado de seguir. Por lo tanto, creo que es beneficioso repasar primero el código en Python, utilizando bucles for sencillos para mayor claridad. Para aquellos que estén ansiosos por profundizar, todo el código necesario está disponible en nuestro sitio web GCentro de información.

Analicemos cómo representar cada píxel individual. De nuestro ejemplo anterior artículotenemos todos los componentes necesarios: puntos 2D, colores asociados, covarianza, orden de profundidad ordenado, covarianza inversa en 2D, valores x e y mínimos y máximos para cada salpicadura, y opacidad asociada. Con estos componentes, podemos renderizar cualquier píxel. Dadas las coordenadas de píxel específicas, iteramos a través de todas las salpicaduras hasta que alcanzamos un umbral de saturación, siguiendo el orden de profundidad de la salpicadura en relación con el plano de la cámara (proyectado al plano de la cámara y luego ordenado por profundidad). Para cada salpicadura, primero verificamos si la coordenada del píxel está dentro de los límites definidos por los valores x e y mínimos y máximos. Esta verificación determina si debemos continuar renderizando o ignorar la salpicadura para estas coordenadas. A continuación, calculamos la intensidad de la salpicadura gaussiana en la coordenada del píxel utilizando la media de la salpicadura, la covarianza de la salpicadura y las coordenadas del píxel.

def compute_gaussian_weight(
pixel_coord: torch.Tensor, # (1, 2) tensor
point_mean: torch.Tensor,
inverse_covariance: torch.Tensor,
) -> torch.Tensor:

difference = point_mean - pixel_coord
power = -0.5 * difference @ inverse_covariance @ difference.T
return torch.exp(power).item()

Multiplicamos este peso por la opacidad del splat para obtener un parámetro llamado alpha. Antes de añadir este nuevo valor al píxel, tenemos que comprobar si hemos superado nuestro umbral de saturación. No queremos que un splat detrás de otros splats afecte a la coloración del píxel y utilice recursos informáticos si el píxel ya está saturado. Por tanto, utilizamos un umbral que nos permite dejar de renderizar una vez que se supera. En la práctica, empezamos nuestro umbral de saturación en 1 y luego lo multiplicamos por min(0,99, (1 — alpha)) para obtener un nuevo valor. Si este valor es inferior a nuestro umbral (0,0001), dejamos de renderizar ese píxel y lo consideramos completo. Si no, añadimos los colores ponderados por el valor de saturación * (1 — alpha) y actualizamos la saturación como new_saturation = old_saturation * (1 — alpha). Por último, recorremos cada píxel (o cada mosaico de 16×16 en la práctica) y renderizamos. El código completo se muestra a continuación.

def render_pixel(
self,
pixel_coords: torch.Tensor,
points_in_tile_mean: torch.Tensor,
colors: torch.Tensor,
opacities: torch.Tensor,
inverse_covariance: torch.Tensor,
min_weight: float = 0.000001,
) -> torch.Tensor:
total_weight = torch.ones(1).to(points_in_tile_mean.device)
pixel_color = torch.zeros((1, 1, 3)).to(points_in_tile_mean.device)
for point_idx in range(points_in_tile_mean.shape[0]):
point = points_in_tile_mean[point_idx, :].view(1, 2)
weight = compute_gaussian_weight(
pixel_coord=pixel_coords,
point_mean=point,
inverse_covariance=inverse_covariance[point_idx],
)
alpha = weight * torch.sigmoid(opacities[point_idx])
test_weight = total_weight * (1 - alpha)
if test_weight < min_weight:
return pixel_color
pixel_color += total_weight * alpha * colors[point_idx]
total_weight = test_weight
# in case we never reach saturation
return pixel_color

Ahora que podemos renderizar un píxel, podemos renderizar un parche de una imagen, o lo que los autores denominan un mosaico.

 def render_tile(
self,
x_min: int,
y_min: int,
points_in_tile_mean: torch.Tensor,
colors: torch.Tensor,
opacities: torch.Tensor,
inverse_covariance: torch.Tensor,
tile_size: int = 16,
) -> torch.Tensor:
"""Points in tile should be arranged in order of depth"""

tile = torch.zeros((tile_size, tile_size, 3))

# iterate by tiles for more efficient processing
for pixel_x in range(x_min, x_min + tile_size):
for pixel_y in range(y_min, y_min + tile_size):
tile[pixel_x % tile_size, pixel_y % tile_size] = self.render_pixel(
pixel_coords=torch.Tensor([pixel_x, pixel_y])
.view(1, 2)
.to(points_in_tile_mean.device),
points_in_tile_mean=points_in_tile_mean,
colors=colors,
opacities=opacities,
inverse_covariance=inverse_covariance,
)
return tile

Y finalmente podemos usar todos esos mosaicos para renderizar una imagen completa. Observa cómo verificamos que el símbolo realmente afecte al mosaico actual (código x_in_tile e y_in_tile).

def render_image(self, image_idx: int, tile_size: int = 16) -> torch.Tensor:
"""For each tile have to check if the point is in the tile"""
preprocessed_scene = self.preprocess(image_idx)
height = self.images[image_idx].height
width = self.images[image_idx].width

image = torch.zeros((width, height, 3))

for x_min in tqdm(range(0, width, tile_size)):
x_in_tile = (x_min >= preprocessed_scene.min_x) & (
x_min + tile_size <= preprocessed_scene.max_x
)
if x_in_tile.sum() == 0:
continue
for y_min in range(0, height, tile_size):
y_in_tile = (y_min >= preprocessed_scene.min_y) & (
y_min + tile_size <= preprocessed_scene.max_y
)
points_in_tile = x_in_tile & y_in_tile
if points_in_tile.sum() == 0:
continue
points_in_tile_mean = preprocessed_scene.points[points_in_tile]
colors_in_tile = preprocessed_scene.colors[points_in_tile]
opacities_in_tile = preprocessed_scene.sigmoid_opacity[points_in_tile]
inverse_covariance_in_tile = preprocessed_scene.inverse_covariance_2d[
points_in_tile
]
image[x_min : x_min + tile_size, y_min : y_min + tile_size] = (
self.render_tile(
x_min=x_min,
y_min=y_min,
points_in_tile_mean=points_in_tile_mean,
colors=colors_in_tile,
opacities=opacities_in_tile,
inverse_covariance=inverse_covariance_in_tile,
tile_size=tile_size,
)
)
return image

Por fin, ahora que tenemos todos los componentes necesarios, podemos renderizar una imagen. Tomamos todos los puntos 3D del conjunto de datos de Treehill y los inicializamos como puntos gaussianos. Para evitar una costosa búsqueda del vecino más cercano, inicializamos todas las variables de escala como .01 (tenga en cuenta que con una variación tan pequeña, necesitaremos una fuerte concentración de puntos en un punto para que sean visibles. Una variación mayor hace que el proceso sea bastante lento). Luego, todo lo que tenemos que hacer es llamar a render_image con el número de imagen que estamos tratando de emular y, como puede ver, obtenemos un conjunto disperso de nubes de puntos que se parecen a nuestra imagen. (¡Consulte nuestra sección adicional en la parte inferior para obtener un kernel CUDA equivalente utilizando la ingeniosa herramienta de pyTorch que compila el código CUDA!)

Imagen real, implementación de CPU, implementación de CUDA. Imagen del autor.

Si bien el paso hacia atrás no es parte de este tutorial, se debe tener en cuenta que, si bien comenzamos solo con estos pocos puntos, pronto tendremos cientos de miles de salpicaduras para la mayoría de las escenas. Esto se debe a la división de salpicaduras grandes (tal como se define por una mayor variación en los ejes) en salpicaduras más pequeñas y la eliminación de salpicaduras que tienen una opacidad extremadamente baja. Por ejemplo, si realmente inicializamos la escala a la media de los tres vecinos más cercanos, tendríamos la mayoría del espacio cubierto. Para obtener detalles finos, necesitaríamos dividirlos en salpicaduras mucho más pequeñas que puedan capturar detalles finos. También necesitan poblar áreas con muy pocas gaussianas. Se refieren a estos dos escenarios como sobre reconstrucción y sub reconstrucción y definen ambos escenarios por grandes valores de gradiente para varias salpicaduras. Luego dividen o clonan las salpicaduras según el tamaño (ver la imagen a continuación) y continúan con el proceso de optimización.

Aunque el paso hacia atrás no se cubre en este tutorial, es importante tener en cuenta que comenzamos con solo unos pocos puntos, pero pronto tenemos cientos de miles de salpicaduras en la mayoría de las escenas. Este aumento se debe a la división de salpicaduras grandes (con mayores variaciones en los ejes) en salpicaduras más pequeñas y la eliminación de salpicaduras con muy baja opacidad. Por ejemplo, si inicialmente establecemos la escala en la media de los tres vecinos más cercanos, la mayor parte del espacio estaría cubierto. Para lograr detalles finos, necesitamos dividir estas salpicaduras grandes en salpicaduras mucho más pequeñas. Además, las áreas con muy pocas gaussianas deben ser pobladas. Estos escenarios se conocen como sobre-reconstrucción y sub-reconstrucción, caracterizados por grandes valores de gradiente para varias salpicaduras. Dependiendo de su tamaño, las salpicaduras se dividen o clonan (ver imagen a continuación) y el proceso de optimización continúa.

Del artículo original del autor sobre cómo se dividen o clonan las gaussianas durante el entrenamiento. Fuente: https://arxiv.org/abs/2308.04079

¡Y esa es una introducción fácil a la salpicadura gaussiana! Ahora deberías tener una buena intuición sobre qué es exactamente lo que está sucediendo en el paso hacia adelante de una representación de escena gaussiana. Si bien es un poco abrumador y no es exactamente una red neuronal, todo lo que se necesita es un poco de álgebra lineal y ¡podemos representar geometría 3D en 2D!

¡No dudes en dejar comentarios sobre temas confusos o si me equivoqué en algo y siempre puedes conectarte conmigo en LinkedIn o Twitter!