Для проекта Местное бремя болезней IHME мы создаем наборы растровых данных, показывающие географическое распространение болезней, медицинских вмешательств и других показателей. Каждый пиксель представляет собой наилучшую оценку нашими исследователями значения данной меры, выраженной в виде числа с плавающей запятой, для области, представляющей собой квадрат размером примерно 5 км на 5 км. Чтобы визуализировать эти точки данных, мы раскрашиваем пиксели, используя линейные цветовые шкалы, переводя каждое значение с плавающей запятой в цвет посредством линейной интерполяции. Вот, например, пиксельная карта, показывающая охват вакцинацией в Африке. Глядя на легенду в правом нижнем углу, вы можете видеть, что значения данных в диапазоне от 0,0 до 100,0 сопоставляются с цветами в диапазоне от красно-оранжевого до синего с некоторыми другими промежуточными цветами.

Эффективная визуализация этих пиксельных данных в нашем инструменте веб-визуализации (показанном выше) оказалась интересной задачей. В этой статье описаны проблемы, с которыми мы столкнулись, и способы их решения.

Первый подход: фрагменты карты отображаются на стороне сервера с помощью Carto

В нашем первом подходе к созданию цветных фрагментов растровой карты использовалась платформа определения местоположения Carto. Carto, среди прочего, предоставляет сервер картографических листов, который способен раскрашивать пиксели с помощью линейной интерполяции, которая нам была нужна. Хотя настроить пользовательский экземпляр платформы с открытым исходным кодом Carto было далеко не просто, ее возможности какое-то время хорошо удовлетворяли наши потребности. Мы смогли дать тайловому серверу SQL-запрос для создания тайлов карты из данных в нашей пользовательской базе данных PostGIS вместе с некоторыми правилами раскрашивания пикселей. Затем сервер возвращал к нашей браузерной визуализации раскрашенные фрагменты карты в виде изображений PNG, и мы отображали эти фрагменты с помощью картографического фреймворка Leaflet.

Однако со временем мы разочаровались в таком подходе. Мало того, что Carto было сложно настроить и поддерживать, особенно в нашей контейнерной реализации микросервиса, так еще и сервер тайлов был довольно медленным при создании раскрашенных фрагментов карты, что делало использование инструмента визуализации разочаровывающим. Мы провели тестирование производительности сервера с помощью Apache Jmeter и обнаружили, что в лучшем случае на возврат тайла уходит около 3 секунд. Я подозреваю, что мы могли бы выжать из тайлового сервера лучшую производительность с помощью некоторых оптимизаций, но его кодовая база всегда была для нас чем-то вроде непроницаемого черного ящика, и перспектива поиска иголок в этом стоге сена казалась довольно пугающей.

Идея: рендеринг на стороне клиента с помощью WebGL

Примерно в это же время я начал думать о других способах рендеринга тайлов карты. Одним из вариантов было создать собственный тайловый сервер для раскрашивания. Пользовательский сервер мог бы лучше соответствовать нашим конкретным потребностям, и, что особенно важно, мы могли бы лучше оптимизировать его производительность, потому что лучше его понимали бы. Тем не менее решение на стороне сервера имело некоторые присущие ему ограничения. Рендеринг большого количества пикселей на ЦП неизбежно будет медленным, потому что пиксели должны обрабатываться последовательно (или с ограниченным параллелизмом, использующим преимущества многоядерной архитектуры). Мне казалось, что идеальный способ рендеринга изображений — на GPU, который специально разработан для параллельной обработки большого количества пикселей. Однако, поскольку общедоступное оборудование, на котором развертываются наши приложения, не имеет графических процессоров, рендеринг графического процессора на стороне сервера будет невозможен. К счастью, современные браузеры предоставляют способ обработки GPU: WebGL! Решение WebGL означало бы, что тайловому серверу почти ничего не нужно делать — просто получать пиксельные данные из PostGIS, а затем передавать их клиенту в необработанном виде. В этом случае браузер пользователя будет нести ответственность за раскрашивание пикселей, и он сможет использовать рендеринг с помощью графического процессора, чтобы сделать это очень эффективно.

Наличие необработанных значений пикселей с плавающей запятой, доступных на стороне клиента, также имело бы еще одно преимущество — предоставляя пользователю быстрый доступ к фактическим оценкам уровня пикселей в визуализации. В нашей визуализации уже была возможность получить значение данных данного пикселя при нажатии пользователем, но для этого требовался серверный запрос, и поэтому он был относительно медленным. Наличие данных, доступных на стороне клиента, означало бы, что мы могли бы отображать значения пикселей во всплывающей подсказке, когда пользователь наводит курсор на карту.

Проблема 1. Чтение плавающих элементов из текстуры в шейдере WebGL

Первым и наиболее важным препятствием, которое я предвидел в этом новом подходе, было ограничение WebGL 1.0: отсутствие поддержки текстур с плавающей запятой. «Текстура» на графическом языке — это, по сути, изображение, которое вы передаете графическому процессору. Как только текстура загружена, вы можете прочитать ее пиксели в своем шейдере, который представляет собой небольшую определяемую пользователем программу, работающую на графическом процессоре. WebGL 1.0, безусловно, наиболее широко реализованная версия стандарта, позволяет создавать текстуры с использованием различных типов пикселей, таких как RGB и RGBA, но если расширение OES_texture_float не включено для аппаратного обеспечения и браузера пользователя, его невозможно использовать. текстура, в которой пиксели представляют плавающие элементы. В WebGL 2.0 добавлена ​​поддержка плавающих текстур, но наша аналитика показала, что многие пользователи нашего инструмента не имеют возможности использовать WebGL 2.0.

К счастью, была еще одна возможность. Предполагается, что пиксель RGBA представляет четыре цветовых «канала» (красный, зеленый, синий и альфа-канал — он же непрозрачность), каждый из которых представляет собой один байт (т. е. 8 бит). Это дает нам всего 32 бита для работы на пиксель, ровно столько места, сколько необходимо для хранения одного 32-битного значения с плавающей запятой! Таким образом, в принципе, мы могли бы кодировать одно число с плавающей запятой на пиксель, передавать эти необработанные двоичные данные в WebGL, а затем декодировать числа с плавающей запятой в коде шейдера. Используя TypedArrays JavaScript, легко создать двоичные данные, представляющие последовательность чисел с плавающей запятой, а затем переинтерпретировать эти данные как 8-битные целые числа без знака, чтобы «обмануть» WebGL, чтобы он принял их как данные пикселей RGBA:

Сложная часть — это декодирование пикселей в коде шейдера. GLSL 1.00, язык шейдеров, используемый в WebGL 1.0, даже не предоставляет никаких побитовых операторов, поэтому для анализа двоичных данных приходится прибегать к обычной арифметике. Мой общий подход состоял в том, чтобы разложить четыре 8-битных целых числа, представляющих каналы RGBA, в массив из 32 бит, а затем вычислить значение с плавающей запятой из этих необработанных битов. Благодаря частым ссылкам на Стандарт IEEE для арифметики с плавающей запятой (IEEE 754), пробам и ошибкам (существует несколько инструментов для отладки кода шейдера) и большому упорству, я, наконец, добился успеха. Если вам интересно, вы можете увидеть мое решение здесь.

После декодирования значения float пикселя в шейдере довольно легко раскрасить его, применив линейную цветовую шкалу. GLSL удобно предоставляет встроенную функцию mix для выполнения линейной интерполяции.

Проблема 2. Передача необработанных пиксельных данных клиенту

Когда у меня появился работающий прототип средства визуализации WebGL, я обратил свое внимание на серверную сторону, чтобы выяснить, как мы будем передавать пиксельные данные с плавающей запятой клиенту. Источником этих данных была наша база данных PostGIS. PostGIS расширяет популярную RDBMS PostgreSQL геопространственными возможностями. Он определяет тип raster для пиксельных данных с несколькими доступными выходными форматами:

  • «Хорошо известный двоичный файл» (WKB) — практически то же самое двоичное представление, которое PostGIS использует для внутренних целей.
  • GeoTIFF — геопространственное расширение распространенного формата TIFF.
  • JPEG
  • PNG

Из этих вариантов JPEG и PNG были неприемлемыми, потому что, реализованные в PostGIS, они не поддерживают пиксели с плавающей запятой. JPEG также является форматом с потерями, и мы не хотели терять данные. GeoTIFF подойдет, потому что он без потерь и поддерживает пиксели с плавающей запятой, но формат довольно сложный, и я подозревал, что парсить его будет непросто. В конечном итоге я выбрал WKB, потому что его определение было проще, и я полагал, что PostGIS будет быстрее производить его, потому что не потребуется преобразование формата. Я не смог найти никаких существующих инструментов для парсинга этого формата, но определение было довольно простым, так что написать свой собственный парсер не составило большого труда.

С данными пикселей с плавающей запятой, извлеченными из PostGIS, мне просто нужен был способ передать их клиенту. Было много возможностей для формата обмена, но я выбрал PNG, прежде всего потому, что он казался наиболее распространенным форматом для растровых данных на серверах картографических листов. Обычно PNG обрабатывают пиксели как цветовые каналы RGB или RGBA, но я черпал вдохновение из общего подхода к созданию тайлов Digital Elevation Model (DEM), при котором значения, не представляющие цвета, кодируются в каналы RGBA (или более абстрактно). 32 бита) каждого пикселя. Наши плитки PNG будут немного отличаться от плиток DEM, потому что нам нужно кодировать 32 числа с плавающей запятой, тогда как PNG DEM обычно кодируют 32-битные целые числа. Тем не менее, основная идея та же — использование 32 битов, доступных для каждого пикселя, для кодирования некоторого значения, которое не представляет цвет. Проницательные читатели также должны признать, что это тот же подход, который мы использовали для кодирования плавающих элементов в текстуре WebGL. Одним из преимуществ использования PNG является то, что этот формат обеспечивает сжатие без потерь. Схема сжатия разработана для RGB(A), поэтому она не так хорошо работает для сжатия чисел с плавающей запятой, но все же лучше, чем отсутствие сжатия.

Проблема 3. Интеграция с Leaflet

К этому моменту у меня был рабочий прототип, охватывающий весь стек. Обобщить:

сервер:

  • запрашивать PostGIS растровые данные с плавающей запятой
  • разобрать результат, извлекая необработанные пиксели
  • упаковать пиксели в тайл PNG и отправить клиенту

клиент:

  • извлечь пиксели из PNG
  • передать их в WebGL через текстуру
  • декодировать и раскрашивать каждый пиксель в шейдере

Чтобы перейти от этого прототипа к полной реализации, которая работала бы в нашем картографическом приложении, оставалось одно серьезное препятствие: интеграция рендеринга WebGL в Leaflet.

Leaflet — это популярный и гибкий фреймворк отображения JavaScript, и одной из его самых сильных сторон является его расширяемость. Встроенный компонент TileLayer — это стандартный способ отображения растровых тайлов в Leaflet. Мы уже написали пользовательский компонент, расширяющий этот слой листов для нашей предыдущей реализации с использованием Carto. Я подумал, что мы могли бы сделать что-то подобное для новой реализации, используя WebGL, но осталось много вопросов, на которые нужно было ответить.

Чтобы выяснить, как лучше всего это сделать, я потратил некоторое время на изучение общедоступных API и внутренних интерфейсов как TileLayer, так и GridLayer (которые расширяет первый). GridLayer поддерживает кэш тайлов, индексированных по координатам x, y и z (уровень масштабирования). В зависимости от текущего вида пользователя и некоторых параметров конфигурации GridLayer отвечает за принятие решения о том, когда добавлять или удалять фрагменты, а также за управление расположением этих фрагментов на карте. Фактическое создание плитки делегируется методу createTile, который предназначен для переопределения в производных классах. Наш пользовательский компонент должен будет получить и отобразить свои плитки в createTile.

Для оптимальной производительности в OpenGL обычно требуется отрисовать как можно больше за один проход или «вызов отрисовки». Я надеялся визуализировать несколько тайлов за один вызов отрисовки, чтобы максимально использовать преимущества аппаратного обеспечения, но дизайн GridLayer требовал отрисовки одного тайла за раз, потому что createTile вызывается по мере необходимости для каждого тайла, который должен быть отрендерен. . Была еще одна проблема: рендеринг WebGL привязан к HTML-элементу canvas. Чтобы не перегружать аппаратные ресурсы и не допустить дорогостоящих изменений состояния в системе WebGL, лучше всего использовать только один canvas для рендеринга. Однако, учитывая дизайн GridLayer, который размещает HTML-элемент в DOM для каждой плитки, одного canvas явно недостаточно.

Создание нового контекста WebGL для каждого нового canvas, добавляемого в DOM, казалось слишком дорогим, поэтому вместо этого я решил создать один закадровый canvas, используемый исключительно для рендеринга WebGL. Каждая плитка будет отображаться в этом закадровом canvas по требованию, затем визуализированные пиксели будут скопированы в новый canvas, который GridLayer будет прикреплен к карте. Это не идеальное решение, потому что, когда нужно отрисовать много плиток, нужно скопировать много пикселей из рендеринга canvas в экранные canvases, но это казалось лучшим вариантом, учитывая дизайн GridLayer.

Расцветает

К настоящему времени основная работа была завершена, но я хотел добавить еще несколько полезных функций. Во-первых, я хотел сделать значение пикселя с плавающей запятой под курсором доступным для определяемых пользователем событий мыши. Так как родительский класс, GridLayer, поддерживает кеш тайлов, видимых на экране, сделать это было довольно просто. Я мог бы использовать положение курсора на экране, чтобы определить (1) какая плитка находилась под курсором и (2) пиксельные координаты курсора внутри плитки. Оттуда значение с плавающей запятой можно получить из двоичных данных пикселей с помощью JavaScript DataView:

Во-вторых, я хотел анимировать переходы (попиксельно), когда пользователь переключал представление для загрузки нового набора плиток. Помимо приятного визуального удовольствия, я подумал, что эти переходы могут быть полезны в визуализации локального бремени болезни для освещения изменений с течением времени. В частности, мы обычно предоставляем элемент управления «воспроизведение», который позволяет пользователям просматривать данные за несколько лет в определенной временной последовательности. Мне было трудно воспринимать величину изменений из года в год при просмотре последовательности статических изображений, как мы изначально реализовали функцию воспроизведения. Однако я подозревал, что анимированные переходы помогут привлечь внимание к областям, где происходят более драматические изменения.

Чтобы реализовать эти переходы, мне нужно изменить средство визуализации WebGL. В частности, во время переходов необходимо будет загрузить две текстуры — одну, содержащую старые тайлы, и одну, содержащую новые. Код шейдера должен был бы вычислять значения пикселей как в старых, так и в новых тайлах и выполнять интерполяцию между ними с течением времени. Необходимо было учитывать несколько случаев, потому что можно было (1) изменить плитки, но сохранить правила раскрашивания прежними, и (2) изменить плитки и правила раскраски. Третьим вариантом было бы сохранить те же плитки, но изменить правила раскрашивания, но, поскольку это было невозможно в приложении Local Burden of Disease, я решил отложить его реализацию до тех пор, пока это действительно не понадобится.

Изменяя плитки, но сохраняя правила раскрашивания, мы могли бы интерполировать между значениями с плавающей запятой каждого пикселя в старой и новой плитках, а затем вычислять цвет интерполированного значения. Это будет означать, что каждый пиксель будет переходить по линейному градиенту (градиентам), определенному для цветовой шкалы.

Однако при изменении тайлов и правил раскрашивания этот подход не сработает, потому что старые и новые тайлы нужно раскрашивать по разным правилам. В этом случае нам нужно будет вычислить цвет каждого пикселя в старых плитках и каждого пикселя в новых плитках, а затем выполнить интерполяцию (в цветовом пространстве RGB) между цветами.

Обратите внимание, что здесь я упростил код шейдера, чтобы выделить основные понятия. Для любознательных читателей вот реальные реализации интерполяции по значению и интерполяции по цвету.

Результат

Новый подход к извлечению и рендерингу фрагментов карты в приложении LBD дал обнадеживающие результаты с точки зрения производительности и удобства использования. Запросы плиток теперь выполняются намного быстрее — обычно менее чем за секунду, по сравнению с 3 и более секундами в предыдущей реализации. Это делает использование инструмента в геопространственном (то есть растровом) режиме более плавным, избавляя от необходимости долго ждать загрузки фрагментов карты. Просмотр значений пикселей при наведении курсора мыши дает пользователям немедленный и непрерывный доступ к визуализируемым оценкам. Наконец, анимированные попиксельные переходы облегчают восприятие изменений между одним представлением и другим, что особенно полезно для визуализации изменений с течением времени.

После реализации этого решения в приложении LBD я извлек и опубликовал основные компоненты как программное обеспечение с открытым исходным кодом:

  • Парсер JavaScript для типа растра PostGIS WKB: wkb-raster
  • Шейдерная функция GLSL для разбора 32-битных чисел с плавающей запятой из векторов RGBA: glsl-rgba-to-float
  • Слой листовки: Leaflet.TileLayer.GLColorScale

Первоначально опубликовано на http://github.com.