Visualización de nubes de puntos con Three.js

Nov 28 2022
¿Sabes cómo alcanzar la perfección? Simplemente combine Midjourney, Stable Diffusion, mapas de profundidad, morphing facial y un poco de 3D.
Desde hace un tiempo vengo trabajando en un proyecto interno cuyo resultado entre muchas otras cosas también son nubes de puntos. Cuando estaba pensando en una herramienta de visualización adecuada de esas nubes, hice una lista de cosas que debería poder hacer: Como siempre, este proyecto paralelo no terminó solo con la visualización.
Casa en la nube de puntos

Desde hace un tiempo vengo trabajando en un proyecto interno cuyo resultado entre muchas otras cosas también son nubes de puntos. Cuando estaba pensando en una herramienta de visualización adecuada de esas nubes, hice una lista de cosas que debería poder hacer:

  • personalización
  • Capacidad para pasar sombreadores de vértices/fragmentos personalizados
  • Fácil de compartir

Como siempre, este proyecto paralelo no terminó solo con la visualización. Como quería que esto también fuera interesante para los demás, en los próximos 15 minutos les contaré algo sobre los mapas de profundidad/MiDaS, el reconocimiento facial/TensorFlow, el algoritmo de transformación de Beier-Neely y el efecto de paralaje. No se preocupe, habrá muchas imágenes y videos también. Esta publicación no es un tutorial detallado, sino más bien una muestra de algunos conceptos; sin embargo, debería ser lo suficientemente bueno como material de estudio junto con el repositorio de Github.

Acerca de las nubes de puntos y Three.js

En general, una nube de puntos es solo un conjunto de puntos en el espacio. Dichas nubes de puntos podrían obtenerse a través de, por ejemplo, lidars o incluso a través de un iPhone moderno. Los mejores lidars pueden generar varios millones de puntos por segundo, por lo que la optimización y el uso de GPU son básicamente imprescindibles para una visualización adecuada. Podría escribir otra publicación sobre enfoques de visualización con respecto al tamaño del conjunto de datos, pero por ahora Three.js y WebGL son lo suficientemente buenos. Con suerte, pronto también podremos usar WebGPU : ya está disponible, pero hasta ahora no es nada oficial en términos de Three.js.

Three.js nos da un objeto Points 3D que se representa internamente con la bandera gl.POINTS . La clase de puntos espera dos parámetros, Material y Geometría. Echemos un vistazo más de cerca a ambos.

Geometría

Hay muchas geometrías auxiliares como Plane of Sphere que te dan la posibilidad de hacer prototipos más rápidos, sin embargo, decidí usar BufferGeometry . Aunque te lleva más tiempo crear algo significativo, tienes control total sobre todo lo relacionado con la geometría. Echemos un vistazo al fragmento a continuación:

if (this.guiWrapper.textureOptions.textureUsage) {
  geometry.setAttribute('uv', new THREE.BufferAttribute(pointUv, this.UV_NUM))
} else {
  geometry.setAttribute('color', new THREE.BufferAttribute(pointsColors, this.COL_NUM))
}
geometry.setAttribute('position', new THREE.BufferAttribute(pointsToRender, this.POS_NUM))

geometry.morphAttributes.position = []
geometry.morphAttributes.color = []

// Add flat mapping
geometry.morphAttributes.position[0] = new THREE.BufferAttribute(pointsToRenderFlat, this.POS_NUM)
geometry.morphAttributes.color[0] = new THREE.BufferAttribute(pointsColors, this.COL_NUM)

// Add "natural" mapping
geometry.morphAttributes.position[1] = new THREE.BufferAttribute(pointsToRenderMapped, this.POS_NUM)
geometry.morphAttributes.color[1] = new THREE.BufferAttribute(pointsColors, this.COL_NUM)

En casos simples como imágenes 2D, podría usar un mapa de profundidad como mapa de desplazamiento, pero en mi caso de uso necesito un comportamiento más complejo de nubes de puntos y modelos 3D.

Material

Nuevamente, hay muchos materiales predefinidos en Three.js, pero en nuestro caso usé ShaderMaterial ya que podemos proporcionar sombreadores personalizados y tener más flexibilidad/mejor rendimiento. El siguiente fragmento muestra tanto el vértice básico como el sombreador de fragmentos. Algunas de las inclusiones son específicas de Three.js, por lo que puede aprovechar la API accesible en el lado de TypeScript. Dado que su código está disponible en Github , siempre puede verificar lo que sea necesario.

export const VERTEX_SHADER = `
    #ifdef GL_ES
    precision highp float;
    #endif

    uniform float size;
    uniform float scale;
    #define USE_COLOR
    #define USE_MORPHCOLORS
    #include <common>
    #include <color_pars_vertex>
    #include <morphtarget_pars_vertex>
    
    attribute vec3 color;
    varying vec2 vUv;
    uniform float u_time;
    uniform float u_minZ;
    uniform float u_maxZ;
    uniform float u_scale;
    uniform vec3 u_camera_angle;
    uniform int u_parallax_type;
    
    void main()
    {
        #include <color_vertex>
        #include <begin_vertex>
        #include <morphtarget_vertex>
        vColor = color;
        vUv = uv;
        gl_PointSize = 2.0;

        #if defined( MORPHTARGETS_COUNT )
            vColor = morphTargetBaseInfluence;
            for ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {
                if ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb morphTargetInfluences[ i ];
            }
        #endif

        if (u_parallax_type == 1) {
            transformed.x += u_scale*u_camera_angle.x*transformed.z*0.05;
            transformed.y += u_scale*u_camera_angle.y*transformed.z*0.02;
        } else if (u_parallax_type == 2) {
            transformed.x += transformed.z*cos(0.5*u_time)*0.01;
            transformed.y += transformed.z*sin(0.5*u_time)*0.005;
        }

        vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );
        gl_Position = projectionMatrix * mvPosition;
    }
`;

export const FRAGMENT_SHADER = `
    #ifdef GL_ES
    precision highp float;
    #endif

    varying vec3 vColor;
    varying vec2 vUv;
    uniform sampler2D u_texture;
    uniform bool u_use_texture;

    void main()
    {
        if (u_use_texture) {
            gl_FragColor = texture2D(u_texture, vUv);
        } else {
            gl_FragColor = vec4( vColor, 1.0 );
        }
    }
    
`;

Nota importante sobre el sitio web de demostración en vivo. Los gifs y videos que se ven en esta publicación se crearon mientras el visualizador usaba activos sin formato/HQ. La demostración en vivo utiliza activos reducidos por un factor de aproximadamente 10, alrededor de 15 MB en total. Además, mientras que en el entorno local puedo permitirme cargar datos sin procesar y esperar unos segundos, la demostración en vivo usa algunos accesos directos internos para mejorar la experiencia. Es decir, la construcción de geometría se descarga parcialmente al trabajador web o carga activos como texturas y los carga en un lienzo fuera de la pantalla para que pueda leer ImageData y llenar búferes básicos y de transformación. - Tiempos desesperados requieren medidas desesperadas. :]

¡Hagamos que esas nubes bailen!

En las próximas secciones, le daré más información sobre los conceptos usados ​​y describiré escenarios de demostración que puede probar más adelante. No voy a describir canalizaciones como [Conjunto de imágenes -> Reconocimiento facial -> Exportación de datos -> Integración de Three.js] en detalle, pero debería poder hacerlo usted mismo siguiendo los enlaces provistos.

Mírame a los ojos — gracias Ryska

MiDaS y efecto de paralaje

MiDaS es un increíble modelo de aprendizaje automático que puede calcular el mapa de profundidad a partir de una sola imagen (estimación de profundidad monocular). Aprendido en múltiples conjuntos de datos, da muy buenos resultados y puede usarlo en su dispositivo. La mayor parte de la configuración se realiza a través de conda, solo tiene que descargar el modelo deseado (hasta 1,3 GB). Qué momento para estar vivo, puedes aprovechar fácilmente el conocimiento de un grupo de personas inteligentes en casa. Una vez que tienes una imagen y su respectivo mapa de profundidad, nada te impide usarlo en el renderizador de nube de puntos como puedes ver en el siguiente video.

¡Pero espera, hay más! Los modelos Text2Image, Image2Image, Text2Mesh etc. son tendencia y no podemos quedarnos atrás. El siguiente video muestra algunas imágenes generadas por IA (Midjourney y Stable Diffusion) procesadas a través de MiDaS y visualizadas (cuando las imágenes están cambiando, solo estamos intercambiando los búferes de transformación mencionados anteriormente):

Probablemente te diste cuenta de que las imágenes se están moviendo. Ese es el trabajo de nuestro vertex shader. Hay dos tipos de efecto de paralaje. El primero está rastreando la posición de la cámara y está tratando de transformar la nube para que el centro de la imagen mire directamente a la cámara. Este efecto funciona bastante bien, pero debería ser más sofisticado porque la gravedad no siempre se coloca en el centro. Estoy planeando escribir un mejor enfoque pronto. El fragmento de código a continuación muestra el paso de parámetros de paralaje a uniformes para que cada vértice pueda ajustarse en consecuencia. En última instancia, esto también se moverá a los sombreadores.

//Will be simplified once testing / prototyping is finished
const imgCenterPoint = this.geometryHelper.imgCenterPoints[this.guiWrapper.renderingType.getValue()][this.interpolationOptions.frame]
this.lastCameraPosition = this.camera.position.clone()
let angleX = this.camera.position.clone().setY(0).angleTo(imgCenterPoint)
let angleY = this.camera.position.clone().setX(0).angleTo(imgCenterPoint)

let normalX = new THREE.Plane().setFromCoplanarPoints(new THREE.Vector3(), this.camera.position.clone().setY(0), imgCenterPoint).normal
let normalY = new THREE.Plane().setFromCoplanarPoints(new THREE.Vector3(), this.camera.position.clone().setX(0), imgCenterPoint).normal

this.parallaxUniforms.u_scale.value = 1 + this.currentSceneObject.scale.z
this.parallaxUniforms.u_camera_angle.value = new THREE.Vector3(-angleX*normalX.y, angleY*normalY.x, 0)

Transformación de imágenes y koalas

Los mapas de profundidad y las imágenes generadas por IA son geniales y todo, pero también debemos respetar un poco la naturaleza. Hay un escenario llamado Koala con dos características. Transformación de imagen entre dos imágenes y representación de imagen de "naturaleza". Comencemos con este último. En este escenario, los colores se asignan al plano z como números de 24 bits y luego se normalizan. Hay algo mágico en la representación de imágenes espaciales, especialmente porque nos brinda la posibilidad de sentir objetos y también nos brinda una intuición más cercana sobre por qué funcionan las redes neuronales para el reconocimiento de objetos. Casi puedes sentir la caracterización de los objetos en el espacio:

Con respecto a la transformación, en este escenario tenemos dos koalas diferentes y 10 imágenes intermedias de transformación: puede navegar a través de ellas a través del control deslizante o dejar que WebGL las anime de manera dinámica. Las imágenes entre los originales se generaron mediante el algoritmo de transformación de Beier-Neely . Para este algoritmo, debe mapear 1–1 conjunto de líneas donde cada línea representa alguna característica como los ojos o la barbilla. Hablaré sobre el algoritmo con más detalle en la siguiente sección. En el video a continuación, puede ver que hay algunos fragmentos extraños porque usé solo 10 características de línea y quería ver los resultados. Pero incluso cuando no es perfecta, la versión animada es bastante agradable, especialmente la bufanda cambiante, muy psicodélica:

Transformación de imagen, API de profundidad de retrato y waifus

Es hora de combinar todo lo anterior y mirar un ejemplo más complejo. En esta sección vamos a interpolar entre dos imágenes con mapas de profundidad. Como mencioné, MiDaS es en general sorprendente, pero cuando desea obtener un mapa de profundidad del rostro humano, necesita un modelo más detallado, entrenado especialmente en rostros humanos (eso no significa que no pueda obtener un mapa de profundidad bastante bueno de Midas). A principios de este año, TensorFlow presentó la API de profundidad de retrato y, de manera similar a MiDaS, también es posible ejecutar este proyecto en su dispositivo. Para esta sección, selecciono a Anne Hathaway y Olivia Munn como objetivos cambiantes. En el menú desplegable puede encontrar esta sección llamada Waifus. No me preguntes por qué. Antes de transformarse, echemos un vistazo al mapa de profundidad de Anne como nube de puntos:

Podemos ver que el modelo nos dio bastante buenos resultados. No seríamos capaces de crear una malla real del rostro humano, pero por lo demás hay detalles agradables como dientes con volumen, la cabeza claramente a un nivel diferente al del torso y el relieve facial también es preciso. Se podría decir que hay un error de imagen porque parece que Anne tiene una segunda barbilla, pero sabemos que no es cierto. Como dijo Nietzsche: “Solo las personas con papada son las que miran hacia el abismo de sus vidas fallidas”. Además, nunca confíes en ninguna cotización en Internet. :]

Portrait Depth API funciona en dos pasos. Al principio detecta la cara/pelos/cuello y otras partes cercanas a la cara (no estoy seguro de cómo se pondera, esta oración proviene solo de mi experiencia mientras jugaba con el modelo), luego crea una máscara negra en el resto de la imagen y finalmente produce un mapa de profundidad. El paso de enmascaramiento no siempre es preciso, por lo que el mapa de profundidad final tiene un ruido agudo en el borde. Afortunadamente, es un buen ruido: se puede eliminar en el mismo tutorial mientras se escribe en los búferes. Escribí heurística para eliminar el ruido y funcionó en el primer intento.

removeNoise(depthMapColor: number, depthmap: Array<Array<number>>, i: number, j: number, width: number, height: number, pointPointer: number, pointsToRender: Float32Array) {
        if (depthMapColor != 0) {
            const percentage = depthMapColor/100
            let left = depthMapColor
            let right = depthMapColor
            let top = depthMapColor
            let down = depthMapColor
            const dropThreshold = 5*percentage
    
            if (j > 0) left = depthmap[i][j-1]
            if (j < width-1) right = depthmap[i][j+1]
            if (i > 0) top = depthmap[i-1][j]
            if (i < height-1) down = depthmap[i+1][j]
            
            if(Math.abs(left - depthMapColor) > dropThreshold || Math.abs(right - depthMapColor) > dropThreshold) {
                pointsToRender[pointPointer*3 + 2] = 0
            }
            else if(Math.abs(top - depthMapColor) > dropThreshold || Math.abs(down - depthMapColor) > dropThreshold) {
                pointsToRender[pointPointer*3 + 2] = 0
            } else {
                // extra scale
                pointsToRender[pointPointer*3 + 2] = 3*(1 - depthMapColor)
            }
        }
    }

Antes y después de eliminar el ruido

Dado que la transformación de koalas producía fragmentos, para la transformación de rostros decidí usar TensorFlow y su modelo de reconocimiento de rostros/características. Usé unas 80 líneas características, 8 veces más que para los koalas:

El resultado es mucho mejor y se ve muy bien, ¡incluso cuando se usan mapas de profundidad! Hay un problema técnico con los cabellos de Olivia, pero nada que no se pueda solucionar más tarde. Cuando animas esta transformación, cada cuadro corta una porción de píxeles. Esto se debe a que el fondo se considera plano, pero también podría corregirse en el posprocesamiento, simplemente mapeando ese fragmento para suavizar la función y acelerar la animación con respecto a la distancia desde la cabeza.

Transformación de imágenes en general

Con respecto al algoritmo de transformación de Beier-Neely, no pude encontrar ninguna implementación paralelizada o acelerada por GPU, por lo que calcular fotogramas intermedios en imágenes grandes como 2k * 2k píxeles con decenas de líneas características lleva mucho tiempo. En el futuro, planeo escribir estas implementaciones. La primera implementación sería para uso local/servidor a través de CUDA y la segunda utilizaría GPGPU y sombreadores. GPGPU es especialmente interesante ya que puedes ser más creativo.

Hay varios proyectos (es decir, DiffMorph ) que acompañan a las redes neuronales en el proceso de transformación, como una aceleración del proceso o como un proceso completo. El razonamiento detrás del uso y la experimentación con el viejo y buen algoritmo determinista es que quería investigar algunos documentos y hacer una implementación relacionada con la GPU.

Proyecto paralelo son geniales

Comenzó como un prototipo rápido solo para visualizar nubes de puntos de mi proyecto diferente, pero al final pasé una semana probando y agregando nuevas funciones solo por diversión y también aprendí algunas cosas en el camino. A continuación, puede encontrar un enlace a mi github con el código completo y al sitio web para una demostración en vivo. Siéntase libre de bifurcar o incluso hacer un PR con nuevas ideas. Planeo agregar más funciones, pero no quiero estropear más. Solo un descargo de responsabilidad, no soy un desarrollador web, por lo que podría estar usando algunas construcciones extrañas, ¡pero estoy abierto a las críticas!

Repositorio Github
Demostración web en vivo

El proyecto es un sitio web estático sin la posibilidad de cargar imágenes personalizadas. Para un uso personalizado, debe clonar el repositorio y proporcionar imágenes/mapas de profundidad usted mismo. Si alguna vez usó conda y npm, no debería tener problemas con MiDaS y otras herramientas. Podría hacer que el proyecto sea dinámico y generar mapas de profundidad / morphing / fotos en 3D en el lado del servidor, pero depende de mis posibilidades de tiempo. Pero estoy seguro de que estoy planeando agregar funciones y refactorizar. Algunos escenarios hacen que la página se vuelva a cargar en el iPhone mientras se usa Safari o Chrome. Lo depuraremos pronto.

Gracias por tu tiempo y por leer este post. ¡Te veo pronto!