Визуализация облаков точек с помощью Three.js

Некоторое время я работал над внутренним проектом, результатом которого, среди прочего, также являются облака точек. Когда я думал о правильном инструменте визуализации этих облаков, я составил список вещей, которые он должен уметь делать:
- Настройка
- Возможность передавать пользовательские вершинные/фрагментные шейдеры
- Легко поделиться
Как всегда, этот сайд-проект не закончился только визуализацией. Поскольку я хотел сделать это интересным и для других, в следующие 15 минут я расскажу вам кое-что о картах глубины / MiDaS, распознавании лиц / TensorFlow, алгоритме морфинга Бейера-Нили и эффекте параллакса. Не волнуйтесь, там будет много изображений и видео. Этот пост не является подробным руководством, а скорее демонстрацией нескольких концепций, однако он должен быть достаточно хорош в качестве учебного материала вместе с репозиторием Github.
Об облаках точек и Three.js
В общем случае облако точек — это просто набор точек в пространстве. Такие облака точек можно получить, например, с помощью лидаров или даже с помощью современного iPhone. Лучшие лидары могут генерировать несколько миллионов точек в секунду, поэтому оптимизация и использование графического процессора в основном необходимы для правильной визуализации. Я мог бы написать еще один пост о подходах к визуализации в зависимости от размера набора данных, но пока Three.js и WebGL достаточно хороши. Надеюсь, скоро мы сможем использовать и WebGPU — он уже доступен, но пока ничего официального в отношении Three.js.
Three.js дает нам 3D-объект Points, который внутренне визуализируется с флагом gl.POINTS . Класс Points ожидает два параметра: Material и Geometry. Давайте поближе посмотрим на них обоих.
Геометрия
Есть много вспомогательных геометрий, таких как Plane of Sphere, которые дают вам возможность создавать более быстрые прототипы, однако я решил использовать BufferGeometry . Хотя создание чего-то значимого занимает у вас больше времени, у вас есть полный контроль над всем, что связано с геометрией. Давайте посмотрим на фрагмент ниже:
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)
В простых случаях, таких как 2D-изображения, вы можете использовать карту глубины в качестве карты смещения, но в моем случае мне нужно более сложное поведение облаков точек и 3D-моделей.
Материал
Опять же, в Three.js есть много предопределенных материалов, но в нашем случае я использовал ShaderMaterial , так как мы можем предоставлять собственные шейдеры и иметь большую гибкость / лучшую производительность. Следующий фрагмент показывает как базовый вершинный, так и фрагментный шейдер. Некоторые из включений специфичны для Three.js, поэтому вы можете использовать API, доступный на стороне TypeScript. Поскольку их код доступен на Github , вы всегда можете проверить все, что нужно.
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 );
}
}
`;
Важное примечание относительно живого демо-сайта. Гифки и видео, показанные в этом посте, были сделаны, когда визуализатор использовал сырые/высококачественные ресурсы. Живая демонстрация использует масштабированные ресурсы примерно в 10 раз, всего около 15 МБ. Кроме того, в то время как в локальной среде я могу позволить себе загрузить необработанные данные и подождать несколько секунд, живая демонстрация использует некоторые внутренние ярлыки, чтобы сделать работу лучше. Т.е. построение геометрии частично выгружается в Web worker или загружается в качестве текстур и загружается в закадровый холст, чтобы я мог читать ImageData и заполнять базовые буферы и буферы морфинга. - Отчаянные времена требуют отчаянных мер. :]
Давайте заставим эти облака танцевать!
В следующих нескольких разделах я расскажу вам больше об используемых концепциях и опишу демонстрационные сценарии, которые вы можете попробовать позже. Я не буду подробно описывать такие конвейеры, как [Набор изображений -> Распознавание лиц -> Экспорт данных -> Интеграция с Three.js], но вы сможете сделать это самостоятельно, перейдя по предоставленным ссылкам.

MiDaS и эффект параллакса
MiDaS — это удивительная модель машинного обучения, которая может вычислять карту глубины из одного изображения (монокулярная оценка глубины). Изучив несколько наборов данных, он дает действительно хорошие результаты, и вы можете свободно использовать его на своем устройстве. Большая часть настройки выполняется через conda, вам остается только скачать нужную модель (до 1,3 Гб). Какое время, чтобы быть живым, вы можете легко использовать знания кучи умных людей дома. Когда у вас есть изображение и соответствующая карта глубины, ничто не мешает вам использовать этот рендерер в облаке точек, как вы можете увидеть в следующем видео.
Но подождите, есть еще! Модели Text2Image, Image2Image, Text2Mesh и т. д. находятся в тренде, и мы не можем оставаться в стороне. Следующее видео показывает несколько изображений, сгенерированных ИИ (Midjourney и Stable Diffusion), обработанных с помощью MiDaS и визуализированных (когда изображения меняются, мы только меняем буферы морфинга, упомянутые ранее):
Вы, наверное, поняли, что изображения движутся. Это работа нашего вершинного шейдера. Существует два типа эффекта параллакса. Первый отслеживает положение камеры и пытается трансформировать облако так, чтобы центр изображения смотрел прямо в камеру. Этот эффект неплохо работает, но он должен быть более изощренным, потому что гравитация не всегда находится в центре. Я планирую написать лучший подход в ближайшее время. Фрагмент кода ниже показывает передачу параметров параллакса в униформы, чтобы каждая вершина могла соответствующим образом корректироваться. В конечном итоге это будет перенесено и в шейдеры.
//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)
Морфирование изображений и коалы
Карты глубины и изображения, созданные искусственным интеллектом, — это круто и все такое, но мы также должны уважать природу. Существует сценарий под названием Koala с двумя особенностями. Преобразование изображения между двумя изображениями и представление изображения «природа». Начнем с последнего. В этом сценарии цвета отображаются в z-плоскости как 24-битные числа, а затем нормализуются. Есть что-то волшебное в представлении пространственных изображений, особенно потому, что оно дает нам возможность чувствовать объекты, а также дает более глубокое понимание того, почему работают нейронные сети для распознавания объектов. Вы можете почти почувствовать характеристики объектов в пространстве:
Что касается морфинга, в этом сценарии у нас есть две разные коалы и 10 морфируемых промежуточных изображений — вы можете просмотреть их с помощью ползунка или позволить WebGL анимировать их прыгучим образом. Изображения между оригиналами были сгенерированы с помощью алгоритма морфинга Бейера-Нили . Для этого алгоритма вам нужно отобразить 1–1 набор линий, где каждая линия представляет какую-либо особенность, например глаза или подбородок. Подробнее об алгоритме я расскажу в следующем разделе. В видео ниже вы можете увидеть несколько странных фрагментов, потому что я использовал всего 10 линейных признаков и хотел увидеть результат. Но даже если и не идеально, анимационная версия довольно хороша, особенно трансформирующийся шарф, очень психоделический:
Морфинг изображения, API портретной глубины и waifus
Настало время объединить все вышеперечисленное и посмотреть на более сложный пример. В этом разделе мы собираемся выполнить интерполяцию между двумя изображениями с картами глубины. Как я уже упоминал, MiDaS в целом великолепен, но когда вы хотите получить карту глубины человеческого лица, вам нужна более подробная модель, специально обученная на человеческих лицах (это не значит, что вы не можете получить довольно хорошую карту глубины из МиДаС). Ранее в этом году TensorFlow представил API Portrait Depth и, как и MiDaS, также этот проект можно запустить на вашем устройстве. В этом разделе я выбираю Энн Хэтэуэй и Оливию Манн в качестве целей для трансформации. В раскрывающемся списке вы можете найти этот раздел под названием Waifus. Не спрашивайте меня, почему. Перед морфингом давайте посмотрим на карту глубины Энн в виде облака точек:
Мы видим, что модель дала нам довольно хорошие результаты. Мы не смогли бы создать реальную сетку человеческого лица, но в остальном есть приятные детали, такие как зубы с объемом, голова явно находится на другом уровне, чем туловище, и рельеф лица также точен. Можно было бы сказать, что это глюк изображения, потому что кажется, что у Анны второй подбородок, но мы знаем, что это неправда. Как сказал Ницше: «Только люди с двойным подбородком смотрят в бездну своей неудачной жизни». Кроме того, никогда не доверяйте никаким цитатам в Интернете. :]
API портретной глубины работает в два этапа. Сначала он определяет лицо/волосы/шею и другие близкие к лицу части (я не уверен, как это взвешивается, это предложение исходит только из моего опыта, когда возился с моделью), затем он делает черную маску на остальную часть изображения и, наконец, создает карту глубины. Шаг маскирования не всегда точен, поэтому окончательная карта глубины имеет резкий шум по краям. К счастью, это хороший шум — его можно убрать в том же пошаговом руководстве при записи в буферы. Я написал эвристику для устранения шума, и она сработала с первой попытки.
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)
}
}
}


Поскольку морфинг коал производил фрагменты, для морфинга лиц я решил использовать TensorFlow и их модель распознавания лиц/признаков. Я использовал около 80 характерных линий, в 8 раз больше, чем для коал:
Результат намного лучше, и это выглядит действительно круто, даже при использовании карт глубины! Есть один глюк с волосами Оливии, но ничего, что нельзя было бы исправить позже. Когда вы анимируете этот морфинг, каждый кадр отрезает часть пикселей. Это связано с тем, что фон считается плоским, но его также можно исправить при постобработке, просто сопоставив этот фрагмент с функцией сглаживания и ускоренной анимацией относительно расстояния от головы.
Морфинг изображения в целом
Что касается алгоритма преобразования Бейера-Нили, мне не удалось найти его параллелизованную или ускоренную на GPU реализацию, поэтому вычисление промежуточных кадров на больших изображениях размером 2k*2k пикселей с десятками характерных линий занимает много времени. В будущем я планирую написать эти реализации. Первая реализация будет предназначена для локального/серверного использования через CUDA, а вторая будет использовать GPGPU и шейдеры. GPGPU особенно интересен тем, что вы можете проявить больше творчества.
Есть несколько проектов (например , DiffMorph ), которые сопровождают нейронные сети в процессе морфинга — как ускорение процесса или как полный процесс. Причина использования и экспериментов со старым добрым детерминированным алгоритмом заключается в том, что я хотел изучить несколько статей и реализовать реализацию, связанную с GPU.
Сайд проект отличный
Это началось как быстрый прототип, просто для визуализации облаков точек из моего другого проекта, но в конце концов я потратил около недели на тестирование и добавление новых функций просто для удовольствия, и я также узнал несколько вещей по пути. Ниже вы можете найти ссылку на мой github с полным кодом и на веб-сайт для живой демонстрации. Не стесняйтесь раскошелиться или даже сделать пиар с новыми идеями. Я планирую добавить больше функций, но не хочу больше портить. Просто отказ от ответственности, я не веб-разработчик, поэтому я могу использовать некоторые странные конструкции, но я открыт для критики!
Репозиторий Github
Живая веб-демонстрация
Проект представляет собой статичный сайт без возможности загрузки пользовательских изображений. Для индивидуального использования вам необходимо клонировать репозиторий и предоставить изображения / карты глубины самостоятельно. Если вы когда-либо использовали conda и npm, у вас не должно возникнуть проблем с MiDaS и другими инструментами. Я могу сделать проект динамичным и генерировать карты глубины / морфинг / 3D-фотографии на стороне сервера, но это зависит от моих временных возможностей. Но я точно планирую добавлять функции и рефакторинг. Некоторые сценарии вызывают перезагрузку страницы на iPhone при использовании Safari или Chrome. Это будет исправлено в ближайшее время.