Wizualizacja chmur punktów za pomocą Three.js

Nov 28 2022
Czy wiesz jak osiągnąć perfekcję? Po prostu połącz Midjourney, Stable Diffusion, mapy głębi, morfing twarzy i odrobinę 3D.
Od jakiegoś czasu pracuję nad wewnętrznym projektem, którego efektem są między innymi chmury punktów. Zastanawiając się nad odpowiednim narzędziem do wizualizacji tych chmur, sporządziłem listę rzeczy, które powinno ono potrafić: Jak zwykle ten poboczny projekt nie zakończył się tylko na wizualizacji.
Dom w chmurze punktów

Od jakiegoś czasu pracuję nad wewnętrznym projektem, którego efektem są między innymi chmury punktów. Zastanawiając się nad odpowiednim narzędziem do wizualizacji tych chmur, sporządziłem listę rzeczy, które powinno ono potrafić:

  • Dostosowywanie
  • Możliwość przekazywania niestandardowych shaderów wierzchołków/fragmentów
  • Łatwe udostępnianie

Jak zwykle ten poboczny projekt nie zakończył się tylko na wizualizacji. Ponieważ chciałem zainteresować tym również innych, w ciągu najbliższych 15 minut opowiem o mapach głębi / MiDaS, rozpoznawaniu twarzy / TensorFlow, algorytmie morfingu Beiera-Neely'ego i efekcie paralaksy. Nie martw się, będzie też mnóstwo zdjęć i filmów. Ten post nie jest szczegółowym samouczkiem, a raczej prezentacją kilku koncepcji, jednak powinien być wystarczająco dobry jako materiał do nauki wraz z repozytorium Github.

O chmurach punktów i Three.js

Chmura punktów to po prostu zbiór punktów w przestrzeni. Takie chmury punktów można uzyskać np. za pomocą lidarów, a nawet nowoczesnych iPhone'ów. Topowe lidary potrafią generować kilka milionów punktów na sekundę, więc optymalizacja i wykorzystanie GPU jest w zasadzie koniecznością dla prawidłowej wizualizacji. Mógłbym napisać kolejny post o podejściach do wizualizacji w odniesieniu do rozmiaru zbioru danych, ale na razie Three.js i WebGL są wystarczająco dobre. Miejmy nadzieję, że wkrótce będziemy mogli również korzystać z WebGPU — jest już dostępne, ale jak dotąd nic oficjalnego, jeśli chodzi o Three.js.

Three.js daje nam obiekt Points 3D, który jest wewnętrznie renderowany z flagą gl.POINTS . Klasa Points oczekuje dwóch parametrów, Material i Geometry. Przyjrzyjmy się bliżej obu z nich.

Geometria

Istnieje wiele geometrii pomocniczych, takich jak Plane of Sphere, które dają możliwość szybszego tworzenia prototypów, jednak zdecydowałem się użyć BufferGeometry . Chociaż stworzenie czegoś sensownego zajmuje więcej czasu, masz pełną kontrolę nad wszystkim, co jest związane z geometrią. Rzućmy okiem na poniższy fragment:

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)

W prostych przypadkach, takich jak obrazy 2D, można użyć mapy głębi jako mapy przemieszczeń, ale w moim przypadku potrzebuję bardziej złożonego zachowania chmur punktów i modeli 3D.

Materiał

Ponownie, istnieje wiele predefiniowanych materiałów w Three.js, ale w naszym przypadku użyłem ShaderMaterial , ponieważ możemy zapewnić niestandardowe shadery i mieć większą elastyczność/lepszą wydajność. Następny fragment pokazuje zarówno podstawowe Vertex, jak i Fragment Shader. Niektóre z dołączeń są specyficzne dla Three.js, więc możesz wykorzystać API dostępne po stronie TypeScript. Ponieważ ich kod jest dostępny na Github , zawsze możesz sprawdzić, co jest potrzebne.

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

Ważna uwaga dotycząca strony demonstracyjnej na żywo. Gify i filmy widoczne w tym poście zostały utworzone, gdy wizualizator używał zasobów raw / HQ. Wersja demonstracyjna na żywo wykorzystuje zasoby pomniejszone o współczynnik około 10, czyli łącznie około 15 MB. Ponadto, podczas gdy na lokalnym środowisku mogę sobie pozwolić na załadowanie nieprzetworzonych danych i odczekanie kilku sekund, demo na żywo używa pewnych wewnętrznych skrótów, aby poprawić wrażenia. Oznacza to, że konstrukcja geometrii jest częściowo przenoszona do pracownika sieci lub ładuje zasoby jako tekstury i ładuje je do płótna poza ekranem, abym mógł odczytać ImageData i zapełnić bufory podstawowe i bufory morfingu. - Desperackie czasy wymagają desperackich środków. :]

Sprawmy, by te chmury zatańczyły!

W kilku następnych sekcjach powiem ci więcej o używanych koncepcjach i opiszę scenariusze demonstracyjne, które możesz wypróbować później. Nie będę szczegółowo opisywał potoków takich jak [Zestaw obrazów -> Rozpoznawanie twarzy -> Eksport danych -> Integracja Three.js], ale powinieneś być w stanie zrobić to sam, korzystając z podanych linków.

Spójrz mi w oczy — thx Ryska

MiDaS i efekt paralaksy

MiDaS to niesamowity model uczenia maszynowego, który może obliczyć mapę głębi na podstawie pojedynczego obrazu (oszacowanie głębokości Monocular). Nauczony na wielu zestawach danych, daje naprawdę dobre wyniki i możesz go używać na swoim urządzeniu. Większość konfiguracji odbywa się za pośrednictwem conda, wystarczy pobrać żądany model (do 1,3 GB). Cóż za czas na życie, możesz z łatwością wykorzystać wiedzę grupy sprytnych ludzi w domu. Gdy masz obraz i odpowiednią mapę głębi, nic nie stoi na przeszkodzie, aby użyć go w rendererze chmury punktów, jak widać w następnym filmie.

Ale poczekaj, jest więcej! Modele Text2Image, Image2Image, Text2Mesh itp. zyskują na popularności i nie możemy pozostać w tyle. Następny film pokazuje kilka obrazów wygenerowanych przez sztuczną inteligencję (Midjourney i Stable Diffusion) przetworzonych przez MiDaS i zwizualizowanych (gdy obrazy się zmieniają, wymieniamy tylko wspomniane wcześniej bufory morfingu):

Prawdopodobnie zdałeś sobie sprawę, że obrazy się poruszają. To jest praca naszego Vertex Shadera. Istnieją dwa rodzaje efektu paralaksy. Pierwszy polega na śledzeniu pozycji kamery i próbowaniu morfowania chmury tak, aby środek obrazu był skierowany bezpośrednio w kamerę. Ten efekt działa całkiem fajnie, ale powinien być bardziej wyrafinowany, ponieważ grawitacja nie zawsze jest umieszczona w środku. Wkrótce planuję napisać lepsze podejście. Poniższy fragment kodu pokazuje przekazywanie parametrów paralaksy do uniformów, dzięki czemu każdy wierzchołek może się odpowiednio dostosować. Ostatecznie zostanie to również przeniesione do shaderów.

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

Morfowanie obrazu i koale

Mapy głębokości i obrazy generowane przez sztuczną inteligencję są fajne, ale musimy też szanować naturę. Istnieje scenariusz o nazwie Koala z dwiema funkcjami. Morfowanie obrazu między dwoma obrazami i reprezentacja obrazu „natury”. Zacznijmy od tego ostatniego. W tym scenariuszu kolory są odwzorowywane na płaszczyźnie Z jako liczby 24-bitowe, a następnie normalizowane. Jest coś magicznego w reprezentacji obrazu przestrzennego, zwłaszcza że daje nam możliwość odczuwania obiektów, a także daje bliższą intuicję, dlaczego działają sieci neuronowe do rozpoznawania obiektów. Charakterystykę obiektów w przestrzeni można niemal poczuć:

Jeśli chodzi o morfing, w tym scenariuszu mamy dwie różne koale i 10 morfingowych obrazów pośrednich — możesz przeglądać je za pomocą suwaka lub pozwolić WebGL animować je w sprężysty sposób. Obrazy między oryginałami zostały wygenerowane za pomocą algorytmu morfingu Beiera-Neely'ego . W tym algorytmie musisz zmapować 1–1 zestaw linii, gdzie każda linia reprezentuje jakąś cechę, taką jak oczy lub podbródek. O algorytmie opowiem bardziej szczegółowo w następnej sekcji. Na poniższym filmie widać, że jest kilka dziwnych fragmentów, ponieważ użyłem tylko 10 funkcji linii i chciałem zobaczyć wyniki. Ale nawet jeśli nie jest idealna, wersja animowana jest całkiem niezła, zwłaszcza zmieniający się szalik, bardzo psychodeliczny:

Morphing obrazu, API głębi portretu i waifus

Czas połączyć wszystko powyżej i spojrzeć na bardziej złożony przykład. W tej sekcji będziemy interpolować między dwoma obrazami z mapami głębi. Jak wspomniałem, MiDaS jest ogólnie niesamowity, ale jeśli chcesz uzyskać mapę głębi ludzkiej twarzy, potrzebujesz bardziej szczegółowego modelu, wytrenowanego specjalnie na ludzkich twarzach (co nie znaczy, że nie możesz uzyskać całkiem dobrej mapy głębi z MiDaS). Na początku tego roku TensorFlow zaprezentował API Portrait Depth i podobnie jak MiDaS, również ten projekt można uruchomić na swoim urządzeniu. W tej części wybieram Anne Hathaway i Olivię Munn jako cele morfingu. W rozwijanym menu możesz znaleźć tę sekcję o nazwie Waifus. Nie pytaj mnie dlaczego. Przed morfingiem spójrzmy na mapę głębi Anne jako chmurę punktów:

Widzimy, że model dał nam całkiem niezłe wyniki. Nie bylibyśmy w stanie stworzyć prawdziwej siatki ludzkiej twarzy, ale poza tym są ładne szczegóły, takie jak zęby z objętością, głowa wyraźnie na innym poziomie niż tułów, a relief twarzy jest również dokładny. Można powiedzieć, że jest błąd w obrazie, ponieważ wygląda na to, że Anne ma drugi podbródek, ale wiemy, że to nieprawda. Jak powiedział Nietzsche: „Tylko ludzie z podwójnym podbródkiem to ci, którzy spoglądają w otchłań swojego upadającego życia”. Nigdy też nie ufaj żadnym cytatom w Internecie. :]

API głębi portretu działa w dwóch krokach. Najpierw wykrywa twarz/włosy/szyję i inne części znajdujące się blisko twarzy (nie wiem jak to jest wyważone, to zdanie pochodzi tylko z moich doświadczeń z majsterkowania modelem), następnie nakłada na twarz czarną maskę resztę obrazu i ostatecznie tworzy mapę głębi. Krok maskowania nie zawsze jest dokładny, więc ostateczna mapa głębokości ma ostry szum na krawędzi. Na szczęście jest to dobry hałas — można go usunąć w tej samej instrukcji podczas zapisywania do buforów. Napisałem heurystykę, aby usunąć szum i zadziałało za pierwszym razem.

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

Przed i po odszumianiu

Ponieważ morfing koali wytworzył fragmenty, do morfingu twarzy zdecydowałem się użyć TensorFlow i ich modelu rozpoznawania twarzy/cech. Użyłem około 80 linii charakterystycznych, 8 razy więcej niż w przypadku koali:

Rezultat jest znacznie lepszy i wygląda naprawdę fajnie, nawet przy użyciu map głębi! Jest jedna usterka z włosami Olivii, ale nic, czego nie dałoby się później naprawić. Podczas animowania tego morfingu każda klatka odcina fragment pikseli. Dzieje się tak dlatego, że tło jest uważane za płaskie, ale można je również naprawić w post-processingu, po prostu mapując ten fragment na płynną funkcję i przyspieszoną animację w odniesieniu do odległości od głowy.

Morfowanie obrazu w ogóle

Jeśli chodzi o algorytm morfingu Beiera-Neely'ego, nie byłem w stanie znaleźć żadnej implementacji równoległej lub akcelerowanej przez GPU, więc obliczenie klatek pośrednich na dużych obrazach, takich jak 2k * 2k pikseli z dziesiątkami linii charakterystycznych, zajmuje dużo czasu. W przyszłości planuję napisać te implementacje. Pierwsza implementacja byłaby przeznaczona do użytku lokalnego/serwerowego za pośrednictwem CUDA, a druga wykorzystywałaby GPGPU i shadery. GPGPU jest szczególnie interesujące, ponieważ możesz być bardziej kreatywny.

Istnieje kilka projektów (np . DiffMorph ), które towarzyszą sieciom neuronowym w procesie morfingu — jako przyspieszenie procesu lub jako kompletny proces. Powodem użycia i eksperymentowania ze starym, dobrym algorytmem deterministycznym jest to, że chciałem przejrzeć kilka artykułów i przeprowadzić implementację związaną z GPU.

Projekty poboczne są świetne

Zaczęło się od szybkiego prototypu, aby zwizualizować chmury punktów z mojego innego projektu, ale w końcu spędziłem około tygodnia na testowaniu i dodawaniu nowych funkcji dla zabawy, a także nauczyłem się kilku rzeczy po drodze. Poniżej znajdziesz link do mojego githuba z kompletnym kodem oraz do strony z wersją demonstracyjną na żywo. Nie krępuj się rozwidlać, a nawet robić PR z nowymi pomysłami. Planuję dodać więcej funkcji, ale nie chcę zepsuć więcej. Tylko zastrzeżenie, nie jestem programistą stron internetowych, więc mogę używać dziwnych konstrukcji, ale jestem otwarty na krytykę!

Repozytorium Github
Demo na żywo w Internecie

Project to statyczna strona internetowa bez możliwości wgrywania własnych grafik. Do niestandardowego użycia musisz sklonować repozytorium i samodzielnie dostarczyć obrazy/mapy głębi. Jeśli kiedykolwiek używałeś conda i npm, nie powinieneś mieć problemów z MiDaS i innymi narzędziami. Mogę zdynamizować projekt i wygenerować mapy głębi / morphing / zdjęcia 3D po stronie serwera, ale to zależy od moich możliwości czasowych. Ale na pewno planuję dodać funkcje i refaktoryzację. Niektóre scenariusze powodują ponowne ładowanie strony na iPhonie podczas korzystania z przeglądarki Safari lub Chrome. Wkrótce to debuguje.

Dziękuję za poświęcony czas i przeczytanie tego postu. Do zobaczenia wkrótce!