Visualização de nuvens de pontos com Three.js

Há algum tempo venho trabalhando em um projeto interno cuja saída entre muitas outras coisas também são nuvens de pontos. Quando eu estava pensando em uma ferramenta de visualização adequada dessas nuvens, fiz uma lista de coisas que ela deveria ser capaz de fazer:
- Costumização
- Capacidade de passar sombreadores de vértice/fragmento personalizados
- fácil de compartilhar
Como sempre, este projeto paralelo não terminou apenas com a visualização. Como eu queria tornar isso interessante também para os outros, nos próximos 15 minutos falarei sobre mapas de profundidade / MiDaS, reconhecimento facial / TensorFlow, algoritmo de transformação Beier-Neely e efeito de paralaxe. Não se preocupe, haverá muitas imagens e vídeos também. Este post não é um tutorial detalhado, mas sim uma vitrine de alguns conceitos, porém deve ser bom o suficiente como material de estudo junto com o repositório do Github.
Sobre nuvens de pontos e Three.js
Em geral, uma nuvem de pontos é apenas um conjunto de pontos no espaço. Essas nuvens de pontos podem ser obtidas por meio de lidars ou mesmo via iPhone moderno. Os principais lidars podem gerar vários milhões de pontos por segundo, portanto, a otimização e o uso da GPU são basicamente essenciais para uma visualização adequada. Eu poderia escrever outro post sobre abordagens de visualização em relação ao tamanho do conjunto de dados, mas por enquanto Three.js e WebGL são bons o suficiente. Esperamos que em breve possamos usar o WebGPU também — ele já está disponível, mas nada oficial até agora em termos de Three.js.
Three.js nos fornece o objeto Points 3D que é renderizado internamente com o sinalizador gl.POINTS . A classe Points espera dois parâmetros, Material e Geometry. Vamos dar uma olhada em ambos.
Geometria
Existem muitas geometrias auxiliares como Plane of Sphere que dão a possibilidade de fazer protótipos mais rápidos, porém decidi usar BufferGeometry . Embora leve mais tempo para criar algo significativo, você tem controle total sobre tudo relacionado à geometria. Vejamos o trecho abaixo:
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)
Em casos simples, como imagens 2D, você pode usar um mapa de profundidade como um mapa de deslocamento, mas no meu caso de uso preciso de um comportamento mais complexo de nuvens de pontos e modelos 3D.
Material
Novamente, há muitos materiais predefinidos em Three.js, mas em nosso caso usei ShaderMaterial , pois podemos fornecer shaders personalizados e ter mais flexibilidade/melhor desempenho. O próximo trecho mostra o vértice básico e o shader de fragmento. Algumas das inclusões são específicas do Three.js para que você possa aproveitar a API acessível no lado do TypeScript. Como o código deles está disponível no Github , você sempre pode verificar o que for necessário.
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 );
}
}
`;
Observação importante sobre o site de demonstração ao vivo. Gifs e vídeos vistos neste post foram feitos enquanto o visualizador usava ativos raw / HQ. A demonstração ao vivo usa ativos reduzidos por um fator de aproximadamente 10, cerca de 15 MB no total. Além disso, enquanto no ambiente local posso carregar dados brutos e esperar alguns segundos, a demonstração ao vivo está usando alguns atalhos internos para melhorar a experiência. Ou seja, a construção da geometria é parcialmente descarregada para o trabalhador da Web ou para carregar ativos como texturas e carregá-los na tela fora da tela para que eu possa ler ImageData e preencher os buffers básicos e de transformação. - Tempos desesperadores requerem medidas desesperadoras. :]
Vamos fazer aquelas nuvens dançar!
Nas próximas seções, falarei mais sobre os conceitos usados e descreverei cenários de demonstração que você pode experimentar mais tarde. Não vou descrever pipelines como [Conjunto de imagens -> Reconhecimento facial -> Exportação de dados -> Integração Three.js] em detalhes, mas você deve ser capaz de fazer isso sozinho seguindo os links fornecidos.

MiDaS e efeito paralaxe
O MiDaS é um incrível modelo de aprendizado de máquina que pode calcular o mapa de profundidade a partir de uma única imagem (estimativa de profundidade monocular). Aprendendo em vários conjuntos de dados, ele fornece resultados muito bons e você pode usá-lo livremente em seu dispositivo. A maior parte da configuração é feita via conda, basta baixar o modelo desejado (até 1,3GB). Que época para estar vivo, você pode facilmente aproveitar o conhecimento de um bando de pessoas inteligentes em casa. Depois de ter uma imagem e seu respectivo mapa de profundidade, nada impede que você use isso no renderizador de nuvem de pontos, como você pode ver no próximo vídeo.
Mas espere, tem mais! Os modelos Text2Image, Image2Image, Text2Mesh etc. estão em alta e não podemos ficar para trás. O próximo vídeo mostra algumas imagens geradas por IA (Midjourney e Stable Diffusion) processadas através do MiDaS e visualizadas (quando as imagens estão mudando, estamos apenas trocando os buffers de transformação mencionados anteriormente):
Você provavelmente percebeu que as imagens estão se movendo. Esse é o trabalho do nosso vertex shader. Existem dois tipos de efeito de paralaxe. O primeiro está rastreando a posição da câmera e está tentando transformar a nuvem para que o centro da imagem olhe diretamente para a câmera. Esse efeito funciona bem, mas deveria ser mais sofisticado porque a gravidade nem sempre está posicionada no centro. Estou planejando escrever uma abordagem melhor em breve. O trecho de código abaixo mostra a passagem de parâmetros de paralaxe para uniformes para que cada vértice possa ser ajustado de acordo. Em última análise, isso também será movido para shaders.
//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)
Transformação de imagem e Koalas
Mapas de profundidade e imagens geradas por IA são legais e tudo, mas também precisamos respeitar a natureza. Existe um cenário chamado Koala com dois recursos. Transformação de imagem entre duas imagens e representação da imagem da “natureza”. Vamos começar com o último. Neste cenário, as cores são mapeadas no plano z como números de 24 bits e depois normalizadas. Há algo de mágico na representação de imagens espaciais, especialmente porque nos dá a possibilidade de sentir os objetos e também nos dá uma intuição mais próxima sobre por que as redes neurais para reconhecimento de objetos funcionam. Você quase pode sentir a caracterização dos objetos no espaço:
Em relação ao morphing, neste cenário temos dois coalas diferentes e 10 imagens intermediárias morphing — você pode navegar por eles via slider ou deixar o WebGL para animá-los de forma saltitante. As imagens entre os originais foram geradas por meio do algoritmo de transformação de Beier-Neely . Para este algoritmo, você precisa mapear 1–1 conjunto de linhas onde cada linha representa algum recurso como olhos ou queixo. Falarei sobre o algoritmo com mais detalhes na próxima seção. No vídeo abaixo você pode ver que existem alguns fragmentos estranhos porque usei apenas 10 recursos de linha e queria ver os resultados. Mas mesmo quando não é perfeito, a versão animada é bem legal, principalmente o morphing scarf, bem psicodélico:
Image Morphing, API de profundidade de retrato e waifus
É hora de combinar tudo acima e olhar para um exemplo mais complexo. Nesta seção vamos interpolar entre duas imagens com mapas de profundidade. Como mencionei, o MiDaS em geral é incrível, mas quando você deseja obter um mapa de profundidade do rosto humano, precisa de um modelo mais detalhado, treinado especialmente em rostos humanos (isso não significa que você não possa obter um bom mapa de profundidade de MiDaS). No início deste ano, o TensorFlow apresentou a API Portrait Depth e, de forma semelhante ao MiDaS, também é possível executar este projeto em seu dispositivo. Para esta seção, selecionei Anne Hathaway e Olivia Munn como alvos de metamorfose. No menu suspenso, você pode encontrar esta seção chamada Waifus. Não me pergunte por quê. Antes de transformar, vamos dar uma olhada no mapa de profundidade de Anne como nuvem de pontos:
Podemos ver que o modelo nos deu resultados muito bons. Não seríamos capazes de criar uma malha real do rosto humano, mas, caso contrário, há detalhes agradáveis, como dentes com volume, cabeça claramente em um nível diferente do torso e o relevo facial também é preciso. Pode-se dizer que há uma falha na imagem porque parece que Anne tem um segundo queixo, mas sabemos que não é verdade. Como Nietzsche disse: “Apenas pessoas com queixo duplo são aquelas que olham para o abismo de suas vidas fracassadas”. Além disso, nunca confie em nenhuma citação na internet. :]
A API de profundidade de retrato funciona em duas etapas. A princípio ele detecta rosto/cabelos/pescoço e outras partes próximas do rosto (não sei bem como pesa, essa frase vem apenas da minha experiência enquanto brincava com o modelo), depois faz uma máscara preta no resto da imagem e, finalmente, produz um mapa de profundidade. A etapa de mascaramento nem sempre é precisa, portanto, o mapa de profundidade final apresenta ruído agudo na borda. Felizmente, é um bom ruído — pode ser removido no mesmo passo a passo ao gravar em buffers. Escrevi heurística para remover o ruído e funcionou na primeira tentativa.
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)
}
}
}


Como o morphing de coalas produziu fragmentos, para o morphing de rostos, decidi usar o TensorFlow e seu modelo de reconhecimento de rosto / recursos. Usei cerca de 80 linhas de recurso, 8 vezes mais do que para coalas:
O resultado é muito melhor e fica muito legal, mesmo usando mapas de profundidade! Há uma falha no cabelo de Olivia, mas nada que não possa ser consertado depois. Quando você anima essa transformação, cada quadro corta um pedaço de pixels. Isso ocorre porque o plano de fundo é considerado plano, mas também pode ser corrigido no pós-processamento, apenas mapeando esse pedaço para suavizar a função e a animação acelerada em relação à distância da cabeça.
Transformação de imagem em geral
Com relação ao algoritmo de transformação de Beier-Neely, não consegui encontrar nenhuma implementação paralelizada ou acelerada por GPU, portanto, calcular quadros intermediários em imagens grandes como 2k * 2k pixels com dezenas de linhas de recurso leva muito tempo. No futuro, estou planejando escrever essas implementações. A primeira implementação seria para uso local/servidor via CUDA e a segunda utilizaria GPGPU e shaders. GPGPU é especialmente interessante, pois você pode ser mais criativo.
Existem vários projetos (ou seja, DiffMorph ) que acompanham as redes neurais no processo de morphing — como uma aceleração do processo ou como um processo completo. Raciocinando por trás do uso e experimentando o bom e velho algoritmo determinístico é que eu queria examinar alguns artigos e fazer a implementação relacionada à GPU.
Os projetos paralelos são ótimos
Começou como um protótipo rápido apenas para visualizar nuvens de pontos do meu projeto diferente, mas no final passei cerca de uma semana testando e adicionando novos recursos apenas por diversão e também aprendi algumas coisas no caminho. Abaixo você pode encontrar um link para o meu github com código completo e para o site para demonstração ao vivo. Sinta-se à vontade para bifurcar ou até mesmo fazer um PR com novas ideias. Pretendo adicionar mais recursos, mas não quero estragar mais. Apenas um aviso, não sou um desenvolvedor web, então posso estar usando algumas construções estranhas, mas estou aberto a críticas!
Repositório Github
Demonstração ao vivo na web
O projeto é um site estático sem a possibilidade de fazer upload de imagens personalizadas. Para uso personalizado, você precisa clonar o repositório e fornecer imagens/mapas de profundidade por conta própria. Se você já usou conda e npm, não deve ter problemas com o MiDaS e outras ferramentas. Posso tornar o projeto dinâmico e gerar mapas de profundidade / morphing / fotos 3D no lado do servidor, mas isso depende das minhas possibilidades de tempo. Mas com certeza estou planejando adicionar recursos e refatorar. Alguns cenários causam o recarregamento da página no iPhone durante o uso do Safari ou Chrome, será depurado em breve.