Visualisation des nuages ​​de points avec Three.js

Nov 28 2022
Savez-vous atteindre la perfection ? Combinez simplement Midjourney, Stable Diffusion, des cartes de profondeur, le morphing du visage et un peu de 3D.
Depuis un certain temps, je travaille sur un projet interne dont la sortie, entre autres choses, est également constituée de nuages ​​de points. Lorsque je réfléchissais à un outil de visualisation approprié de ces nuages, j'ai fait une liste de choses qu'il devrait être capable de faire : Comme toujours, ce projet parallèle ne s'est pas limité à la visualisation.
Maison dans le nuage de points

Depuis un certain temps, je travaille sur un projet interne dont la sortie, entre autres choses, est également constituée de nuages ​​de points. Quand je pensais à un bon outil de visualisation de ces nuages, j'ai fait une liste de choses qu'il devrait être capable de faire :

  • Personnalisation
  • Possibilité de passer des shaders de vertex/fragment personnalisés
  • Facile à partager

Comme toujours, ce projet parallèle ne s'est pas limité à la visualisation. Comme je voulais rendre cela intéressant aussi pour les autres, dans les 15 prochaines minutes, je vais vous parler des cartes de profondeur / MiDaS, de la reconnaissance faciale / TensorFlow, de l'algorithme de morphing Beier-Neely et de l'effet de parallaxe. Ne vous inquiétez pas, il y aura aussi beaucoup d'images et de vidéos. Cet article n'est pas un tutoriel détaillé mais plutôt une vitrine de quelques concepts, mais il devrait être assez bon comme matériel d'étude avec le référentiel Github.

À propos des nuages ​​de points et de Three.js

En général, un nuage de points n'est qu'un ensemble de points dans l'espace. De tels nuages ​​de points pourraient être obtenus via des lidars ou même via un iPhone moderne. Les meilleurs lidars peuvent générer plusieurs millions de points par seconde. L'optimisation et l'utilisation du GPU sont donc indispensables pour une visualisation correcte. Je pourrais écrire un autre article sur les approches de visualisation en ce qui concerne la taille du jeu de données, mais pour l'instant, Three.js et WebGL sont assez bons. Espérons que bientôt nous pourrons également utiliser WebGPU - il est déjà disponible mais rien d'officiel jusqu'à présent en termes de Three.js.

Three.js nous donne un objet Points 3D qui est rendu en interne avec le drapeau gl.POINTS . La classe Points attend deux paramètres, Material et Geometry. Regardons de plus près les deux.

Géométrie

Il existe de nombreuses géométries d'assistance comme Plane of Sphere qui vous donnent la possibilité de faire des prototypes plus rapides, mais j'ai décidé d'utiliser BufferGeometry . Bien que cela vous prenne plus de temps pour créer quelque chose de significatif, vous avez un contrôle total sur tout ce qui concerne la géométrie. Jetons un coup d'œil à l'extrait ci-dessous :

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)

Dans des cas simples comme les images 2D, vous pouvez utiliser une carte de profondeur comme carte de déplacement, mais dans mon cas d'utilisation, j'ai besoin d'un comportement plus complexe des nuages ​​de points et des modèles 3D.

Matériel

Encore une fois, il existe de nombreux matériaux prédéfinis dans Three.js, mais dans notre cas, j'ai utilisé ShaderMaterial car nous pouvons fournir des shaders personnalisés et avoir plus de flexibilité / de meilleures performances. L'extrait suivant montre à la fois le vertex de base et le shader de fragment. Certaines des inclusions sont spécifiques à Three.js afin que vous puissiez tirer parti de l'API accessible du côté TypeScript. Comme leur code est disponible sur Github , vous pouvez toujours vérifier tout ce dont vous avez besoin.

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 );
        }
    }
    
`;

Remarque importante concernant le site Web de démonstration en direct. Les gifs et vidéos vus dans cet article ont été réalisés alors que le visualiseur utilisait des ressources brutes / HQ. La démo en direct utilise des actifs réduits par un facteur d'environ 10, soit environ 15 Mo au total. De plus, alors que sur l'environnement local, je peux me permettre de charger des données brutes et d'attendre quelques secondes, la démonstration en direct utilise des raccourcis internes pour améliorer l'expérience. C'est-à-dire que la construction de la géométrie est partiellement déchargée sur le travailleur Web ou charge les actifs en tant que textures et les charge dans un canevas hors écran afin que je puisse lire ImageData et remplir les tampons de base et de morphing. - Aux grands maux les grands moyens. :]

Faisons danser ces nuages !

Dans les prochaines sections, je vais vous en dire plus sur les concepts utilisés et décrire des scénarios de démonstration que vous pourrez essayer plus tard. Je ne vais pas décrire en détail des pipelines tels que [Ensemble d'images -> Reconnaissance faciale -> Exportation de données -> Intégration Three.js], mais vous devriez pouvoir le faire vous-même en suivant les liens fournis.

Regarde dans mes yeux - merci Ryska

MiDaS et effet de parallaxe

MiDaS est un incroyable modèle d'apprentissage automatique qui peut calculer une carte de profondeur à partir d'une seule image (estimation de la profondeur monoculaire). Appris sur plusieurs ensembles de données, il donne de très bons résultats et vous êtes libre de l'utiliser sur votre appareil. La plupart de la configuration se fait via conda, il vous suffit de télécharger le modèle souhaité (jusqu'à 1,3 Go). Quel temps d'être en vie, vous pouvez facilement tirer parti des connaissances d'un groupe de personnes intelligentes à la maison. Une fois que vous avez une image et sa carte de profondeur respective, rien ne vous empêche de l'utiliser dans le rendu de nuage de points, comme vous pouvez le voir dans la vidéo suivante.

Mais attendez, il y a plus ! Les modèles Text2Image, Image2Image, Text2Mesh etc. sont à la mode et nous ne pouvons pas rester en retrait. La vidéo suivante montre quelques images générées par l'IA (Midjourney et Stable Diffusion) traitées via MiDaS et visualisées (lorsque les images changent, nous échangeons uniquement les tampons de morphing mentionnés précédemment) :

Vous vous êtes probablement rendu compte que les images bougent. C'est le travail de notre vertex shader. Il existe deux types d'effets de parallaxe. Le premier suit la position de la caméra et essaie de transformer le nuage afin que le centre de l'image regarde directement la caméra. Cet effet fonctionne plutôt bien mais il devrait être plus sophistiqué car la gravité n'est pas toujours positionnée au centre. Je prévois d'écrire une meilleure approche bientôt. L'extrait de code ci-dessous montre le passage des paramètres de parallaxe aux uniformes afin que chaque sommet puisse s'ajuster en conséquence. En fin de compte, cela sera également déplacé vers les 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)

Morphing d'image et koalas

Les cartes de profondeur et les images générées par l'IA sont cool et tout, mais nous devons également respecter un peu la nature. Il existe un scénario nommé Koala avec deux fonctionnalités. Morphing d'image entre deux images et représentation d'image "nature". Commençons par ce dernier. Dans ce scénario, les couleurs sont mappées dans le plan z sous forme de nombres 24 bits, puis normalisées. Il y a quelque chose de magique dans la représentation spatiale des images, en particulier parce qu'elle nous donne la possibilité de ressentir des objets et qu'elle donne également une intuition plus précise sur le fonctionnement des réseaux de neurones pour la reconnaissance d'objets. Vous pouvez presque sentir la caractérisation des objets dans l'espace :

En ce qui concerne le morphing, dans ce scénario, nous avons deux koalas différents et 10 images intermédiaires de morphing - vous pouvez les parcourir via un curseur ou laisser WebGL les animer de manière rebondissante. Les images entre les originaux ont été générées via l' algorithme de morphing Beier – Neely . Pour cet algorithme, vous devez mapper 1 à 1 ensemble de lignes où chaque ligne représente une caractéristique comme les yeux ou le menton. Je parlerai de l'algorithme plus en détail dans la section suivante. Dans la vidéo ci-dessous, vous pouvez voir qu'il y a des fragments étranges parce que je n'ai utilisé que 10 caractéristiques de ligne et je voulais voir les résultats. Mais même quand elle n'est pas parfaite, la version animée est plutôt sympa, surtout le foulard morphing, très psychédélique :

Morphing d'image, API de profondeur de portrait et waifus

Il est temps de combiner tout ce qui précède et de regarder un exemple plus complexe. Dans cette section, nous allons interpoler entre deux images avec des cartes de profondeur. Comme je l'ai mentionné, MiDaS est en général incroyable, mais lorsque vous voulez obtenir une carte de profondeur du visage humain, vous avez besoin d'un modèle plus détaillé, formé spécialement sur les visages humains (cela ne signifie pas que vous ne pouvez pas obtenir une assez bonne carte de profondeur de MiDaS). Plus tôt cette année, TensorFlow a présenté l' API Portrait Depth et, de la même manière que MiDaS, ce projet peut également être exécuté sur votre appareil. Pour cette section, je sélectionne Anne Hathaway et Olivia Munn comme cibles de morphing. Dans la liste déroulante, vous pouvez trouver cette section nommée Waifus. Ne me demandez pas pourquoi. Avant le morphing, regardons la carte de profondeur d'Anne sous forme de nuage de points :

Nous pouvons voir que le modèle nous a donné d'assez bons résultats. Nous ne serions pas en mesure de créer un maillage réaliste du visage humain, mais sinon, il y a de beaux détails comme les dents avec du volume, la tête clairement à un niveau différent de celui du torse et le relief du visage est également précis. On pourrait dire qu'il y a un problème d'image car il semble qu'Anne ait un deuxième menton, mais nous savons que ce n'est pas vrai. Comme l'a dit Nietzsche "Seules les personnes avec un double menton sont celles qui regardent dans l'abîme de leur vie défaillante." Aussi, ne faites jamais confiance à aucune citation sur Internet. :]

L'API Portrait Depth fonctionne en deux étapes. Au début, il détecte le visage/les cheveux/le cou et d'autres parties proches du visage (je ne sais pas comment il est pondéré, cette phrase ne vient que de mon expérience en jouant avec le modèle), puis il fait un masque noir sur le reste de l'image et produit finalement une carte de profondeur. L'étape de masquage n'est pas toujours précise, de sorte que la carte de profondeur finale présente un bruit net sur le bord. Heureusement, c'est un bon bruit - il peut être supprimé dans la même procédure pas à pas lors de l'écriture dans les tampons. J'ai écrit une heuristique pour supprimer le bruit et cela a fonctionné du premier coup.

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)
            }
        }
    }

Avant et après débruitage

Étant donné que le morphing des koalas produisait des fragments, pour le morphing des visages, j'ai décidé d'utiliser TensorFlow et leur modèle de reconnaissance des visages/caractéristiques. J'ai utilisé environ 80 traits caractéristiques, 8 fois plus que pour les koalas :

Le résultat est bien meilleur et ça a l'air vraiment cool, même en utilisant des cartes de profondeur ! Il y a un problème avec les cheveux d'Olivia mais rien qui ne puisse être réparé plus tard. Lorsque vous animez ce morphing, chaque image coupe un morceau de pixels. En effet, l'arrière-plan est considéré comme plat mais pourrait également être corrigé en post-traitement, en mappant simplement ce morceau pour lisser la fonction et accélérer l'animation par rapport à la distance de la tête.

Morphing d'image en général

En ce qui concerne l'algorithme de morphing Beier-Neely, je n'ai trouvé aucune implémentation parallélisée ou accélérée par GPU, donc le calcul d'images intermédiaires sur de grandes images comme 2k * 2k pixels avec des dizaines de lignes caractéristiques prend beaucoup de temps. À l'avenir, je prévois d'écrire ces implémentations. La première implémentation serait pour une utilisation locale/serveur via CUDA et la seconde utiliserait GPGPU et des shaders. GPGPU est particulièrement intéressant car vous pouvez être plus créatif.

Il existe plusieurs projets (c'est-à-dire DiffMorph ) qui accompagnent les réseaux de neurones dans le processus de morphing - comme une accélération du processus ou comme un processus complet. Le raisonnement derrière l'utilisation et l'expérimentation du bon vieil algorithme déterministe est que je voulais examiner quelques articles et faire une implémentation liée au GPU.

Les projets parallèles sont super

Cela a commencé comme un prototype rapide juste pour visualiser les nuages ​​de points de mon projet différent mais à la fin j'ai passé environ une semaine à tester et à ajouter de nouvelles fonctionnalités juste pour le plaisir et j'ai aussi appris quelques choses en cours de route. Ci-dessous, vous trouverez un lien vers mon github avec le code complet et vers le site Web pour une démonstration en direct. N'hésitez pas à bifurquer ou même à faire un PR avec de nouvelles idées. Je prévois d'ajouter plus de fonctionnalités mais je ne veux pas spoiler davantage. Juste un avertissement, je ne suis pas un développeur web donc j'utilise peut-être des constructions bizarres mais je suis ouvert aux critiques !

Référentiel Github
Démo Web en direct

Project est un site Web statique sans possibilité de télécharger des images personnalisées. Pour une utilisation personnalisée, vous devez cloner le référentiel et fournir vous-même des images/cartes de profondeur. Si vous avez déjà utilisé conda et npm, vous ne devriez avoir aucun problème avec MiDaS et d'autres outils. Il se peut que je dynamise le projet et génère des cartes de profondeur / morphing / photos 3D côté serveur mais cela dépend de mes possibilités de temps. Mais je prévois certainement d'ajouter des fonctionnalités et de refactoriser. Certains scénarios provoquent le rechargement de la page sur l'iPhone lors de l'utilisation de Safari ou de Chrome, le déboguera bientôt.

Merci pour votre temps et pour avoir lu ce post. À bientôt!