Как я искал боровики и дошёл до спутниковой классификации леса
Грибов в этом году у нас было мало. То ли весенние заморозки, то ли сухое лето, то ли дожди, которые под конец, наоборот, лили без остановки. Не поймёшь. Только к концу августа мне начали попадаться первые сообщения: в лесах Ленинградской области наконец пошли боровики. Моя супруга боровики очень любит, и каждую осень мне приходится искать места их обитания.
В этот раз на «тихую охоту» оставалось совсем немного времени — ну, три пары выходных, не больше, как мне тогда казалось. Конкуренция в сезон почище, чем за вакансии на HH, а тратить эти считанные дни на прогулки с пустой корзиной не хотелось.
Первый боровик вылез прямо у нас на дачном участке. Восприняли как сигнал: пора.
Первым делом мы отправились в сторону Алёховщины — проверять сосновые боры и беломошники. С точки зрения грибника именно такой лес и мечтаешь увидеть: стройные сосны, сухой песок, светло, а под ногами белёсый хрустящий ковёр, на котором так и представляешь крепкий боровик с тёмно-каштановой шляпкой.
Беломошник — название народное и немного обманчивое: этот светлый покров образуют преимущественно лишайники (в основном родов Cladonia и Cetraria), а вовсе не мох. Ходить по такому бору — чистое эстетическое удовольствие.
Грибов не было. Совсем.
В тот же день двинулись дальше, в сторону Олонца. Проверили места, кторые заприметили две недели назад по дороге на терский берег и рассчитывали на них, — тоже пусто. На обратном пути я решил наугад свернуть к Нижне-Свирскому заповеднику. Без всякого чёткого плана и уже без больших надежд. И там, в придорожной канаве у второстепенной грунтовки, в совершенно случайном даже не очень то и сосняке, мы набрали две полные корзины за час.
После такой поездки (четыре часа непрерывной рулёжки в одну сторону как-никак) появился чисто айтишный интерес: как выбирать точки для следующего выезда, чтобы не стрелять вхолостую? Почему в хрестоматийном лесу с открытки — шаром покати, а там где совсем грибов не ждешь их с избытком? Хотелось уменьшить фактор случайности в этом итак небыстром процессе.
Пытливый мозг, грибной Инстаграмм (экстремисткая секта, всячески порицаю) и прошлый опыт намекали: существует прямая связь между сосной, грибницей и песком. И тогда я решил построить карту пригодных биотопов.
Отдельный гриб на спутниковом снимке, само собой, не разглядишь — разрешение не то. Но можно попробовать найти подходящий для него лес. Поиски начались с микобиологии.
Что мы вообще ищем: биология вида
Прежде чем загонять спутники в алгоритмы, стоило уточнить сам объект поиска. Я ориентировался на белый гриб сосновый — Boletus pinophilus. Поручил Клоду собрать научные статьи по теме и ушел пить чай.
Научная справка: молекулярная филогения и систематика Boletus pinophilusЕго самостоятельность подтверждается молекулярным филогенетическим исследованием Beugelsdijk et al. (2008): анализ участков ДНК (дезоксирибонуклеиновой кислоты) — ITS (Internal Transcribed Spacer, внутреннего транскрибируемого спейсера рибосомной РНК) и GAPDH (Glyceraldehyde-3-Phosphate Dehydrogenase, гена фермента глицеральдегид-3-фосфатдегидрогеназы) — надёжно выделил этот вид в отдельную эволюционную ветвь внутри секции Boletus.
Для моей задачи особенно полезна экологическая характеристика: вид образует эктомикоризу преимущественно с сосной обыкновенной (Pinus sylvestris) и приурочен к сухим, кислым, дренированным песчаным и супесчаным почвам. Среди морфологических маркеров — мясистая бугорчатая шляпка тёмного винно-красно-коричневого оттенка и выраженная рельефная сеточка в верхней части массивной ножки.
При этом исследование предостерегает от поверхностных выводов:
Связь с сосной не абсолютна: зафиксированы находки микоризы в еловых насаждениях.
Белый гриб под сосной — не обязательно B. pinophilus. Авторы описали образцы, собранные на песчаных почвах под соснами, которые фенотипически и по биотопу напоминали сосновый боровик, но по ДНК оказались экологической формой обычного белого гриба — Boletus edulis (в микологических статьях после первого упоминания род сокращается: B. pinophilus, B. edulis).
Так с помощью ложки заварки родилась рабочая гипотеза проекта: сочетание чистого сосняка и бедной песчаной почвы позволяет очертить приоритетные полигоны для полевой разведки. Это метод косвенного моделирования пригодности местообитаний (Habitat Suitability Modelling). Он не гарантирует присутствие мицелия и тем более не обещает слой грибов к выходным, но резко отсекает заведомо бесперспективные площади.
Сбор «сырья»: Почвы, спутники и первые оверлеи
Первыми источниками данных стали:
Почвенная карта РСФСР 1988 года под редакцией В. М. Фридланда (масштаб 1:2 500 000);
Слой хвойных лесов глобального мониторинга Copernicus Global Land Service (CGLS);
Краудсорсинговая картографическая база OpenStreetMap (OSM), откуда выгружались участки с отметками типов растительности.
У каждого источника была своя задача. Карта Фридланда указывала на кислые почвы борового ряда. Спутниковый продукт показывал массивы деревьев. А в OSM энтузиасты вручную размечают участки леса. Не то, чтобы всё это было категорически необходимо в поездке, но если уж начал....
Извлечение геоданных: Livewire-харвестинг и оконный доступ к COGИзвлечение почв: динамический веб-харвестинг
На портале soil-db.ru оцифрованная институтом им. Докучаева карта Фридланда не отдаётся готовым файлом. Она работает в веб-интерфейсе через фреймворк Livewire: при панорамировании окна браузер отправляет метод loadFeatures, а сервер шлёт геометрию полигонов в событии livewire-map:add-features.
Я написал сборщик на базе протокола Chrome DevTools Protocol (CDP), который прошёл всю Ленинградскую область сеткой из 55 перекрывающихся окон зума 9, вытянул векторные контуры и слил их в единый GeoJSON (RFC 7946) — формат хранения географических объектов на базе синтаксиса JSON.
Снапшот состояния, скопированный из активного окна, содержал кэш loadedIds — сервер считал эти участки уже загруженными и наотрез отказывался отдавать центральное окно Петербурга и Карельского перешейка. Пришлось сбрасывать идентификаторы и догружать недостающие 60 полигонов отдельным проходом. Итог: 364 полигона, 25 почвенных разностей. Главный фаворит — иллювиально-железистые подзолы (), визитная карточка сосновых боров на песках.
Маска леса: оконный доступ к COG
Для лесного покрова я взял продукт CGLS-LC100 (Buchhorn et al., 2020) за 2019 год. Это глобальный растр с шагом 100 м. Качать весь файл (около гигабайта) не потребовалось: GeoTIFF был оформлен по стандарту Cloud-Optimized GeoTIFF (COG).
С помощью библиотеки GDAL и её виртуальной файловой системы /vsicurl в связке с Python-библиотекой rasterio можно читать растр напрямую из удалённого хранилища через HTTP-запросы диапазонов байтов (range-requests). Скрипт вырезал ровно географическую рамку Ленобласти.
Далее пиксели класса 1 (вечнозелёный хвойный лес) были векторизованы функцией rasterio.features.shapes: получилось 3044 контура площадью более 1 км².
Ориентироваться только на теги OSM (leaf_type=needleleaved — тег, означающий иглолистную хвою вообще, а не конкретно сосну) не позволяла фрагментарность покрытия.
Собранное вместе это позволило получить первый прототип карты, который оказался не плох в ретроспективе.
Вторая поездка: Лужские беломошники
Вооружившись первой версией карты, на следующие выходные мы позвали друзей и махнули под Лугу.
Есть особое чувство безответственности, когда везешь людей за 150 км под вечер, основываясь на только что склеенном скриптом файле. Но карта и бородатые мужики из инстаграмма как будто намекали нам: на песчаных террасах реки Луги хвойный лес совпадает с нужными подзолами.
Первая точка у самой реки встретила нас красивейшим пейзажем, но грибы были не те: разномастные сыроежки и пара старых моховиков. Закрались сомнения...
Переезжаем на вторую точку, углубляемся в массив — и попадаем в безупречный, хрестоматийный беломошник. Светлый просторный сосновый бор, чистый белый лишайниковый ковёр, под ним сухой хрустящий песок. И главное — боровики! Крепкие, тёмно-вишнёвые, как с картинки. Сомнения закравшиеся в предыдущем абзаце оказались беспочвенны.
Правда, быстро выяснился нюанс: почти всё, что росло на виду, срезали конкуренты ещё ранним субботним утром. Нам достались трудовые «хвосты». Но концепция подтвердилась на местности: косвенные признаки вывели нас ровно в целевой биотоп. Слой карты - «кто здесь проходил с ведром три часа назад» спутник, увы, отдает не всем желающим.
Алгоритм кандидатов
Чтобы не сличать слои глазами, я формализовал пересечение в алгоритм генерации полигонов-кандидатов:
Пайплайн алгоритма кандидатовЭкспертная оценка почв: типам почв Фридланда присвоили рейтинг пригодности (). В отбор пошли почвы от (прежде всего те самые иллювиально-железистые подзолы).
Растеризация: вектор почв перевели в растровую сетку, идентичную сетке CGLS-LC100.
Логическое пересечение: бинарная маска «хвойный лес почва ».
Полигонизация и отсев: преобразовали маску обратно в вектор через rasterio.features.shapes, выкинув мелкий мусор площадью до 0,3 км². Осталось 3265 зон общей площадью 10 240 км².
Зональная статистика подложки: для каждого полигона рассчитали зональное среднее доли песка в подпочве на глубине 60 см по глобальной сетке OpenLandMap / SoilGrids (Hengl et al., 2017) (разрешение 250 м).
Разделение на сосну и ель: если средняя песчаность превышала 60%, полигон получал флаг pine=true (условный сосняк); если ниже — отправлялся в категорию «прочий хвойный» (чаще сырые ельники).
Итог: 1048 «сосновых» зон (4348 км²) и 2217 прочих хвойных.
Флаг pine=true был смелым авансом: он означал не то, что модель распознала сосну, а лишь то, что хвойный лес стоит на относительно лёгкой песчаной почве. Пересечение разномасштабных данных (почвы 1:2,5 млн, песок 250 м, лес 100 м) не делает границы точнее их самого грубого компонента. Звёзды означали приоритет проверки ногами, а не плотность боровиков на квадратный метр.
Архитектура на коленке: Мобильный вьюер и смена картографического движка
После Луги стало ясно, что в лес нужен удобный полевой инструмент. Я дал Клоду задание набросать веб-интерфейс, куда заложить сценарии для смартфона:
Тап по участку раскрывает карточку с кнопками перехода в навигаторы - ссылку по протоколу geo: (RFC 5870) для открытия в сторонних офлайн-приложениях Android.
Кнопка геолокации через встроенный в браузер W3C Geolocation API: стоишь среди сосен и видишь, внутри ты расчётного полигона или ещё бредёшь по переходной зоне.
Разделение панели на «простой режим» (только грибные места, радар дождей и болота) и «экспертный» (где можно крутить прозрачность почв и подсвечивать отдельные типы).
Не спервой попытки но электронный мозген справился. Проект переставл быть простым. Автоматичеи подтянулось тестирование и деплой репозитория через гитхаб экшенс, хранение секретов, логирование логинов и еще черта в ступе. Все как мы любим.
Инженерия доставки: решение проблемы сдвига в 18 км, PMTiles и Cloudflare WorkersДраматичный переезд с Leaflet на OpenLayers
Первый прототип крутился на библиотеке Leaflet. Но тут вскрылась фундаментальная проблема геодезических проекций:
Стандартные веб-карты (OSM, Google) используют проекцию EPSG:3857 (WGS 84 / Pseudo-Mercator, или Web Mercator).
Спутниковые тайлы Яндекса исторически опираются на эллипсоидальную проекцию Меркатора — EPSG:3395 (WGS 84 / World Mercator).
Если натянуть векторный оверлей из одной проекции на растр другой, на широте Санкт-Петербурга (60° с. ш.) возникает физический сдвиг геометрии на местности примерно в 18 километров. Библиотека MapLibre GL JS отпала сразу — она жёстко привязана к Web Mercator. Единственным движком, способным динамически трансформировать растровые и векторные слои в произвольные проекции через WebGL (браузерный интерфейс аппаратного ускорения графики) и Canvas прямо на мобильном процессоре, оказался OpenLayers. Проверил прототип на смартфоне: подложка Яндекса встала без перекосов, контуры легли пиксель в пиксель.
Векторные тайлы и сжатие в PMTiles
Отдавать мобильному браузеру «сырые» файлы GeoJSON лесов и болот весом по 35 МБ через мобильную сеть на лесной опушке — верная смерть. Данные требовалось тайлировать — разрезать на стандартные пространственные квадраты разного масштаба.
Конвейер генерации оформили в воспроизводимый bash-скрипт:
Утилита mapshaper переводила координаты в локально настроенную азимутальную равновеликую проекцию Ламберта LAEA (Lambert Azimuthal Equal Area, сохраняющую площади объектов) с центральной точкой 60° с. ш., 31° в. д. В плоских метрах пороги упрощения и фильтры площади работают геометрически строго, в отличие от искажающихся угловых градусов WGS84.
Команда -clean удаляла топологические наложения и осколки растеризации.
Упрощение геометрии алгоритмами Дугласа–Пекера (1973) / Висвалингама–Уайатта (1993) с флагом keep-shapes удаляло лишние вершины, не допуская распада мелких замкнутых полигонов. Контуры болот, например, «похудели» с 778 до 333 тысяч вершин.
Утилита tippecanoe паковала полигоны в векторные тайлы формата Mapbox Vector Tile (MVT) внутри единого контейнера PMTiles.
Формат PMTiles устроен гениально: это единый файл, содержащий пирамиду тайлов со встроенным индексным заголовком. Браузер через OpenLayers шлёт к серверу запросы диапазонов байтов (HTTP range-requests), скачивая только нужные несколько килобайт на текущий экран, а сервер отвечает кодом 206 Partial Content.
Архивы легли в облачное объектное хранилище Cloudflare R2, а перед ними встал Cloudflare Workers — бессерверный обработчик на JavaScript, который проверяет авторизацию и раздаёт байты с минимальной задержкой.
Болота: метод отрицания
Чтобы не тонуть в прямом смысле слова, через Overpass API (язык запросов к базе OpenStreetMap) мы выкачали все полигоны болот Ленобласти (natural=wetland). Утилита osmtogeojson собрала их в мультиполигоны с внутренними «дырами». Этот слой стал отрисовываться поверх кандидатов ярко-синим и оливковым цветом. Логика: сосновый бор часто тянется узкой песчаной гривой между непроходимыми верховыми топями. Видишь сухое ребро среди синевы — туда и надо идти.
Монетизация: три продажи на Авито
Тогда же в поездке возникла мысль: нужно ли это кому‑то ещё? Я выставил объявление на Авито о продаже доступа к карте на две недели за символическую плату. Случились три реальные продажи. Пришлось быстро дописывать защитный контур: Вход через учётные записи Яндекса и «ВКонтакте» А Выдача приватного слоя точек только при наличии валидной сессионной cookie.
Третий выезд: А мы пойдем на север
Тем временем, сезон неумолимо катился к финалу. Окрылённые лужским успехом, мы отправились на проверенные места в Выборгский район.
Результат слегка отрезвил: на опушках стояли шеренги машин, по лесу бродили угрюмые конкуренты с пустыми ведрами. Видимо байки про грибников собирающих грибы с фонариками по ночам были не беспочвенны. На следующем намеченном участке сосняк на поверку оказался завален буреломом и затянут подлеском. Карта работала с вероятностью «фифти-фифти»: если точка обещала сосну — сосна там, скорее всего, была; но в половине мест под ногами чавкала жижа, пропущенная грубыми картами почв.
Не теряем надежды, едем дальше. Поезжаем еще буквально 10 километров в то место, где карта предсказывает беломощники и сосновый лес. И оказываемся ровно в таком лесу. Все на месте. беломощники, сосна и боровые грибы. Победа.
Вернувшись домой, я решилглубже копнуть геологию. Откуда вообще берётся песок под сосняками?
Геология четвертичного чехла и конвейер векторизации советских геолкартГеология: Девон против ледника
Песок и песчаник — принципиально разные геологические тела. Песок — рыхлый, несцементированный обломочный материал. Песчаник — монолитная осадочная горная порода, где зёрна кварца намертво связаны природным минеральным цементом. Классический пример — знаменитые девонские обрывы реки Оредеж у Белогорки и Ям-Тёсова: этим породам свыше 380 миллионов лет, когда здесь плескалось тёплое мелководное море и плавали панцирные рыбы.
Но современный сосновый бор растёт не на девонских плитах. Его среда — это четвертичный чехол, сформированный последними оледенениями:
Морена (): несортированная смесь валунов, суглинков и гравия, которую ледник грёб перед собой. Это субстрат тяжёлый, плотный, на нём чаще поднимается тёмный ельник.
Флювиогляциальные отложения (): пески и гравийники, перемытые и отсортированные бешеными потоками талых ледниковых вод. Именно они слагают зандровые поля, озы и камовые холмы — сухие, прекрасно дренированные возвышенности.
Озёрно-ледниковые отложения (): накапливались в приледниковых подпруженных озёрах. Они состоят как из песков, так и из тончайших ленточных глин. Сам по себе индекс на карте вовсе не гарантирует сухой грунт под ногами.
Эоловые пески (): древние песчаные дюны, навеянные ветром в суровом перигляциальном климате.
Теперь у меня были довольно точные данные о подходящих почвах
Четвертый выезд: Заборы Карельского перешейка
Решили проверить кандидата под Токсово в районе Кавголовского озера. Приезжаем — идеальный, звенящий сосновый бор на крутых песчаных холмах. Но ровно по границе перспективного полигона тянется трёхметровый глухой забор неизвестного генезиса. Плюнули, развернулись и пошли на соседнее болото собирать спелую клюкву.
Тупик порогов и прорыв в машинном обучении
Карта обогатилась геологией, но оставалась фундаментальная брешь: как заглянуть внутрь лесного массива с шагом не 100, а хотя бы 10–20 метров?
Первым делом мы добавили базовую глобальную маску древесного покрова ESA WorldCover v200 с шагом 10 метров (класс Tree cover). Стало видно просеки, опушки и границы вырубок. Но WorldCover — это бинарная маска: она подсвечивает дерево, но не отличает сосну от берёзы или ольхи.
Провал простых порогов Sentinel-2: физика зимних теней и унимодальный сигналПровал «в лоб»
Возникло ощущение, что решение лежит на поверхности: хвойные деревья вечнозелёные, а лиственные сбрасывают листву на зиму. Считаем нормализованный разностный вегетационный индекс NDVI (Rouse et al., 1974; Tucker, 1979):
Логика: сравним летний NDVI и весенний безлистный NDVI спутника Sentinel-2 (Drusch et al., 2012), найдём порог «просадки» зелёности и отделим хвойные от лиственных.
Мы проверили эту гипотезу на пикселях Ленобласти в Google Earth Engine (GEE) (Gorelick et al., 2017). Полный провал:
Бесснежная весенняя фенология: гистограмма сезонной разности NDVI внутри леса оказалась строго одногорбой. Никакого разделения на два колокола нет — единым порогом отделить хвою от листвы невозможно. К тому же озимые поля и луговины весной выдавали точно такую же «зелёность», как зрелый сосняк.
Зимний «снегометод»: идея искать вечнозелёные кроны над белым снегом разбилась о физику высоких широт. На 60° с. ш. солнце зимой едва поднимается на 6–15° над горизонтом. Кроны отбрасывают гигантские тени: 97% лесных пикселей сколлапсировали в физический минимум индекса.
Казалось, спутники бессильны. Но ресёрч научной литературы (Udali et al., 2021; Blickensdörfer et al., 2024) подсказал: проблема не в спутнике Sentinel-2, а в попытке решить многомерную задачу простым ручным порогом. Нужны размеченные обучающие выборки и машинное обучение.
Пайплайн
Случайный лес находит лес хвойный
Моих познаний в ML было недостаточно чтобы понять как выбрать подходящую модель и как ее дообучать. Пришлось обратиться к научным работам разных авторов раскрывающим тему определения типов растительности по спутниковым снимкам.
В конце концов был найден хорошо расписанный пайплайн для разделения хвойных и лиственных основанный как раз на Random Forest модели: MDPI rs13163237
ML-пайплайн: Random Forest, пространственная кросс-валидация и абляционный анализЯ снова обратился к OSM, но теперь не за готовыми сосняками, а за обучающими полигонами с тегом leaf_type. После отсева мелких скверов и дачных посадок (оставили строго контуры площадью от 3 гектаров) набралось 3 769 полигонов: 1 407 чисто хвойных, 451 лиственный и 1 911 смешанных. Смешанные массивы временно отложили, чтобы обучить бинарный классификатор.
Контуры ужали внутрь на 15 метров буфером отсечения граничных эффектов и насэмплировали обучающие точки с шагом 30 метров (76 760 точек). Для каждой точки в GEE собрали вектор признаков:
Оптика Sentinel-2: летний и весенний NDVI, их разность (фенологический сдвиг), коротковолновые инфракрасные каналы SWIR (Short-Wave Infrared) B11 и B12, чувствительные к влажности хвои и геометрии полога, индекс NDII;
Радар Sentinel-1: обратное рассеяние в поляризациях VV (вертикальная передача и приём) и VH (вертикальная передача, горизонтальный приём) летом и зимой;
Рельеф Copernicus GLO-30: цифровая модель высот DEM с разрешением 30 метров, из которой вычислялись абсолютная высота и уклон поверхности.
Облачные запросы падали по таймауту пяти минут, поэтому выборку выгрузили пакетным CSV-файлом, а обучение запустили локально в Python на базе библиотеки scikit-learn. Модель — Random Forest (ансамбль решающих деревьев).
Честная валидация: как не обмануть себя
В геоданных кроется страшная ловушка — пространственная автокорреляция. Соседние пиксели леса почти идентичны. Если перемешать точки случайным образом, соседние пиксели одного и того же дерева окажутся и в обучении, и в тесте, выдав дутые 99% точности.
Мы применили пространственно-блочную кросс-валидацию: всю Ленинградскую область разбили на квадраты сетки 0,25°. Модель училась на одних блоках, а тестировалась на 30% совершенно независимых полигонов, которых никогда не видела в глаза.
Результат на 29 190 проверочных точках:
Overall Accuracy (общая точность): 0,887;
Каппа Коэна (): 0,772 (отличный показатель с поправкой на случайное угадывание);
Полнота по классам: лиственный — 0,895, хвойный — 0,878.
Абляционный анализ: что реально работает?
Мы поочерёдно отключали группы признаков, чтобы понять их реальный вклад:
Набор признаков
Число признаков
Accuracy
Каппа
Только оптика Sentinel-2
6
0,848
0,692
Оптика + Рельеф (высота, уклон)
8
0,884
0,765
Оптика + Рельеф + Радар Sentinel-1
14
0,888
0,774
Полный набор (+ песок)
15
0,888
0,774
Выводы оказались поразительными:
Рельеф — ключевой драйвер: добавление высоты и уклона дало колоссальный скачок точности (+3,6 процентных пункта). Хвойные и мелколиственные леса в тайге чётко привязаны к геоморфологии.
Радар оказался лишним: 6 тяжелейших каналов Sentinel-1 дали прирост всего в 0,4 п.п. Для экономии памяти и времени радар был исключён из финального продакшена.
Анализ важности признаков по перестановкам (permutation importance) показал: лидирует летний NDVI (0,1635), за ним — уклон рельефа (0,0282) и канал SWIR B11 (0,0277). А та самая сезонная просадка NDVI, которая с треском провалилась в виде одиночного порога, внутри многомерной модели дала весомый вклад (0,0145).
Производственный релиз: компромиссы и оптимизация
Попытка рассчитать всю Ленобласть в 10-метровом разрешении в GEE зависла на 15,5 часов и была снята. На сетке 20 метров число пикселей сократилось в 4 раза, и расчёт уложился в 25 минут.
Чтобы исключить ошибки классификации над водоёмами (без маски модель объявила хвойным половину Ладожского озера), растр обрезали строгой маской древесного полога ESA WorldCover.
Итоговый слой получил название «Хвойный 20 м» (пометка , бета-версия). При нарезке растровых тайлов файл весил 466 МиБ, превышая лимит загрузки Cloudflare R2 в 300 МиБ. Мы квантовали альфа-канал прозрачности (задающий вероятность хвойного) с помощью Pillow до 16 фиксированных градаций: вес упал до 221 МиБ без визуальной деградации.
┌────────────────────────────────────────────────────────────────────────┐
│ ИТОГОВАЯ ТОЧНОСТЬ КЛАССИФИКАТОРА │
│ │
│ МАТРИЦА ОШИБОК (ТОЧЕК В ТЕСТЕ: 29 190) │
│ Назвала Лиственным Назвала Хвойным │
│ На деле Лиственный: 14 578 1 706 │
│ На деле Хвойный: 1 580 11 326 │
│ │
│ • Overall Accuracy (Общая точность): 88.7% │
│ • Каппа Коэна (согласие сверх случайного): 0.772 │
│ • Полнота детекции хвои (Recall): 87.8% │
└────────────────────────────────────────────────────────────────────────┘
sourcecraft.dev
Результат выделения хвойников
Что в сухом остатке
Изначально я просто хотел меньше крутить баранку и не бродить по пустым лесам. В итоге пет-проект заставил погрузиться в целый стек технологий и дисциплин:
Геология и почвоведение: генезис четвертичных отложений, литология девона, морфология подзолов;
Основы микологии: экология эктомикоризы Boletus pinophilus, молекулярные маркеры ITS/GAPDH;
ГИС и веб-картография: проекции, тонкости OpenLayers, векторные тайлы PMTiles и range-запросы в Cloudflare Workers / R2;
Computer Vision и OCR: конвейеры векторизации архивных геолкарт, дескрининг растров, робастные оценки;
Дистанционное зондирование Земли и ML: мультиспектральная фенология Sentinel-2, радар Sentinel-1, борьба с пространственной автокорреляцией и оптимизация ансамблей Random Forest.
Что осталось нерешённым: сосна, ель и беломошникЧестный отрицательный результат: сосна против ели
А можно ли выделить саму сосну? Мы проверили это математически.
Среди хвойных точек попытались обучить модель предсказывать песчаный субстрат по спектру кроны:
По оптике показатель ROC AUC (Receiver Operating Characteristic — Area Under the Curve, площадь под кривой ошибок) составил ровно 0,500 — эквивалент подбрасывания монетки.
Радар поднял AUC лишь до 0,537.
Спектрально кроны сосны и ели на песчаных и суглинистых почвах в среднем диапазоне Sentinel-2 неотличимы. А обучающих меток конкретных пород в OSM катастрофически мало: сосны набралось около 150 выделов, а ели — всего 28.
Выделить породу сосны в чистом виде из бесплатного космоса и выборки OSM пока не получилось. Надо либо доразмечать датасет в ручную либо взять область шире. Но наш рабочий суррогат - это пересечение ML-слоя хвойного леса 20 м с песчаными контурами ГГК-1000 и сухим рельефом — на практике прекрасно локализует боровые биотопы.
Проблема лишайникового покрова
Беломошник - геоботанический тип леса, а не ландшафтный класс. Напочвенный лишайниковый покров со спутников среднего разрешения сквозь сомкнутый полог крон не считывается. Попытки найти повыдельные таксационные карты в открытых источниках (ФГИС ЛК, региональные лесохозяйственные регламенты) упёрлись в то, что по Ленинградской области геометрия лесоустройства в открытом доступе отсутствует. Поэтому пересечение факторов остаётся наиболее честным приближением.
И пара слов о современных инструментах разработки. Без диалога с LLM (в частности, Claude) подобный исследовательский пласт: от парсинга нестандартных протоколов до математической обработки растров заняло бы в одиночку пару месяцев плотного времени. Я же уложился в серию спринтов между поездками.
Две корзины у Свири, радость от найденных боровиков под Лугой и даже забор под Токсово сложились в общую исторю этой осени. Получвшаяся карта не гарантирует, что грибы прыгнут в багажник сами но значительно экономит моторесурс и дарит азарт исследователя.
Проект продолжает развиваться:
Рабочая карта: griby-lenobl.mushrooms.workers.dev
Репозиторий проекта: Neanderthal/mushrooms
Глоссарий терминов, сокращений и обозначений
Глоссарий терминов, сокращений и систем координатКарты, геоданные и пространственные системы
OSM (OpenStreetMap) — краудсорсинговый картографический проект с открытыми геоданными. Источник разметки лесов и контуров болот.
CGLS (Copernicus Global Land Service) — служба глобального мониторинга земной поверхности в рамках европейской космической программы Copernicus.
LC100 (Land Cover 100 m) — растровый продукт земного покрова с шагом пикселя 100 м (в проекте взят слой Forest-Type за 2019 год).
ESA WorldCover — глобальная высокодетальная карта типов поверхности с разрешением 10 м от Европейского космического агентства (ESA). В проекте использован класс Tree cover версии v200.
Copernicus DEM GLO-30 — глобальная цифровая модель высот (DEM — Digital Elevation Model) с шагом около 30 метров, источник данных о высоте и углах наклона склонов.
GDAL (Geospatial Data Abstraction Library) — базовая библиотека для трансляции, трансформации и чтения растровых и векторных геоформатов.
GeoTIFF — стандартизированный формат растровых графических файлов TIFF, содержащий встроенные географические метаданные о координатной привязке.
COG (Cloud-Optimized GeoTIFF) — оптимизированный для облачных сред GeoTIFF с внутренней тайловой структурой, позволяющий скачивать фрагменты растра HTTP-запросами диапазонов байтов без полной загрузки файла.
GeoJSON — открытый формат описания географических объектов (точек, ломаных, полигонов и их свойств), основанный на синтаксисе JSON.
EPSG — реестр геодезических параметров Европейской группы нефтеразведки (European Petroleum Survey Group). Числовые коды обозначают конкретные координатные системы.
EPSG:3857 (Web Mercator / Pseudo-Mercator) — общепринятая в веб-картографии цилиндрическая проекция на сфероиде (OSM, Google Maps).
EPSG:3395 (World Mercator) — эллипсоидальная проекция Меркатора, применяемая в тайловых сервисах Яндекса.
LAEA (Lambert Azimuthal Equal Area) — азимутальная равновеликая проекция Ламберта. Сохраняет истинные площади контуров, устраняя масштабные искажения при расчётах в метрах.
WGS84 (World Geodetic System 1984) — общеземная эллипсоидальная геодезическая система, глобальный стандарт спутниковой навигации GPS.
ГГК-1000/3 — Государственная геологическая карта масштаба 1:1 000 000 третьего поколения (ВСЕГЕИ).
— геологический индекс флювиогляциальных (водно-ледниковых) отложений (пески, гравийники зандров и озов).
— геологический индекс озёрно-ледниковых отложений (пески, супеси, ленточные глины приледниковых озёр).
— геологический индекс эоловых отложений (дюнные перевеянные пески).
— геологический индекс ледниковых моренных отложений (несортированные суглинки, валунники).
Спутниковое зондирование и машинное обучение
NDVI (Normalized Difference Vegetation Index) — нормализованный относительный вегетационный индекс, количественная мера фотосинтетической активности растительности.
SCL (Scene Classification Layer) — аппаратно-программный растровый слой классификации сцены спутника Sentinel-2, позволяющий автоматически отфильтровывать облака, тени крон, снежный покров и битые пиксели.
SWIR (Short-Wave Infrared) — коротковолновый инфракрасный спектральный диапазон, отражение в котором определяется структурой крон и содержанием влаги в хвое/листве.
B11 / B12 — каналы 11 и 12 сенсора MSI спутника Sentinel-2, соответствующие полосам SWIR.
VV / VH — поляризационные моды радара с синтезированной апертурой (SAR) спутника Sentinel-1 (VV — вертикальное излучение и приём, VH — вертикальное излучение и горизонтальный приём).
Random Forest (Случайный лес) — ансамблевый алгоритм классификации на базе комитетного голосования множества независимых решающих деревьев.
ROC AUC (Receiver Operating Characteristic — Area Under the Curve) — метрика площади под кривой ошибок, отражающая интегральную способность бинарного классификатора разделять классы независимо от выбранного порога (0,5 — уровень случайного шума, 1,0 — идеальное разделение).
Каппа Коэна () — статистическая метрика согласованности предсказаний модели с эталонной разметкой за вычетом вероятности случайного совпадения классов.
Overall Accuracy (Общая точность) — отношение числа безошибочно классифицированных тестовых точек к общему размеру проверочной выборки.
Компьютерное зрение и векторизация
CIELAB — перцептивно сбалансированное цветовое пространство CIE , где геометрическое расстояние между координатами соответствует воспринимаемой глазом разнице оттенков.
HSV (Hue, Saturation, Value) — цилиндрическое представление цвета по тону, насыщенности и яркости.
RANSAC / USAC / MAGSAC++ — семейство робастных алгоритмов оценки математических моделей на зашумлённых массивах данных с высокой долей грубых ошибок (выбросов).
Сетевой стек и облачная инфраструктура
API (Application Programming Interface) — программный интерфейс для взаимодействия сетевых сервисов и передачи структурированных данных.
206 Partial Content — статусный код протокола HTTP, возвращающий запрошенный по смещению диапазон байтов файла.
KV (Key–Value) — распределённое бессерверное хранилище типа «ключ — значение» платформы Cloudflare Workers.
R2 — объектное S3-совместимое облачное хранилище Cloudflare с нулевой стоимостью исходящего трафика.
WebGL (Web Graphics Library) — браузерный API для высокопроизводительного рендеринга 2D- и 3D-графики с использованием аппаратных ресурсов GPU.
PMTiles — открытый облачно-ориентированный однофайловый архив для картографических векторных и растровых тайлов.
Биология, сокращения и единицы измерений
ITS (Internal Transcribed Spacer) — внутренний транскрибируемый спейсер ДНК, универсальный молекулярный штрихкод в систематике грибов.
B. pinophilus / B. edulis — общепринятое в биологической номенклатуре сокращение названий видов Boletus pinophilus (белый гриб сосновый) и Boletus edulis (белый гриб сетчатый/обыкновенный).
ООПТ — особо охраняемая природная территория.
РСФСР — Российская Советская Федеративная Социалистическая Республика.
Having triangulation conform to Delaunay is a big advantage though! You should also try out mapbox/earcut.hpp for the benchmark — it got a refine function recently that refines earcut output to Delaunay as well, and earcut+refine is still pretty fast.
Implementing Visual Regression Testing in UI Automation
- Why pixel-level checks catch what functional tests miss
- Choosing between Percy, Playwright, and Cypress — trade-offs that change decisions
- How to manage baselines, thresholds, and reduce visual flakiness
- Embedding UI snapshots into CI and PR review workflows
- Practical steps: setup checklist and CI pipeline
Visual regressions are silent, high-impact bugs: the DOM is correct, buttons respond, but a 2px shift, a missing font, or a clipped SVG breaks a user journey and your metrics. Treat visual testing as the only practical way to assert that the UI your users see matches the UI you expect.
The symptoms are familiar: green test suites with sneaky layout regressions reaching production, long manual visual checks in every release, and PRs that require back-and-forth screenshots in comments. You already have functional E2E, unit, and integration tests; what you don’t have is a reliable, automated way to capture rendered errors — the kind users actually notice and complain about — without throwing away engineering time.
Why pixel-level checks catch what functional tests miss
Functional tests validate behavior and DOM contract: clicks, navigation, APIs, accessibility attributes — the what. Visual tests validate the how — spacing, type, color, composition and responsive behavior. A button can be present and clickable yet visually occluded by a sticky header or mispositioned across breakpoints; functional assertions miss that, but a UI snapshot will show it as a pixel diff. Teams using visual checks report catching layout and styling regressions earlier in the cycle, with the diffs serving as minimal, actionable artifacts for designers and engineers to triage.
> Important: Visual diffs are not a replacement for functional tests — they are a complementary layer that prevents surface-level regressions from eroding product quality.
Concrete examples from practice:
- A component library update changed line-height and pushed CTA buttons off baseline — all unit tests passed because props and events still worked, but users lost conversions until a visual snapshot flagged the change.
- An A/B style tweak set a different system font stack for one branch; the replacement font caused a 1–2px layout shift across cards that reduced click targets on mobile. A screenshot comparison exposed the drift immediately.
Choosing between Percy, Playwright, and Cypress — trade-offs that change decisions
When you select a visual strategy you answer three operational questions: where baselines live, how diffs get reviewed, and whether you want managed rendering (cloud) or in-repo golden files.
| Tool / Approach | Baseline storage | Rendering model | Review workflow | Good for |
|---|---:|---|---|---|
| Percy (managed SaaS + SDKs) | Cloud baselines, snapshot history | Percy renders snapshots (DOM/assets) centrally and shows pixel diffs in web UI | PR integration, visual review/approval UI; snapshot carry-forward and auto-approve settings | Teams that want PR-driven reviews and centralized baseline management. |
| Playwright visual tests (toHaveScreenshot) | Golden images committed to repo (*-snapshots dir) | Local screenshots compared by Playwright's runner (pixelmatch under the hood) | Review diffs as changed files in VCS; update with --update-snapshots | Fast iteration for devs who want in-repo snapshots and tight runner control. |
| Cypress + cypress-image-snapshot | Golden images in repo (cypress/snapshots) | Uses Cypress screenshots + jest-image-snapshot/pixelmatch diffs | Diffs stored locally; update with environment flags; or integrate with Percy for hosted review | Teams using Cypress who prefer open-source snapshot flow or hybrid approach. |
Key operational trade-offs to weigh (practical language, not high-level marketing):
- Percy centralizes baselines, provides a purpose-built review UI, and surfaces PR statuses automatically, which shortens designer/engineer handoffs. That convenience comes with a service dependency and snapshot quotas to track.
- Playwright’s built-in snapshots keep everything in your repo and let you run comparisons entirely in CI without an external service; that suits single-repo teams that prefer committing goldens and controlling update flows. Playwright also exposes maxDiffPixels and threshold options to tune sensitivity.
- Cypress plus cypress-image-snapshot is a mature OSS option with flexible configuration and local diff artifacts, and it plays well with Cypress’ existing test flows. If you want a hosted review but already use Cypress, the @percy/cypress SDK bridges both worlds.
Contrarian insight from the field: picking a tool on “features” alone rarely solves visibility and process friction. The real ROI comes from the review loop (who approves snapshots?), baseline ownership (QA or dev branch?), and CI ergonomics (are snapshots synchronized across parallel runs?). Percy reduces friction on review and baseline carry-forward; Playwright and local snapshot approaches reduce external dependencies and make snapshot diffs part of the code review as file changes.
How to manage baselines, thresholds, and reduce visual flakiness
Baseline strategy — two common patterns
- Cloud-managed baseline (Percy): choose a canonical branch (e.g., main) as your base and have Percy carry approved snapshots forward; use Percy’s approval workflow to gate which snapshots become the canonical baseline for subsequent builds. Percy supports auto-approve and approval-required branch configurations to match team processes.
- Repo-based golden files (Playwright / cypress-image-snapshot): commit the first-run golden images to source control; updates require an explicit --update-snapshots or updateSnapshots=true step so changes are deliberate and auditable. Playwright uses --update-snapshots; cypress-image-snapshot uses --env updateSnapshots=true.
Thresholds: pixel vs percent vs perceptual
- Image-diff engines operate with two levers:
- Per-pixel sensitivity (e.g., pixelmatch/threshold): how strict the per-pixel comparison is.
- Aggregate threshold (failureThreshold / maxDiffPixels / percent): how many pixels/what percent can differ before failing.
- Practical rule-of-thumb from teams: start strict for components (0–1% tolerance) and looser for large dynamic composites such as charts (1–5% depending on fidelity). Use SSIM for perceptual comparisons when small anti-aliasing differences produce noise. jest-image-snapshot/cypress-image-snapshot expose comparisonMethod: 'ssim' as an option.
Flakiness mitigation checklist (these are deterministic actions to implement):
- Freeze or disable animations at capture time:
- Playwright toHaveScreenshot supports an animations option to disable animations during capture.
- Percy snapshots accept a waitForSelector/waitForTimeout option and percyCSS to neutralize animations and dynamic elements.
- Decouple dynamic content:
- Mask or blackout region(s) that contain time stamps, randomized IDs or ads. Playwright supports mask locators in screenshot options; cypress-image-snapshot supports blackout in cy.screenshot() options.
- Stabilize fonts and rendering:
- Serve deterministic fonts during CI runs (bundle or preload fonts) rather than relying on system fallbacks; renderers differ across OSs and hardware—lock the environment. Percy serializes DOM and assets which helps, but you still need deterministic fonts for exact pixel parity.
- Use controlled rendering environment:
- Run visual tests in a consistent CI runner (Docker image or containerized environment) and pin browser versions; Playwright’s multiple project runners (Chromium/Firefox/WebKit) can generate per-browser snapshots for cross-browser visual checks.
- Wait for meaningful paint:
- Use targeted waitForSelector before capturing so the UI has stable data and server-driven placeholders have resolved. Percy and CLI snapshot commands support waitForSelector or waitForTimeout.
Debugging flaky diffs:
- Compare the produced diff images (the composite) to see whether differences are anti-aliasing noise, layout shifts, or data differences. Tools like jest-image-snapshot and pixelmatch provide configuration like includeAA and threshold to filter anti-aliasing noise.
- If diffs are due to currency/time data or random IDs, mask those regions or inject deterministic stubs during test runs.
Embedding UI snapshots into CI and PR review workflows
A robust workflow has four stages: snapshot capture → upload/compare → review → baseline update.
Percy flow (PR-centric, SaaS):
1. Add Percy SDK to tests (@percy/cypress, @percy/playwright) and call cy.percySnapshot() or percySnapshot(page, 'name') in places you want coverage.
2. In CI, set the PERCY_TOKEN environment secret and run your test command prefixed with percy exec --. Percy collects DOM/assets, renders snapshots in its service, computes pixel diffs, and surfaces them in the web UI. PRs show Percy build statuses and links to visual diffs for reviewers.
3. Reviewers approve snapshots (or reject) in Percy; approved snapshots become the baseline for future builds per your project settings (carry-forward/auto-approve).
Playwright / Cypress local snapshot flow (repo + CI):
1. Run tests in CI; snapshot diffs are produced as modified files or diff artifacts in the build workspace.
2. Configure CI to fail a build on snapshot diffs (default) so the PR indicates visual regression. Alternatively, allow the job to pass and require a separate "visual review" step to examine artifacts.
3. Updating baselines is an explicit step: run npx playwright test --update-snapshots or rebuild and commit updated cypress/snapshots after a team-approved visual change.
Example: GitHub Actions (Percy + Cypress)
name: Visual tests (Cypress + Percy)
on: [pull_request]
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Start app
run: npm start & npx wait-on http://localhost:3000
- name: Run Cypress with Percy
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
run: npx percy exec -- npx cypress run --headless
Note the PERCY_TOKEN secret and percy exec -- wrapper to capture snapshots and upload them to Percy in CI. Percy also provides tighter GitHub integration so PR statuses reflect visual review outcomes.
Parallel builds and NONCE uniqueness:
- If your CI runs snapshots in parallel jobs, ensure Percy’s NONCE (build identifier) is unique per run; some CI providers reuse run IDs across job steps which can cause finalization conflicts — Percy docs describe strategies for unique build NONCE across jobs.
Practical steps: setup checklist and CI pipeline
Actionable checklist you can apply in the next sprint (ordered):
1. Inventory visual surface: list pages/components that require snapshots (login, critical funnels, brand components, charts). Keep snapshots focused: many teams start with 50–200 snapshots and grow from there.
2. Pick baseline strategy: cloud (Percy) if you want PR-driven visual reviews; repo baselines (Playwright / cypress-image-snapshot) if you prefer version-controlled golden files.
3. Implement stabilizers:
- Add percyCSS or per-snapshot CSS to hide dates and animations.
- For Playwright use animations: 'disabled' in toHaveScreenshot and mask to hide dynamic elements.
- For Cypress with cypress-image-snapshot use blackout and capture: 'viewport' options.
4. Add snapshot calls to high-impact tests:
- Playwright example (Percy + Playwright):
// tests/visual.spec.js
const percySnapshot = require('@percy/playwright');
test('homepage visual check', async ({ page }) => {
await page.goto('https://example.com', { waitUntil: 'networkidle' });
// stabilize or disable animations as needed
await percySnapshot(page, 'Homepage - logged out');
});
- Playwright native snapshot example:
import { test, expect } from '@playwright/test';
test('header visual', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveScreenshot('header.png', { animations: 'disabled' });
});
- Cypress (Percy) example:
// cypress/e2e/visual.cy.js
it('renders home', () => {
cy.visit('/');
cy.get('body').should('have.class', 'app-loaded');
cy.percySnapshot('Home - default');
});
- Cypress (cypress-image-snapshot) example:
// cypress/e2e/snapshot.cy.js
it('renders dashboard', () => {
cy.visit('/dashboard');
cy.matchImageSnapshot('dashboard', { failureThreshold: 0.02, failureThresholdType: 'percent' });
});
5. CI integration:
- Add PERCY_TOKEN as a secret for Percy-backed flows and wrap the test run with percy exec --.
- For repo-based baselines, ensure your CI pipeline fails on diffs and that tests that update baselines run only on protected branches (or require explicit approval) so you avoid accidental golden updates.
6. Review and governance:
- Decide who approves visuals (product designer, QA lead) and where approvals get recorded (Percy UI vs VCS commit). Configure Percy auto-approve or approval-required branches to match your process.
7. Monitor and iterate:
- Track snapshot counts, failing snapshot trends, and false positive rate. If noise rises, tighten stabilization (mask/blackout fonts) and tune thresholds rather than disabling snapshots.
Quick troubleshooting commands:
- Update Playwright snapshots: npx playwright test --update-snapshots.
- Update Cypress snapshots: npx cypress run --env updateSnapshots=true (or set CYPRESS_updateSnapshots=true).
- Run Percy locally: export PERCY_TOKEN=... && npx percy exec -- <test-command>.
> Small operational policy: treat golden updates like code changes: require a clear PR, a screenshot review in the diff, and a deliberate commit message (e.g., "update visual snapshot: header typography change").
Every time you add visual tests, you add an executable artifact that lives alongside your test strategy: UI snapshots. They turn vague "it looks different" complaints into concrete images you can review, approve, or revert. Use the automation to keep that loop short, deterministic, and owned: stabilize the environment, choose a baseline strategy that matches how your team likes to approve changes, and wire snapshots into CI so visual feedback arrives as early as unit-test feedback.
Sources:
percy/percy-cypress - Official Percy Cypress SDK repository and README showing cy.percySnapshot() usage and integration notes.
percy/percy-playwright - Percy Playwright SDK repo with percySnapshot(page, 'name') examples and per-snapshot options.
Playwright — Visual comparisons / snapshots - Playwright Test docs describing expect(page).toHaveScreenshot(), snapshot lifecycle, --update-snapshots, and options (thresholds, animations, masks).
Visual Testing in Cypress (Cypress Docs) - Official Cypress guidance listing visual testing tools and examples of cy.percySnapshot() usage.
simonsmith/cypress-image-snapshot (GitHub) - Maintained Cypress image snapshot plugin README with configuration, matchImageSnapshot options (failureThreshold, blackout, etc.), and update flags.
Visual Testing with Percy — overview and baseline concepts (BrowserStack Docs) - Percy workflow, approvals, and baseline management details useful for team processes.
percy/cli (GitHub) - Percy CLI repository describing percy exec, percy snapshot command options and asset discovery essentials.
pixelmatch (npm / README) - The pixel-level diff engine used by many snapshot tools; documents threshold, anti-alias settings, and how pixel diffs operate.
Under the hood: integrating Vulkan into the Mapbox rendering engine
Vulkan support was introduced in v11.24.1. The details on how to enable Vulkan are here. In this post we would like to explain the reasoning for migrating from OpenGL to Vulkan, plus some more migration details.
Reasons for migrating from OpenGL to Vulkan
OpenGL has long been the primary graphics API in our map rendering engine. OpenGL is a time-proven API, but it is being steadily replaced by newer graphic APIs, like DX12, Metal and Vulkan. These modern APIs are designed to be really close to the hardware, reducing the overhead of issuing graphics API calls. OpenGL provides a high abstraction level, which imposes several considerations.
Built in error checking
Built in error checking may sound like a good thing as it keeps the rendering robust. The OpenGL driver is forced to maintain the global state and handle errors in a gentle way. For example, an unbound buffer during copy command will not cause a crash. But the downside is that the error checking consumes CPU cycles and cannot be switched off.
Runtime shader compilation
OpenGL accepts shaders written in GLSL, and the driver compiles them into machine code at runtime. This approach prevents shipping precompiled shaders, and causes stutter when there is a need to compile a shader.
Opaque memory management
With OpenGL, there is very limited control on what is allocated and when. This can cause stutters that are unpredictable and difficult to avoid.
Limited multithreading capabilities
OpenGL operates via contexts. A context is a complex object that encapsulates, accumulates and mutates a state responsible for holding all the data needed to execute a GPU command, i.e. make a draw call. That context is bound to only a single CPU thread at a time, restricting the ability to make API calls on a context from multiple threads in a concurrent manner. This prevents optimizations used in modern rendering engines, like recording draw commands from multiple threads concurrently.
State leaking
OpenGL maintains a state that is tied to a global context. State leaking is a known issue in multi-library scenarios, where each of the libraries makes OpenGL calls.
<figcaption>Our application was expecting a zero bound framebuffer, but the third-part library changed the state, breaking our application logic.</figcaption>
OpenGL deprecation
There are several reasons for the deprecation of OpenGL:
First, the evolution of the OpenGL ES API has been halted in favour of Vulkan API. The latest version of OpenGL ES 3.2 was released in 2015, since then no major releases were issued. New graphics features will only be exposed through Vulkan.
Next, Android has taken a course to deprecate OpenGL native driver support. According to the Vulkan roadmap for Android, starting 2025 the preferred way to make OpenGL calls is to go through the ANGLE abstraction layer, and then after translation, call to the Vulkan driver. ANGLE is the Almost Native Graphics Layer Engine, which is included in Android 15 and higher. The addition of a layer between render calls and the driver introduces more complexity and less control, and it will hurt performance.
!Direct usage of Vulkan allows us to bypass intermediate translation layers.<figcaption>Direct usage of Vulkan allows us to bypass intermediate translation layers.</figcaption>
Lastly, tooling is worth mentioning because it is crucial to the developer experience. Rendering tooling, like frame profilers or shader analyzers, is very limited or totally absent for OpenGL especially for mobile platforms. For example, modern gpu profiling tool sokatoa goes with Vulkan support only.
For these reasons, we decided to add support for a Vulkan backend in our renderer.
Introducing Vulkan
When transitioning to Vulkan, we first needed to decide which target platform Vulkan version to support.
Currently there are five notable Vulkan iterations: 1.0, 1.1, 1.2, 1.3 and 1.4. We chose Vulkan 1.1. It has a high device support percentage (87% support). Additionally, compared to 1.0 (93% device support), it has important functionality guaranteed; like dedicated allocations, and a negative viewport in order to flip the image without altering all the code responsible for transformations and SPIR-V 1.3. Detailed information on Vulkan support percentage on Android is in the distribution dashboard (Latest update November 2025).
In order to minimize the boilerplate of initializing Vulkan and setting up swapchain, we opt for widespread industry approved libraries: a combination of volk and vk-boostrap.
For presentation, we use the recommended mobile settings: two frames in flight, minimum of three requested swapchain images, and FIFO presentation mode.
Shaders
At the core of every rendering engine are shaders. Historically, we have a single shader codebase written in GLSL that is shared between all our backends. This allows us to guarantee visual parity across all platforms. GLSL is a first-class citizen for WebGL and OpenGL, whereas Vulkan accepts shaders in SPIR-V format.
This raises three main questions:
- What do we compile?
- When do we compile?
- How do we compile?
What do we compile?
For Vulkan, we want to preserve all the initial shaders. So our input is OpenGL ES 3.0 GLSL shader source code with multiple preprocessor definitions dependent logic. Those preprocessor definitions when replaced produce the result GLSL (shader variant) which is then used for rendering.
!Shader Variant
<figcaption>Typical shader preamble: there are multiple compile time definitions that control behaviour of the shader.</figcaption>
When do we compile?
To answer this question, the following options were considered:
Compile at runtime
There are pros and cons when compilation happens on demand. This is the least invasive to the infrastructure and to the rendering code as well. There’s no need to introduce additional artefacts, ship them, or provide them to the end-user. However, there are potential stutters or missing content on screen if compiled on the render thread.
Stuttering should be avoided at all costs. Missing content is also not ideal. So approaches with compiling SPIR-V beforehand were considered.
Compile during build time
There are pros and cons when compilation happens when we prepare our main artefact. It’s a benefit that there is no runtime compilation penalty for precompiled variants. However, it’s not possible to compile all the variants, and there is binary size growth.
Compile on the server
This is when compilation happens beforehand and SPIR-V is stored on the server. Those are then served to the client among other resources: geometry data, textures and SPIR-V. As with all options, there are pros and cons. There’s no runtime penalty, and you can precompile significantly more shader variants. However, the list of cons is lengthier.
- There are still too many variants to precompile
- You need to precompile variants for different client versions
- There is network traffic increase
- It is not suitable for offline
- It requires a lot more modifications across all our infrastructure
Mapbox chose a combination approach
Clearly none of these approaches is a silver bullet. So we opted for a combination: runtime, embedding precompiled SPIR-V, persistent caching, and background compilation.
Our maps styling and rendering is highly customisable by the end user, making it impossible to compile shader variants beforehand. Because of that, runtime compilation is unavoidable. Once a variant is compiled it is placed into a persistent cache, so further occurrences of that particular variant will not trigger compilation (even after application restarts).
It is impossible to precompile all the SPIR-V variants, but we can precompile the most common variants seen in our Standard style. For our Standard style we simulate the most common map usage scenarios and gather around 150 unique shaders, resulting in about 2 Mb of extra memory that we embed into the binaries.
To reduce hiccups, all the runtime compilation happens in the background thread so it does not block the render thread.
How do we compile?
For GLSL to SPIR-V compilation, we use the shaderc + SPIRV-Reflect libraries combo. Shaderc is based upon glslang - the reference GLSL to SPIR-V compiler.
We use the following shaderc::CompileOptions for compilation:
shaderc::CompileOptions compileOptions;
compileOptions.SetVulkanRulesRelaxed(true);
compileOptions.SetAutoBindUniforms(true);
compileOptions.SetAutoMapLocations(true);
compileOptions.SetTargetEnvironment(shaderc_target_env_vulkan, shaderc_env_version_vulkan_1_1);
compileOptions.SetOptimizationLevel(shaderc_optimization_level_size);
compileOptions.SetTargetSpirv(shaderc_spirv_version_1_3);
Those options make shaderc execute the following steps:
- Transform OpenGL ES 3.0 GLSL to intermediate Vulkan acceptable GLSL
- Compile intermediate GLSL to SPIR-V.
!Example of a OpenGL ES shader transformed to a Vulkan acceptable GLSL flavour<figcaption>Example of a OpenGL ES shader transformed to a Vulkan acceptable GLSL flavour.</figcaption>
Pipelines
Once we have SPIR-V shaders, the next step is to compile those shaders to actual machine code. In Vulkan this is done as a part of pipeline creation.
A pipeline is the central Vulkan object since it is meant to encapsulate all the state information that is needed for rendering, including shaders. Pipeline creation is a time-consuming operation, in part due to SPIR-V compilation.
<figcaption>A high-level view of a graphics pipeline consisting of multiple states which can be divided into four separate categories: vertex input, pre-resterization shaders, fragment shader and fragment output interface.</figcaption>
In order to speed up compilation we use VkPipelineCache as recommended by multiple GPU vendors.
<figcaption>Creating a graphics pipeline that uses the exact same SPIR-V from an already created pipeline shows significant time reduction.</figcaption>
That VkPipelineCache is also serialized to persistent cache so it can be reused for application reruns.
But this method doesn’t properly handle two important cases: when we cold start, and when we first encounter a certain shader variant. To handle this we use fossilize, a library that allows us to serialize data needed for pipeline creation, and replay that data later in order to create the exact same pipeline.
Similar to how we prepare the GLSL to SPIR-V prebuilt cache, we gather pipeline creation data for our Standard style, serialize that data, embed it in our build artifacts, and replay those pipelines on application start filling VkPipelineCache.
How do we handle uniforms?
Contrary to OpenGL and WebGL, Vulkan doesn’t accept free uniforms. Uniforms can be passed only via push constants or by buffer memory. Our aim was to make as few modifications to our high level API as possible.
When compiling from GLSL to SPIR-V, we pack all uniforms in a single uniform buffer object. That UBO is set dynamically for each draw call.
Vulkan accepts shader resources in the form of descriptor sets. In order to minimize descriptor set updates, we maintain a single large UBO which accepts all writes from uniform updates. This single UBO is used in cooperation with a single dynamic UBO descriptor. Essentially, we have a single descriptor responsible for all the rendering commands.
<figcaption>A single memory block and single buffer hold all the uniforms used for rendering. Uniform blocks are organized in a queue-like fashion. Uniform blocks are removed from the queue after frame in flight count frames (usually two).</figcaption>
Memory
In Vulkan, it is the developers’ responsibility to make all the allocations in appropriate memory heaps. We opted for VMA, a well-known library that takes most of the allocation burden on its shoulders. VMA allows a certain amount of configuration. We settled on the following configuration:
- Render targets, large buffers, and textures are always allocated dedicated
- Vertex & index buffers, textures, staging buffers, uniform buffers all to separate pools
It’s also worth mentioning that due to frames in flight, all resources that imply per-frame or frequent updates have a number of frames in flight duplicates under the hood.
<figcaption>A brief visualization of how frames in flight are handled. On Frame N + 1, the buffer we update could still be used for rendering, or it can be in pending to be rendered state. In order not to create a synchronization point between CPU and GPU, all the buffers that are subject for per-frame updates work in a circular fashion.</figcaption>
On platforms with Unified Memory Architecture (UMA) support, where both the CPU and the GPU share a single system RAM which includes most of the mobile platforms, we omit staging memory whenever it is possible.
Here is a a map view and corresponding memory layout
!Standard style.<figcaption>Standard style.</figcaption>
<figcaption>Two dedicated shadows maps, a pool of small textures, a pool of uniform buffer objects and a pool of vertex/index buffers.</figcaption>
Validation layer
Layers are a core mechanic in the Vulkan API that allow extending behaviour beyond what the spec allows. The most useful layer is the Khronos validation layer. This layer validates Vulkan function calls: checking arguments, maintaining render state, and reporting if any errors are encountered.
We do execute our CI jobs with a validation layer enabled to ensure no validation errors on target devices.
<figcaption>It is possible to get direct pointers to Vulkan functions inside the driver, or to go through a chain of layers including the validation layer.</figcaption>
It is worth noting that the Vulkan validation layer is far more powerful than the OpenGL error reporting system, which highlights only critical errors. Checking all graphics API calls via the Vulkan Validation layer highlights far more errors, incompatible formats, and suboptimal usage, even if compared to glDebugMessageCallback.
Results
Here are the gains of Vulkan migration.
Faster frames
We gained about 10% faster frames for CPU heavy styles with multiple draw calls. This is achieved by removing some of the CPU bottlenecks that OpenGL adds behind the scenes, such as validation optional and pipeline caching.
The gains can be seen both in lightweight and heavy scenes:
!Standard style. Pixel 7 device. 50, 90, 95, 99 frame time percentile. Vulkan is about 10% faster.Standard style. Pixel 7 device. 50, 90, 95, 99 frame time percentile. Vulkan is about 10% faster.
!Pixel 7 device. [Tracy (profiler built into our engine) gathered frame time histograms comparison for a high zoom, layer intense view point. Yellow - Vulkan. Red - OpenGL. About 10% from OpenGL median between OpenGL and Vulkan medians.
](dev-to-uploads.s3.us-east-2.amazonaws.com)<figcaption>Pixel 7 device. Tracy (profiler built into our engine) gathered frame time histograms comparison for a high zoom, layer intense view point. Yellow - Vulkan. Red - OpenGL. About 10% from OpenGL median between OpenGL and Vulkan medians.</figcaption>
<figcaption>Standard style zoom-in/zoom-out scenario. P75 frame time. Both Galaxy S22 and Moto G22 show better performance with Vulkan.</figcaption>
Fewer hiccups and faster cold start times
By embedding most common SPIR-V & pipeline variants we get fewer hiccups and faster cold start times.
<figcaption>Standard style. A typical high zoom level frame with about 150 unique shaders.</figcaption>
<figcaption>Pixel 4 device. All shaders/pipelines compilation time (lower is better) for the above camera position.</figcaption>
Better integration for multi-graphics library Vulkan-ecosystem
For developers who have Vulkan initialization handled on their side and render loop set up, we provide an API to inject global Vulkan objects into our library. Vulkan global objects we accept include: VkInstance, VkDevice, VkPhysicalDevice, VkQueue, and per frame data as well including VkCommandPool, VkCommandBuffer, VkImage for render target color and depth.
Conclusion
Vulkan is gaining more and more share of the mobile market. While OpenGL will not go away (at least via ANGLE), using it comes with sacrifices: a certain runtime penalty, less control on what is actually happening on the GPU, and less access to all the features the hardware exposes.
Since the Vulkan release in 2016, the drivers have matured enough to be production ready. The same is true for developer tooling. There are frame inspectors and shaders analyzers for all the major mobile vendors, clearly making Vulkan a viable option.
As a result, Vulkan support was added to our map rendering engine, without any major infrastructural changes, and with immediate gains like better frame rates and more control on stalls.
In addition to the immediate gains, Vulkan offers the potential for more intrusive engine optimizations. We can spread command buffer construction across multiple threads. We can move parts of the map engine logic to the GPU, like symbol placement. And we can reuse command buffers between frames for static data, and much more. In the coming months we’ll be exploring these optimizations further.
Pixelmatch, my image comparison library for visual regression testing, got a major overhaul: a new color difference metric (OKLab + HyAB) that's much closer to how we see color, a windowSize option to fight scattered noise, and more — while staying fast github.com/mapbox/pixelmatch/releases/tag/v8.0.0 t.co
8 likes · 1 repost · 369 views
Mentions
Как я искал боровики и дошёл до спутниковой классификации леса Грибов в этом году у нас было мало. То ли весенние заморозки, то ли сухое лето, то ли дожди, которые под конец, наоборот, лили без остановки. Не поймёшь. Только к концу августа мне начали попадаться первые сообщения: в лесах Ленинградской области наконец пошли боровики. Моя супруга боровики очень любит, и каждую осень мне приходится искать места их обитания. В этот раз на «тихую охоту» оставалось совсем немного времени — ну, три пары выходных, не больше, как мне тогда казалось. Конкуренция в сезон почище, чем за вакансии на HH, а тратить эти считанные дни на прогулки с пустой корзиной не хотелось. Первый боровик вылез прямо у нас на дачном участке. Восприняли как сигнал: пора. Первым делом мы отправились в сторону Алёховщины — проверять сосновые боры и беломошники. С точки зрения грибника именно такой лес и мечтаешь увидеть: стройные сосны, сухой песок, светло, а под ногами белёсый хрустящий ковёр, на котором так и представляешь крепкий боровик с тёмно-каштановой шляпкой. Беломошник — название народное и немного обманчивое: этот светлый покров образуют преимущественно лишайники (в основном родов Cladonia и Cetraria), а вовсе не мох. Ходить по такому бору — чистое эстетическое удовольствие. Грибов не было. Совсем. В тот же день двинулись дальше, в сторону Олонца. Проверили места, кторые заприметили две недели назад по дороге на терский берег и рассчитывали на них, — тоже пусто. На обратном пути я решил наугад свернуть к Нижне-Свирскому заповеднику. Без всякого чёткого плана и уже без больших надежд. И там, в придорожной канаве у второстепенной грунтовки, в совершенно случайном даже не очень то и сосняке, мы набрали две полные корзины за час. После такой поездки (четыре часа непрерывной рулёжки в одну сторону как-никак) появился чисто айтишный интерес: как выбирать точки для следующего выезда, чтобы не стрелять вхолостую? Почему в хрестоматийном лесу с открытки — шаром покати, а там где совсем грибов не ждешь их с избытком? Хотелось уменьшить фактор случайности в этом итак небыстром процессе. Пытливый мозг, грибной Инстаграмм (экстремисткая секта, всячески порицаю) и прошлый опыт намекали: существует прямая связь между сосной, грибницей и песком. И тогда я решил построить карту пригодных биотопов. Отдельный гриб на спутниковом снимке, само собой, не разглядишь — разрешение не то. Но можно попробовать найти подходящий для него лес. Поиски начались с микобиологии. Что мы вообще ищем: биология вида Прежде чем загонять спутники в алгоритмы, стоило уточнить сам объект поиска. Я ориентировался на белый гриб сосновый — Boletus pinophilus. Поручил Клоду собрать научные статьи по теме и ушел пить чай. Научная справка: молекулярная филогения и систематика Boletus pinophilusЕго самостоятельность подтверждается молекулярным филогенетическим исследованием Beugelsdijk et al. (2008): анализ участков ДНК (дезоксирибонуклеиновой кислоты) — ITS (Internal Transcribed Spacer, внутреннего транскрибируемого спейсера рибосомной РНК) и GAPDH (Glyceraldehyde-3-Phosphate Dehydrogenase, гена фермента глицеральдегид-3-фосфатдегидрогеназы) — надёжно выделил этот вид в отдельную эволюционную ветвь внутри секции Boletus. Для моей задачи особенно полезна экологическая характеристика: вид образует эктомикоризу преимущественно с сосной обыкновенной (Pinus sylvestris) и приурочен к сухим, кислым, дренированным песчаным и супесчаным почвам. Среди морфологических маркеров — мясистая бугорчатая шляпка тёмного винно-красно-коричневого оттенка и выраженная рельефная сеточка в верхней части массивной ножки. При этом исследование предостерегает от поверхностных выводов: Связь с сосной не абсолютна: зафиксированы находки микоризы в еловых насаждениях. Белый гриб под сосной — не обязательно B. pinophilus. Авторы описали образцы, собранные на песчаных почвах под соснами, которые фенотипически и по биотопу напоминали сосновый боровик, но по ДНК оказались экологической формой обычного белого гриба — Boletus edulis (в микологических статьях после первого упоминания род сокращается: B. pinophilus, B. edulis). Так с помощью ложки заварки родилась рабочая гипотеза проекта: сочетание чистого сосняка и бедной песчаной почвы позволяет очертить приоритетные полигоны для полевой разведки. Это метод косвенного моделирования пригодности местообитаний (Habitat Suitability Modelling). Он не гарантирует присутствие мицелия и тем более не обещает слой грибов к выходным, но резко отсекает заведомо бесперспективные площади. Сбор «сырья»: Почвы, спутники и первые оверлеи Первыми источниками данных стали: Почвенная карта РСФСР 1988 года под редакцией В. М. Фридланда (масштаб 1:2 500 000); Слой хвойных лесов глобального мониторинга Copernicus Global Land Service (CGLS); Краудсорсинговая картографическая база OpenStreetMap (OSM), откуда выгружались участки с отметками типов растительности. У каждого источника была своя задача. Карта Фридланда указывала на кислые почвы борового ряда. Спутниковый продукт показывал массивы деревьев. А в OSM энтузиасты вручную размечают участки леса. Не то, чтобы всё это было категорически необходимо в поездке, но если уж начал.... Извлечение геоданных: Livewire-харвестинг и оконный доступ к COGИзвлечение почв: динамический веб-харвестинг На портале soil-db.ru оцифрованная институтом им. Докучаева карта Фридланда не отдаётся готовым файлом. Она работает в веб-интерфейсе через фреймворк Livewire: при панорамировании окна браузер отправляет метод loadFeatures, а сервер шлёт геометрию полигонов в событии livewire-map:add-features. Я написал сборщик на базе протокола Chrome DevTools Protocol (CDP), который прошёл всю Ленинградскую область сеткой из 55 перекрывающихся окон зума 9, вытянул векторные контуры и слил их в единый GeoJSON (RFC 7946) — формат хранения географических объектов на базе синтаксиса JSON. Снапшот состояния, скопированный из активного окна, содержал кэш loadedIds — сервер считал эти участки уже загруженными и наотрез отказывался отдавать центральное окно Петербурга и Карельского перешейка. Пришлось сбрасывать идентификаторы и догружать недостающие 60 полигонов отдельным проходом. Итог: 364 полигона, 25 почвенных разностей. Главный фаворит — иллювиально-железистые подзолы (), визитная карточка сосновых боров на песках. Маска леса: оконный доступ к COG Для лесного покрова я взял продукт CGLS-LC100 (Buchhorn et al., 2020) за 2019 год. Это глобальный растр с шагом 100 м. Качать весь файл (около гигабайта) не потребовалось: GeoTIFF был оформлен по стандарту Cloud-Optimized GeoTIFF (COG). С помощью библиотеки GDAL и её виртуальной файловой системы /vsicurl в связке с Python-библиотекой rasterio можно читать растр напрямую из удалённого хранилища через HTTP-запросы диапазонов байтов (range-requests). Скрипт вырезал ровно географическую рамку Ленобласти. Далее пиксели класса 1 (вечнозелёный хвойный лес) были векторизованы функцией rasterio.features.shapes: получилось 3044 контура площадью более 1 км². Ориентироваться только на теги OSM (leaf_type=needleleaved — тег, означающий иглолистную хвою вообще, а не конкретно сосну) не позволяла фрагментарность покрытия. Собранное вместе это позволило получить первый прототип карты, который оказался не плох в ретроспективе. Вторая поездка: Лужские беломошники Вооружившись первой версией карты, на следующие выходные мы позвали друзей и махнули под Лугу. Есть особое чувство безответственности, когда везешь людей за 150 км под вечер, основываясь на только что склеенном скриптом файле. Но карта и бородатые мужики из инстаграмма как будто намекали нам: на песчаных террасах реки Луги хвойный лес совпадает с нужными подзолами. Первая точка у самой реки встретила нас красивейшим пейзажем, но грибы были не те: разномастные сыроежки и пара старых моховиков. Закрались сомнения... Переезжаем на вторую точку, углубляемся в массив — и попадаем в безупречный, хрестоматийный беломошник. Светлый просторный сосновый бор, чистый белый лишайниковый ковёр, под ним сухой хрустящий песок. И главное — боровики! Крепкие, тёмно-вишнёвые, как с картинки. Сомнения закравшиеся в предыдущем абзаце оказались беспочвенны. Правда, быстро выяснился нюанс: почти всё, что росло на виду, срезали конкуренты ещё ранним субботним утром. Нам достались трудовые «хвосты». Но концепция подтвердилась на местности: косвенные признаки вывели нас ровно в целевой биотоп. Слой карты - «кто здесь проходил с ведром три часа назад» спутник, увы, отдает не всем желающим. Алгоритм кандидатов Чтобы не сличать слои глазами, я формализовал пересечение в алгоритм генерации полигонов-кандидатов: Пайплайн алгоритма кандидатовЭкспертная оценка почв: типам почв Фридланда присвоили рейтинг пригодности (). В отбор пошли почвы от (прежде всего те самые иллювиально-железистые подзолы). Растеризация: вектор почв перевели в растровую сетку, идентичную сетке CGLS-LC100. Логическое пересечение: бинарная маска «хвойный лес почва ». Полигонизация и отсев: преобразовали маску обратно в вектор через rasterio.features.shapes, выкинув мелкий мусор площадью до 0,3 км². Осталось 3265 зон общей площадью 10 240 км². Зональная статистика подложки: для каждого полигона рассчитали зональное среднее доли песка в подпочве на глубине 60 см по глобальной сетке OpenLandMap / SoilGrids (Hengl et al., 2017) (разрешение 250 м). Разделение на сосну и ель: если средняя песчаность превышала 60%, полигон получал флаг pine=true (условный сосняк); если ниже — отправлялся в категорию «прочий хвойный» (чаще сырые ельники). Итог: 1048 «сосновых» зон (4348 км²) и 2217 прочих хвойных. Флаг pine=true был смелым авансом: он означал не то, что модель распознала сосну, а лишь то, что хвойный лес стоит на относительно лёгкой песчаной почве. Пересечение разномасштабных данных (почвы 1:2,5 млн, песок 250 м, лес 100 м) не делает границы точнее их самого грубого компонента. Звёзды означали приоритет проверки ногами, а не плотность боровиков на квадратный метр. Архитектура на коленке: Мобильный вьюер и смена картографического движка После Луги стало ясно, что в лес нужен удобный полевой инструмент. Я дал Клоду задание набросать веб-интерфейс, куда заложить сценарии для смартфона: Тап по участку раскрывает карточку с кнопками перехода в навигаторы - ссылку по протоколу geo: (RFC 5870) для открытия в сторонних офлайн-приложениях Android. Кнопка геолокации через встроенный в браузер W3C Geolocation API: стоишь среди сосен и видишь, внутри ты расчётного полигона или ещё бредёшь по переходной зоне. Разделение панели на «простой режим» (только грибные места, радар дождей и болота) и «экспертный» (где можно крутить прозрачность почв и подсвечивать отдельные типы). Не спервой попытки но электронный мозген справился. Проект переставл быть простым. Автоматичеи подтянулось тестирование и деплой репозитория через гитхаб экшенс, хранение секретов, логирование логинов и еще черта в ступе. Все как мы любим. Инженерия доставки: решение проблемы сдвига в 18 км, PMTiles и Cloudflare WorkersДраматичный переезд с Leaflet на OpenLayers Первый прототип крутился на библиотеке Leaflet. Но тут вскрылась фундаментальная проблема геодезических проекций: Стандартные веб-карты (OSM, Google) используют проекцию EPSG:3857 (WGS 84 / Pseudo-Mercator, или Web Mercator). Спутниковые тайлы Яндекса исторически опираются на эллипсоидальную проекцию Меркатора — EPSG:3395 (WGS 84 / World Mercator). Если натянуть векторный оверлей из одной проекции на растр другой, на широте Санкт-Петербурга (60° с. ш.) возникает физический сдвиг геометрии на местности примерно в 18 километров. Библиотека MapLibre GL JS отпала сразу — она жёстко привязана к Web Mercator. Единственным движком, способным динамически трансформировать растровые и векторные слои в произвольные проекции через WebGL (браузерный интерфейс аппаратного ускорения графики) и Canvas прямо на мобильном процессоре, оказался OpenLayers. Проверил прототип на смартфоне: подложка Яндекса встала без перекосов, контуры легли пиксель в пиксель. Векторные тайлы и сжатие в PMTiles Отдавать мобильному браузеру «сырые» файлы GeoJSON лесов и болот весом по 35 МБ через мобильную сеть на лесной опушке — верная смерть. Данные требовалось тайлировать — разрезать на стандартные пространственные квадраты разного масштаба. Конвейер генерации оформили в воспроизводимый bash-скрипт: Утилита mapshaper переводила координаты в локально настроенную азимутальную равновеликую проекцию Ламберта LAEA (Lambert Azimuthal Equal Area, сохраняющую площади объектов) с центральной точкой 60° с. ш., 31° в. д. В плоских метрах пороги упрощения и фильтры площади работают геометрически строго, в отличие от искажающихся угловых градусов WGS84. Команда -clean удаляла топологические наложения и осколки растеризации. Упрощение геометрии алгоритмами Дугласа–Пекера (1973) / Висвалингама–Уайатта (1993) с флагом keep-shapes удаляло лишние вершины, не допуская распада мелких замкнутых полигонов. Контуры болот, например, «похудели» с 778 до 333 тысяч вершин. Утилита tippecanoe паковала полигоны в векторные тайлы формата Mapbox Vector Tile (MVT) внутри единого контейнера PMTiles. Формат PMTiles устроен гениально: это единый файл, содержащий пирамиду тайлов со встроенным индексным заголовком. Браузер через OpenLayers шлёт к серверу запросы диапазонов байтов (HTTP range-requests), скачивая только нужные несколько килобайт на текущий экран, а сервер отвечает кодом 206 Partial Content. Архивы легли в облачное объектное хранилище Cloudflare R2, а перед ними встал Cloudflare Workers — бессерверный обработчик на JavaScript, который проверяет авторизацию и раздаёт байты с минимальной задержкой. Болота: метод отрицания Чтобы не тонуть в прямом смысле слова, через Overpass API (язык запросов к базе OpenStreetMap) мы выкачали все полигоны болот Ленобласти (natural=wetland). Утилита osmtogeojson собрала их в мультиполигоны с внутренними «дырами». Этот слой стал отрисовываться поверх кандидатов ярко-синим и оливковым цветом. Логика: сосновый бор часто тянется узкой песчаной гривой между непроходимыми верховыми топями. Видишь сухое ребро среди синевы — туда и надо идти. Монетизация: три продажи на Авито Тогда же в поездке возникла мысль: нужно ли это кому‑то ещё? Я выставил объявление на Авито о продаже доступа к карте на две недели за символическую плату. Случились три реальные продажи. Пришлось быстро дописывать защитный контур: Вход через учётные записи Яндекса и «ВКонтакте» А Выдача приватного слоя точек только при наличии валидной сессионной cookie. Третий выезд: А мы пойдем на север Тем временем, сезон неумолимо катился к финалу. Окрылённые лужским успехом, мы отправились на проверенные места в Выборгский район. Результат слегка отрезвил: на опушках стояли шеренги машин, по лесу бродили угрюмые конкуренты с пустыми ведрами. Видимо байки про грибников собирающих грибы с фонариками по ночам были не беспочвенны. На следующем намеченном участке сосняк на поверку оказался завален буреломом и затянут подлеском. Карта работала с вероятностью «фифти-фифти»: если точка обещала сосну — сосна там, скорее всего, была; но в половине мест под ногами чавкала жижа, пропущенная грубыми картами почв. Не теряем надежды, едем дальше. Поезжаем еще буквально 10 километров в то место, где карта предсказывает беломощники и сосновый лес. И оказываемся ровно в таком лесу. Все на месте. беломощники, сосна и боровые грибы. Победа. Вернувшись домой, я решилглубже копнуть геологию. Откуда вообще берётся песок под сосняками? Геология четвертичного чехла и конвейер векторизации советских геолкартГеология: Девон против ледника Песок и песчаник — принципиально разные геологические тела. Песок — рыхлый, несцементированный обломочный материал. Песчаник — монолитная осадочная горная порода, где зёрна кварца намертво связаны природным минеральным цементом. Классический пример — знаменитые девонские обрывы реки Оредеж у Белогорки и Ям-Тёсова: этим породам свыше 380 миллионов лет, когда здесь плескалось тёплое мелководное море и плавали панцирные рыбы. Но современный сосновый бор растёт не на девонских плитах. Его среда — это четвертичный чехол, сформированный последними оледенениями: Морена (): несортированная смесь валунов, суглинков и гравия, которую ледник грёб перед собой. Это субстрат тяжёлый, плотный, на нём чаще поднимается тёмный ельник. Флювиогляциальные отложения (): пески и гравийники, перемытые и отсортированные бешеными потоками талых ледниковых вод. Именно они слагают зандровые поля, озы и камовые холмы — сухие, прекрасно дренированные возвышенности. Озёрно-ледниковые отложения (): накапливались в приледниковых подпруженных озёрах. Они состоят как из песков, так и из тончайших ленточных глин. Сам по себе индекс на карте вовсе не гарантирует сухой грунт под ногами. Эоловые пески (): древние песчаные дюны, навеянные ветром в суровом перигляциальном климате. Теперь у меня были довольно точные данные о подходящих почвах Четвертый выезд: Заборы Карельского перешейка Решили проверить кандидата под Токсово в районе Кавголовского озера. Приезжаем — идеальный, звенящий сосновый бор на крутых песчаных холмах. Но ровно по границе перспективного полигона тянется трёхметровый глухой забор неизвестного генезиса. Плюнули, развернулись и пошли на соседнее болото собирать спелую клюкву. Тупик порогов и прорыв в машинном обучении Карта обогатилась геологией, но оставалась фундаментальная брешь: как заглянуть внутрь лесного массива с шагом не 100, а хотя бы 10–20 метров? Первым делом мы добавили базовую глобальную маску древесного покрова ESA WorldCover v200 с шагом 10 метров (класс Tree cover). Стало видно просеки, опушки и границы вырубок. Но WorldCover — это бинарная маска: она подсвечивает дерево, но не отличает сосну от берёзы или ольхи. Провал простых порогов Sentinel-2: физика зимних теней и унимодальный сигналПровал «в лоб» Возникло ощущение, что решение лежит на поверхности: хвойные деревья вечнозелёные, а лиственные сбрасывают листву на зиму. Считаем нормализованный разностный вегетационный индекс NDVI (Rouse et al., 1974; Tucker, 1979): Логика: сравним летний NDVI и весенний безлистный NDVI спутника Sentinel-2 (Drusch et al., 2012), найдём порог «просадки» зелёности и отделим хвойные от лиственных. Мы проверили эту гипотезу на пикселях Ленобласти в Google Earth Engine (GEE) (Gorelick et al., 2017). Полный провал: Бесснежная весенняя фенология: гистограмма сезонной разности NDVI внутри леса оказалась строго одногорбой. Никакого разделения на два колокола нет — единым порогом отделить хвою от листвы невозможно. К тому же озимые поля и луговины весной выдавали точно такую же «зелёность», как зрелый сосняк. Зимний «снегометод»: идея искать вечнозелёные кроны над белым снегом разбилась о физику высоких широт. На 60° с. ш. солнце зимой едва поднимается на 6–15° над горизонтом. Кроны отбрасывают гигантские тени: 97% лесных пикселей сколлапсировали в физический минимум индекса. Казалось, спутники бессильны. Но ресёрч научной литературы (Udali et al., 2021; Blickensdörfer et al., 2024) подсказал: проблема не в спутнике Sentinel-2, а в попытке решить многомерную задачу простым ручным порогом. Нужны размеченные обучающие выборки и машинное обучение. Пайплайн Случайный лес находит лес хвойный Моих познаний в ML было недостаточно чтобы понять как выбрать подходящую модель и как ее дообучать. Пришлось обратиться к научным работам разных авторов раскрывающим тему определения типов растительности по спутниковым снимкам. В конце концов был найден хорошо расписанный пайплайн для разделения хвойных и лиственных основанный как раз на Random Forest модели: MDPI rs13163237 ML-пайплайн: Random Forest, пространственная кросс-валидация и абляционный анализЯ снова обратился к OSM, но теперь не за готовыми сосняками, а за обучающими полигонами с тегом leaf_type. После отсева мелких скверов и дачных посадок (оставили строго контуры площадью от 3 гектаров) набралось 3 769 полигонов: 1 407 чисто хвойных, 451 лиственный и 1 911 смешанных. Смешанные массивы временно отложили, чтобы обучить бинарный классификатор. Контуры ужали внутрь на 15 метров буфером отсечения граничных эффектов и насэмплировали обучающие точки с шагом 30 метров (76 760 точек). Для каждой точки в GEE собрали вектор признаков: Оптика Sentinel-2: летний и весенний NDVI, их разность (фенологический сдвиг), коротковолновые инфракрасные каналы SWIR (Short-Wave Infrared) B11 и B12, чувствительные к влажности хвои и геометрии полога, индекс NDII; Радар Sentinel-1: обратное рассеяние в поляризациях VV (вертикальная передача и приём) и VH (вертикальная передача, горизонтальный приём) летом и зимой; Рельеф Copernicus GLO-30: цифровая модель высот DEM с разрешением 30 метров, из которой вычислялись абсолютная высота и уклон поверхности. Облачные запросы падали по таймауту пяти минут, поэтому выборку выгрузили пакетным CSV-файлом, а обучение запустили локально в Python на базе библиотеки scikit-learn. Модель — Random Forest (ансамбль решающих деревьев). Честная валидация: как не обмануть себя В геоданных кроется страшная ловушка — пространственная автокорреляция. Соседние пиксели леса почти идентичны. Если перемешать точки случайным образом, соседние пиксели одного и того же дерева окажутся и в обучении, и в тесте, выдав дутые 99% точности. Мы применили пространственно-блочную кросс-валидацию: всю Ленинградскую область разбили на квадраты сетки 0,25°. Модель училась на одних блоках, а тестировалась на 30% совершенно независимых полигонов, которых никогда не видела в глаза. Результат на 29 190 проверочных точках: Overall Accuracy (общая точность): 0,887; Каппа Коэна (): 0,772 (отличный показатель с поправкой на случайное угадывание); Полнота по классам: лиственный — 0,895, хвойный — 0,878. Абляционный анализ: что реально работает? Мы поочерёдно отключали группы признаков, чтобы понять их реальный вклад: Набор признаков Число признаков Accuracy Каппа Только оптика Sentinel-2 6 0,848 0,692 Оптика + Рельеф (высота, уклон) 8 0,884 0,765 Оптика + Рельеф + Радар Sentinel-1 14 0,888 0,774 Полный набор (+ песок) 15 0,888 0,774 Выводы оказались поразительными: Рельеф — ключевой драйвер: добавление высоты и уклона дало колоссальный скачок точности (+3,6 процентных пункта). Хвойные и мелколиственные леса в тайге чётко привязаны к геоморфологии. Радар оказался лишним: 6 тяжелейших каналов Sentinel-1 дали прирост всего в 0,4 п.п. Для экономии памяти и времени радар был исключён из финального продакшена. Анализ важности признаков по перестановкам (permutation importance) показал: лидирует летний NDVI (0,1635), за ним — уклон рельефа (0,0282) и канал SWIR B11 (0,0277). А та самая сезонная просадка NDVI, которая с треском провалилась в виде одиночного порога, внутри многомерной модели дала весомый вклад (0,0145). Производственный релиз: компромиссы и оптимизация Попытка рассчитать всю Ленобласть в 10-метровом разрешении в GEE зависла на 15,5 часов и была снята. На сетке 20 метров число пикселей сократилось в 4 раза, и расчёт уложился в 25 минут. Чтобы исключить ошибки классификации над водоёмами (без маски модель объявила хвойным половину Ладожского озера), растр обрезали строгой маской древесного полога ESA WorldCover. Итоговый слой получил название «Хвойный 20 м» (пометка , бета-версия). При нарезке растровых тайлов файл весил 466 МиБ, превышая лимит загрузки Cloudflare R2 в 300 МиБ. Мы квантовали альфа-канал прозрачности (задающий вероятность хвойного) с помощью Pillow до 16 фиксированных градаций: вес упал до 221 МиБ без визуальной деградации. ┌────────────────────────────────────────────────────────────────────────┐ │ ИТОГОВАЯ ТОЧНОСТЬ КЛАССИФИКАТОРА │ │ │ │ МАТРИЦА ОШИБОК (ТОЧЕК В ТЕСТЕ: 29 190) │ │ Назвала Лиственным Назвала Хвойным │ │ На деле Лиственный: 14 578 1 706 │ │ На деле Хвойный: 1 580 11 326 │ │ │ │ • Overall Accuracy (Общая точность): 88.7% │ │ • Каппа Коэна (согласие сверх случайного): 0.772 │ │ • Полнота детекции хвои (Recall): 87.8% │ └────────────────────────────────────────────────────────────────────────┘ sourcecraft.dev Результат выделения хвойников Что в сухом остатке Изначально я просто хотел меньше крутить баранку и не бродить по пустым лесам. В итоге пет-проект заставил погрузиться в целый стек технологий и дисциплин: Геология и почвоведение: генезис четвертичных отложений, литология девона, морфология подзолов; Основы микологии: экология эктомикоризы Boletus pinophilus, молекулярные маркеры ITS/GAPDH; ГИС и веб-картография: проекции, тонкости OpenLayers, векторные тайлы PMTiles и range-запросы в Cloudflare Workers / R2; Computer Vision и OCR: конвейеры векторизации архивных геолкарт, дескрининг растров, робастные оценки; Дистанционное зондирование Земли и ML: мультиспектральная фенология Sentinel-2, радар Sentinel-1, борьба с пространственной автокорреляцией и оптимизация ансамблей Random Forest. Что осталось нерешённым: сосна, ель и беломошникЧестный отрицательный результат: сосна против ели А можно ли выделить саму сосну? Мы проверили это математически. Среди хвойных точек попытались обучить модель предсказывать песчаный субстрат по спектру кроны: По оптике показатель ROC AUC (Receiver Operating Characteristic — Area Under the Curve, площадь под кривой ошибок) составил ровно 0,500 — эквивалент подбрасывания монетки. Радар поднял AUC лишь до 0,537. Спектрально кроны сосны и ели на песчаных и суглинистых почвах в среднем диапазоне Sentinel-2 неотличимы. А обучающих меток конкретных пород в OSM катастрофически мало: сосны набралось около 150 выделов, а ели — всего 28. Выделить породу сосны в чистом виде из бесплатного космоса и выборки OSM пока не получилось. Надо либо доразмечать датасет в ручную либо взять область шире. Но наш рабочий суррогат - это пересечение ML-слоя хвойного леса 20 м с песчаными контурами ГГК-1000 и сухим рельефом — на практике прекрасно локализует боровые биотопы. Проблема лишайникового покрова Беломошник - геоботанический тип леса, а не ландшафтный класс. Напочвенный лишайниковый покров со спутников среднего разрешения сквозь сомкнутый полог крон не считывается. Попытки найти повыдельные таксационные карты в открытых источниках (ФГИС ЛК, региональные лесохозяйственные регламенты) упёрлись в то, что по Ленинградской области геометрия лесоустройства в открытом доступе отсутствует. Поэтому пересечение факторов остаётся наиболее честным приближением. И пара слов о современных инструментах разработки. Без диалога с LLM (в частности, Claude) подобный исследовательский пласт: от парсинга нестандартных протоколов до математической обработки растров заняло бы в одиночку пару месяцев плотного времени. Я же уложился в серию спринтов между поездками. Две корзины у Свири, радость от найденных боровиков под Лугой и даже забор под Токсово сложились в общую исторю этой осени. Получвшаяся карта не гарантирует, что грибы прыгнут в багажник сами но значительно экономит моторесурс и дарит азарт исследователя. Проект продолжает развиваться: Рабочая карта: griby-lenobl.mushrooms.workers.dev Репозиторий проекта: Neanderthal/mushrooms Глоссарий терминов, сокращений и обозначений Глоссарий терминов, сокращений и систем координатКарты, геоданные и пространственные системы OSM (OpenStreetMap) — краудсорсинговый картографический проект с открытыми геоданными. Источник разметки лесов и контуров болот. CGLS (Copernicus Global Land Service) — служба глобального мониторинга земной поверхности в рамках европейской космической программы Copernicus. LC100 (Land Cover 100 m) — растровый продукт земного покрова с шагом пикселя 100 м (в проекте взят слой Forest-Type за 2019 год). ESA WorldCover — глобальная высокодетальная карта типов поверхности с разрешением 10 м от Европейского космического агентства (ESA). В проекте использован класс Tree cover версии v200. Copernicus DEM GLO-30 — глобальная цифровая модель высот (DEM — Digital Elevation Model) с шагом около 30 метров, источник данных о высоте и углах наклона склонов. GDAL (Geospatial Data Abstraction Library) — базовая библиотека для трансляции, трансформации и чтения растровых и векторных геоформатов. GeoTIFF — стандартизированный формат растровых графических файлов TIFF, содержащий встроенные географические метаданные о координатной привязке. COG (Cloud-Optimized GeoTIFF) — оптимизированный для облачных сред GeoTIFF с внутренней тайловой структурой, позволяющий скачивать фрагменты растра HTTP-запросами диапазонов байтов без полной загрузки файла. GeoJSON — открытый формат описания географических объектов (точек, ломаных, полигонов и их свойств), основанный на синтаксисе JSON. EPSG — реестр геодезических параметров Европейской группы нефтеразведки (European Petroleum Survey Group). Числовые коды обозначают конкретные координатные системы. EPSG:3857 (Web Mercator / Pseudo-Mercator) — общепринятая в веб-картографии цилиндрическая проекция на сфероиде (OSM, Google Maps). EPSG:3395 (World Mercator) — эллипсоидальная проекция Меркатора, применяемая в тайловых сервисах Яндекса. LAEA (Lambert Azimuthal Equal Area) — азимутальная равновеликая проекция Ламберта. Сохраняет истинные площади контуров, устраняя масштабные искажения при расчётах в метрах. WGS84 (World Geodetic System 1984) — общеземная эллипсоидальная геодезическая система, глобальный стандарт спутниковой навигации GPS. ГГК-1000/3 — Государственная геологическая карта масштаба 1:1 000 000 третьего поколения (ВСЕГЕИ). — геологический индекс флювиогляциальных (водно-ледниковых) отложений (пески, гравийники зандров и озов). — геологический индекс озёрно-ледниковых отложений (пески, супеси, ленточные глины приледниковых озёр). — геологический индекс эоловых отложений (дюнные перевеянные пески). — геологический индекс ледниковых моренных отложений (несортированные суглинки, валунники). Спутниковое зондирование и машинное обучение NDVI (Normalized Difference Vegetation Index) — нормализованный относительный вегетационный индекс, количественная мера фотосинтетической активности растительности. SCL (Scene Classification Layer) — аппаратно-программный растровый слой классификации сцены спутника Sentinel-2, позволяющий автоматически отфильтровывать облака, тени крон, снежный покров и битые пиксели. SWIR (Short-Wave Infrared) — коротковолновый инфракрасный спектральный диапазон, отражение в котором определяется структурой крон и содержанием влаги в хвое/листве. B11 / B12 — каналы 11 и 12 сенсора MSI спутника Sentinel-2, соответствующие полосам SWIR. VV / VH — поляризационные моды радара с синтезированной апертурой (SAR) спутника Sentinel-1 (VV — вертикальное излучение и приём, VH — вертикальное излучение и горизонтальный приём). Random Forest (Случайный лес) — ансамблевый алгоритм классификации на базе комитетного голосования множества независимых решающих деревьев. ROC AUC (Receiver Operating Characteristic — Area Under the Curve) — метрика площади под кривой ошибок, отражающая интегральную способность бинарного классификатора разделять классы независимо от выбранного порога (0,5 — уровень случайного шума, 1,0 — идеальное разделение). Каппа Коэна () — статистическая метрика согласованности предсказаний модели с эталонной разметкой за вычетом вероятности случайного совпадения классов. Overall Accuracy (Общая точность) — отношение числа безошибочно классифицированных тестовых точек к общему размеру проверочной выборки. Компьютерное зрение и векторизация CIELAB — перцептивно сбалансированное цветовое пространство CIE , где геометрическое расстояние между координатами соответствует воспринимаемой глазом разнице оттенков. HSV (Hue, Saturation, Value) — цилиндрическое представление цвета по тону, насыщенности и яркости. RANSAC / USAC / MAGSAC++ — семейство робастных алгоритмов оценки математических моделей на зашумлённых массивах данных с высокой долей грубых ошибок (выбросов). Сетевой стек и облачная инфраструктура API (Application Programming Interface) — программный интерфейс для взаимодействия сетевых сервисов и передачи структурированных данных. 206 Partial Content — статусный код протокола HTTP, возвращающий запрошенный по смещению диапазон байтов файла. KV (Key–Value) — распределённое бессерверное хранилище типа «ключ — значение» платформы Cloudflare Workers. R2 — объектное S3-совместимое облачное хранилище Cloudflare с нулевой стоимостью исходящего трафика. WebGL (Web Graphics Library) — браузерный API для высокопроизводительного рендеринга 2D- и 3D-графики с использованием аппаратных ресурсов GPU. PMTiles — открытый облачно-ориентированный однофайловый архив для картографических векторных и растровых тайлов. Биология, сокращения и единицы измерений ITS (Internal Transcribed Spacer) — внутренний транскрибируемый спейсер ДНК, универсальный молекулярный штрихкод в систематике грибов. B. pinophilus / B. edulis — общепринятое в биологической номенклатуре сокращение названий видов Boletus pinophilus (белый гриб сосновый) и Boletus edulis (белый гриб сетчатый/обыкновенный). ООПТ — особо охраняемая природная территория. РСФСР — Российская Советская Федеративная Социалистическая Республика.
Having triangulation conform to Delaunay is a big advantage though! You should also try out mapbox/earcut.hpp for the benchmark — it got a refine function recently that refines earcut output to Delaunay as well, and earcut+refine is still pretty fast.
Implementing Visual Regression Testing in UI Automation - Why pixel-level checks catch what functional tests miss - Choosing between Percy, Playwright, and Cypress — trade-offs that change decisions - How to manage baselines, thresholds, and reduce visual flakiness - Embedding UI snapshots into CI and PR review workflows - Practical steps: setup checklist and CI pipeline Visual regressions are silent, high-impact bugs: the DOM is correct, buttons respond, but a 2px shift, a missing font, or a clipped SVG breaks a user journey and your metrics. Treat visual testing as the only practical way to assert that the UI your users see matches the UI you expect. The symptoms are familiar: green test suites with sneaky layout regressions reaching production, long manual visual checks in every release, and PRs that require back-and-forth screenshots in comments. You already have functional E2E, unit, and integration tests; what you don’t have is a reliable, automated way to capture rendered errors — the kind users actually notice and complain about — without throwing away engineering time. Why pixel-level checks catch what functional tests miss Functional tests validate behavior and DOM contract: clicks, navigation, APIs, accessibility attributes — the what. Visual tests validate the how — spacing, type, color, composition and responsive behavior. A button can be present and clickable yet visually occluded by a sticky header or mispositioned across breakpoints; functional assertions miss that, but a UI snapshot will show it as a pixel diff. Teams using visual checks report catching layout and styling regressions earlier in the cycle, with the diffs serving as minimal, actionable artifacts for designers and engineers to triage. > Important: Visual diffs are not a replacement for functional tests — they are a complementary layer that prevents surface-level regressions from eroding product quality. Concrete examples from practice: - A component library update changed line-height and pushed CTA buttons off baseline — all unit tests passed because props and events still worked, but users lost conversions until a visual snapshot flagged the change. - An A/B style tweak set a different system font stack for one branch; the replacement font caused a 1–2px layout shift across cards that reduced click targets on mobile. A screenshot comparison exposed the drift immediately. Choosing between Percy, Playwright, and Cypress — trade-offs that change decisions When you select a visual strategy you answer three operational questions: where baselines live, how diffs get reviewed, and whether you want managed rendering (cloud) or in-repo golden files. | Tool / Approach | Baseline storage | Rendering model | Review workflow | Good for | |---|---:|---|---|---| | Percy (managed SaaS + SDKs) | Cloud baselines, snapshot history | Percy renders snapshots (DOM/assets) centrally and shows pixel diffs in web UI | PR integration, visual review/approval UI; snapshot carry-forward and auto-approve settings | Teams that want PR-driven reviews and centralized baseline management. | | Playwright visual tests (toHaveScreenshot) | Golden images committed to repo (*-snapshots dir) | Local screenshots compared by Playwright's runner (pixelmatch under the hood) | Review diffs as changed files in VCS; update with --update-snapshots | Fast iteration for devs who want in-repo snapshots and tight runner control. | | Cypress + cypress-image-snapshot | Golden images in repo (cypress/snapshots) | Uses Cypress screenshots + jest-image-snapshot/pixelmatch diffs | Diffs stored locally; update with environment flags; or integrate with Percy for hosted review | Teams using Cypress who prefer open-source snapshot flow or hybrid approach. | Key operational trade-offs to weigh (practical language, not high-level marketing): - Percy centralizes baselines, provides a purpose-built review UI, and surfaces PR statuses automatically, which shortens designer/engineer handoffs. That convenience comes with a service dependency and snapshot quotas to track. - Playwright’s built-in snapshots keep everything in your repo and let you run comparisons entirely in CI without an external service; that suits single-repo teams that prefer committing goldens and controlling update flows. Playwright also exposes maxDiffPixels and threshold options to tune sensitivity. - Cypress plus cypress-image-snapshot is a mature OSS option with flexible configuration and local diff artifacts, and it plays well with Cypress’ existing test flows. If you want a hosted review but already use Cypress, the @percy/cypress SDK bridges both worlds. Contrarian insight from the field: picking a tool on “features” alone rarely solves visibility and process friction. The real ROI comes from the review loop (who approves snapshots?), baseline ownership (QA or dev branch?), and CI ergonomics (are snapshots synchronized across parallel runs?). Percy reduces friction on review and baseline carry-forward; Playwright and local snapshot approaches reduce external dependencies and make snapshot diffs part of the code review as file changes. How to manage baselines, thresholds, and reduce visual flakiness Baseline strategy — two common patterns - Cloud-managed baseline (Percy): choose a canonical branch (e.g., main) as your base and have Percy carry approved snapshots forward; use Percy’s approval workflow to gate which snapshots become the canonical baseline for subsequent builds. Percy supports auto-approve and approval-required branch configurations to match team processes. - Repo-based golden files (Playwright / cypress-image-snapshot): commit the first-run golden images to source control; updates require an explicit --update-snapshots or updateSnapshots=true step so changes are deliberate and auditable. Playwright uses --update-snapshots; cypress-image-snapshot uses --env updateSnapshots=true. Thresholds: pixel vs percent vs perceptual - Image-diff engines operate with two levers: - Per-pixel sensitivity (e.g., pixelmatch/threshold): how strict the per-pixel comparison is. - Aggregate threshold (failureThreshold / maxDiffPixels / percent): how many pixels/what percent can differ before failing. - Practical rule-of-thumb from teams: start strict for components (0–1% tolerance) and looser for large dynamic composites such as charts (1–5% depending on fidelity). Use SSIM for perceptual comparisons when small anti-aliasing differences produce noise. jest-image-snapshot/cypress-image-snapshot expose comparisonMethod: 'ssim' as an option. Flakiness mitigation checklist (these are deterministic actions to implement): - Freeze or disable animations at capture time: - Playwright toHaveScreenshot supports an animations option to disable animations during capture. - Percy snapshots accept a waitForSelector/waitForTimeout option and percyCSS to neutralize animations and dynamic elements. - Decouple dynamic content: - Mask or blackout region(s) that contain time stamps, randomized IDs or ads. Playwright supports mask locators in screenshot options; cypress-image-snapshot supports blackout in cy.screenshot() options. - Stabilize fonts and rendering: - Serve deterministic fonts during CI runs (bundle or preload fonts) rather than relying on system fallbacks; renderers differ across OSs and hardware—lock the environment. Percy serializes DOM and assets which helps, but you still need deterministic fonts for exact pixel parity. - Use controlled rendering environment: - Run visual tests in a consistent CI runner (Docker image or containerized environment) and pin browser versions; Playwright’s multiple project runners (Chromium/Firefox/WebKit) can generate per-browser snapshots for cross-browser visual checks. - Wait for meaningful paint: - Use targeted waitForSelector before capturing so the UI has stable data and server-driven placeholders have resolved. Percy and CLI snapshot commands support waitForSelector or waitForTimeout. Debugging flaky diffs: - Compare the produced diff images (the composite) to see whether differences are anti-aliasing noise, layout shifts, or data differences. Tools like jest-image-snapshot and pixelmatch provide configuration like includeAA and threshold to filter anti-aliasing noise. - If diffs are due to currency/time data or random IDs, mask those regions or inject deterministic stubs during test runs. Embedding UI snapshots into CI and PR review workflows A robust workflow has four stages: snapshot capture → upload/compare → review → baseline update. Percy flow (PR-centric, SaaS): 1. Add Percy SDK to tests (@percy/cypress, @percy/playwright) and call cy.percySnapshot() or percySnapshot(page, 'name') in places you want coverage. 2. In CI, set the PERCY_TOKEN environment secret and run your test command prefixed with percy exec --. Percy collects DOM/assets, renders snapshots in its service, computes pixel diffs, and surfaces them in the web UI. PRs show Percy build statuses and links to visual diffs for reviewers. 3. Reviewers approve snapshots (or reject) in Percy; approved snapshots become the baseline for future builds per your project settings (carry-forward/auto-approve). Playwright / Cypress local snapshot flow (repo + CI): 1. Run tests in CI; snapshot diffs are produced as modified files or diff artifacts in the build workspace. 2. Configure CI to fail a build on snapshot diffs (default) so the PR indicates visual regression. Alternatively, allow the job to pass and require a separate "visual review" step to examine artifacts. 3. Updating baselines is an explicit step: run npx playwright test --update-snapshots or rebuild and commit updated cypress/snapshots after a team-approved visual change. Example: GitHub Actions (Percy + Cypress) name: Visual tests (Cypress + Percy) on: [pull_request] jobs: visual: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - name: Start app run: npm start & npx wait-on http://localhost:3000 - name: Run Cypress with Percy env: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} run: npx percy exec -- npx cypress run --headless Note the PERCY_TOKEN secret and percy exec -- wrapper to capture snapshots and upload them to Percy in CI. Percy also provides tighter GitHub integration so PR statuses reflect visual review outcomes. Parallel builds and NONCE uniqueness: - If your CI runs snapshots in parallel jobs, ensure Percy’s NONCE (build identifier) is unique per run; some CI providers reuse run IDs across job steps which can cause finalization conflicts — Percy docs describe strategies for unique build NONCE across jobs. Practical steps: setup checklist and CI pipeline Actionable checklist you can apply in the next sprint (ordered): 1. Inventory visual surface: list pages/components that require snapshots (login, critical funnels, brand components, charts). Keep snapshots focused: many teams start with 50–200 snapshots and grow from there. 2. Pick baseline strategy: cloud (Percy) if you want PR-driven visual reviews; repo baselines (Playwright / cypress-image-snapshot) if you prefer version-controlled golden files. 3. Implement stabilizers: - Add percyCSS or per-snapshot CSS to hide dates and animations. - For Playwright use animations: 'disabled' in toHaveScreenshot and mask to hide dynamic elements. - For Cypress with cypress-image-snapshot use blackout and capture: 'viewport' options. 4. Add snapshot calls to high-impact tests: - Playwright example (Percy + Playwright): // tests/visual.spec.js const percySnapshot = require('@percy/playwright'); test('homepage visual check', async ({ page }) => { await page.goto('https://example.com', { waitUntil: 'networkidle' }); // stabilize or disable animations as needed await percySnapshot(page, 'Homepage - logged out'); }); - Playwright native snapshot example: import { test, expect } from '@playwright/test'; test('header visual', async ({ page }) => { await page.goto('https://example.com'); await expect(page).toHaveScreenshot('header.png', { animations: 'disabled' }); }); - Cypress (Percy) example: // cypress/e2e/visual.cy.js it('renders home', () => { cy.visit('/'); cy.get('body').should('have.class', 'app-loaded'); cy.percySnapshot('Home - default'); }); - Cypress (cypress-image-snapshot) example: // cypress/e2e/snapshot.cy.js it('renders dashboard', () => { cy.visit('/dashboard'); cy.matchImageSnapshot('dashboard', { failureThreshold: 0.02, failureThresholdType: 'percent' }); }); 5. CI integration: - Add PERCY_TOKEN as a secret for Percy-backed flows and wrap the test run with percy exec --. - For repo-based baselines, ensure your CI pipeline fails on diffs and that tests that update baselines run only on protected branches (or require explicit approval) so you avoid accidental golden updates. 6. Review and governance: - Decide who approves visuals (product designer, QA lead) and where approvals get recorded (Percy UI vs VCS commit). Configure Percy auto-approve or approval-required branches to match your process. 7. Monitor and iterate: - Track snapshot counts, failing snapshot trends, and false positive rate. If noise rises, tighten stabilization (mask/blackout fonts) and tune thresholds rather than disabling snapshots. Quick troubleshooting commands: - Update Playwright snapshots: npx playwright test --update-snapshots. - Update Cypress snapshots: npx cypress run --env updateSnapshots=true (or set CYPRESS_updateSnapshots=true). - Run Percy locally: export PERCY_TOKEN=... && npx percy exec -- <test-command>. > Small operational policy: treat golden updates like code changes: require a clear PR, a screenshot review in the diff, and a deliberate commit message (e.g., "update visual snapshot: header typography change"). Every time you add visual tests, you add an executable artifact that lives alongside your test strategy: UI snapshots. They turn vague "it looks different" complaints into concrete images you can review, approve, or revert. Use the automation to keep that loop short, deterministic, and owned: stabilize the environment, choose a baseline strategy that matches how your team likes to approve changes, and wire snapshots into CI so visual feedback arrives as early as unit-test feedback. Sources: percy/percy-cypress - Official Percy Cypress SDK repository and README showing cy.percySnapshot() usage and integration notes. percy/percy-playwright - Percy Playwright SDK repo with percySnapshot(page, 'name') examples and per-snapshot options. Playwright — Visual comparisons / snapshots - Playwright Test docs describing expect(page).toHaveScreenshot(), snapshot lifecycle, --update-snapshots, and options (thresholds, animations, masks). Visual Testing in Cypress (Cypress Docs) - Official Cypress guidance listing visual testing tools and examples of cy.percySnapshot() usage. simonsmith/cypress-image-snapshot (GitHub) - Maintained Cypress image snapshot plugin README with configuration, matchImageSnapshot options (failureThreshold, blackout, etc.), and update flags. Visual Testing with Percy — overview and baseline concepts (BrowserStack Docs) - Percy workflow, approvals, and baseline management details useful for team processes. percy/cli (GitHub) - Percy CLI repository describing percy exec, percy snapshot command options and asset discovery essentials. pixelmatch (npm / README) - The pixel-level diff engine used by many snapshot tools; documents threshold, anti-alias settings, and how pixel diffs operate.
Under the hood: integrating Vulkan into the Mapbox rendering engine Vulkan support was introduced in v11.24.1. The details on how to enable Vulkan are here. In this post we would like to explain the reasoning for migrating from OpenGL to Vulkan, plus some more migration details. Reasons for migrating from OpenGL to Vulkan OpenGL has long been the primary graphics API in our map rendering engine. OpenGL is a time-proven API, but it is being steadily replaced by newer graphic APIs, like DX12, Metal and Vulkan. These modern APIs are designed to be really close to the hardware, reducing the overhead of issuing graphics API calls. OpenGL provides a high abstraction level, which imposes several considerations. Built in error checking Built in error checking may sound like a good thing as it keeps the rendering robust. The OpenGL driver is forced to maintain the global state and handle errors in a gentle way. For example, an unbound buffer during copy command will not cause a crash. But the downside is that the error checking consumes CPU cycles and cannot be switched off. Runtime shader compilation OpenGL accepts shaders written in GLSL, and the driver compiles them into machine code at runtime. This approach prevents shipping precompiled shaders, and causes stutter when there is a need to compile a shader. Opaque memory management With OpenGL, there is very limited control on what is allocated and when. This can cause stutters that are unpredictable and difficult to avoid. Limited multithreading capabilities OpenGL operates via contexts. A context is a complex object that encapsulates, accumulates and mutates a state responsible for holding all the data needed to execute a GPU command, i.e. make a draw call. That context is bound to only a single CPU thread at a time, restricting the ability to make API calls on a context from multiple threads in a concurrent manner. This prevents optimizations used in modern rendering engines, like recording draw commands from multiple threads concurrently. State leaking OpenGL maintains a state that is tied to a global context. State leaking is a known issue in multi-library scenarios, where each of the libraries makes OpenGL calls. <figcaption>Our application was expecting a zero bound framebuffer, but the third-part library changed the state, breaking our application logic.</figcaption> OpenGL deprecation There are several reasons for the deprecation of OpenGL: First, the evolution of the OpenGL ES API has been halted in favour of Vulkan API. The latest version of OpenGL ES 3.2 was released in 2015, since then no major releases were issued. New graphics features will only be exposed through Vulkan. Next, Android has taken a course to deprecate OpenGL native driver support. According to the Vulkan roadmap for Android, starting 2025 the preferred way to make OpenGL calls is to go through the ANGLE abstraction layer, and then after translation, call to the Vulkan driver. ANGLE is the Almost Native Graphics Layer Engine, which is included in Android 15 and higher. The addition of a layer between render calls and the driver introduces more complexity and less control, and it will hurt performance. !Direct usage of Vulkan allows us to bypass intermediate translation layers.<figcaption>Direct usage of Vulkan allows us to bypass intermediate translation layers.</figcaption> Lastly, tooling is worth mentioning because it is crucial to the developer experience. Rendering tooling, like frame profilers or shader analyzers, is very limited or totally absent for OpenGL especially for mobile platforms. For example, modern gpu profiling tool sokatoa goes with Vulkan support only. For these reasons, we decided to add support for a Vulkan backend in our renderer. Introducing Vulkan When transitioning to Vulkan, we first needed to decide which target platform Vulkan version to support. Currently there are five notable Vulkan iterations: 1.0, 1.1, 1.2, 1.3 and 1.4. We chose Vulkan 1.1. It has a high device support percentage (87% support). Additionally, compared to 1.0 (93% device support), it has important functionality guaranteed; like dedicated allocations, and a negative viewport in order to flip the image without altering all the code responsible for transformations and SPIR-V 1.3. Detailed information on Vulkan support percentage on Android is in the distribution dashboard (Latest update November 2025). In order to minimize the boilerplate of initializing Vulkan and setting up swapchain, we opt for widespread industry approved libraries: a combination of volk and vk-boostrap. For presentation, we use the recommended mobile settings: two frames in flight, minimum of three requested swapchain images, and FIFO presentation mode. Shaders At the core of every rendering engine are shaders. Historically, we have a single shader codebase written in GLSL that is shared between all our backends. This allows us to guarantee visual parity across all platforms. GLSL is a first-class citizen for WebGL and OpenGL, whereas Vulkan accepts shaders in SPIR-V format. This raises three main questions: - What do we compile? - When do we compile? - How do we compile? What do we compile? For Vulkan, we want to preserve all the initial shaders. So our input is OpenGL ES 3.0 GLSL shader source code with multiple preprocessor definitions dependent logic. Those preprocessor definitions when replaced produce the result GLSL (shader variant) which is then used for rendering. !Shader Variant <figcaption>Typical shader preamble: there are multiple compile time definitions that control behaviour of the shader.</figcaption> When do we compile? To answer this question, the following options were considered: Compile at runtime There are pros and cons when compilation happens on demand. This is the least invasive to the infrastructure and to the rendering code as well. There’s no need to introduce additional artefacts, ship them, or provide them to the end-user. However, there are potential stutters or missing content on screen if compiled on the render thread. Stuttering should be avoided at all costs. Missing content is also not ideal. So approaches with compiling SPIR-V beforehand were considered. Compile during build time There are pros and cons when compilation happens when we prepare our main artefact. It’s a benefit that there is no runtime compilation penalty for precompiled variants. However, it’s not possible to compile all the variants, and there is binary size growth. Compile on the server This is when compilation happens beforehand and SPIR-V is stored on the server. Those are then served to the client among other resources: geometry data, textures and SPIR-V. As with all options, there are pros and cons. There’s no runtime penalty, and you can precompile significantly more shader variants. However, the list of cons is lengthier. - There are still too many variants to precompile - You need to precompile variants for different client versions - There is network traffic increase - It is not suitable for offline - It requires a lot more modifications across all our infrastructure Mapbox chose a combination approach Clearly none of these approaches is a silver bullet. So we opted for a combination: runtime, embedding precompiled SPIR-V, persistent caching, and background compilation. Our maps styling and rendering is highly customisable by the end user, making it impossible to compile shader variants beforehand. Because of that, runtime compilation is unavoidable. Once a variant is compiled it is placed into a persistent cache, so further occurrences of that particular variant will not trigger compilation (even after application restarts). It is impossible to precompile all the SPIR-V variants, but we can precompile the most common variants seen in our Standard style. For our Standard style we simulate the most common map usage scenarios and gather around 150 unique shaders, resulting in about 2 Mb of extra memory that we embed into the binaries. To reduce hiccups, all the runtime compilation happens in the background thread so it does not block the render thread. How do we compile? For GLSL to SPIR-V compilation, we use the shaderc + SPIRV-Reflect libraries combo. Shaderc is based upon glslang - the reference GLSL to SPIR-V compiler. We use the following shaderc::CompileOptions for compilation: shaderc::CompileOptions compileOptions; compileOptions.SetVulkanRulesRelaxed(true); compileOptions.SetAutoBindUniforms(true); compileOptions.SetAutoMapLocations(true); compileOptions.SetTargetEnvironment(shaderc_target_env_vulkan, shaderc_env_version_vulkan_1_1); compileOptions.SetOptimizationLevel(shaderc_optimization_level_size); compileOptions.SetTargetSpirv(shaderc_spirv_version_1_3); Those options make shaderc execute the following steps: - Transform OpenGL ES 3.0 GLSL to intermediate Vulkan acceptable GLSL - Compile intermediate GLSL to SPIR-V. !Example of a OpenGL ES shader transformed to a Vulkan acceptable GLSL flavour<figcaption>Example of a OpenGL ES shader transformed to a Vulkan acceptable GLSL flavour.</figcaption> Pipelines Once we have SPIR-V shaders, the next step is to compile those shaders to actual machine code. In Vulkan this is done as a part of pipeline creation. A pipeline is the central Vulkan object since it is meant to encapsulate all the state information that is needed for rendering, including shaders. Pipeline creation is a time-consuming operation, in part due to SPIR-V compilation. <figcaption>A high-level view of a graphics pipeline consisting of multiple states which can be divided into four separate categories: vertex input, pre-resterization shaders, fragment shader and fragment output interface.</figcaption> In order to speed up compilation we use VkPipelineCache as recommended by multiple GPU vendors. <figcaption>Creating a graphics pipeline that uses the exact same SPIR-V from an already created pipeline shows significant time reduction.</figcaption> That VkPipelineCache is also serialized to persistent cache so it can be reused for application reruns. But this method doesn’t properly handle two important cases: when we cold start, and when we first encounter a certain shader variant. To handle this we use fossilize, a library that allows us to serialize data needed for pipeline creation, and replay that data later in order to create the exact same pipeline. Similar to how we prepare the GLSL to SPIR-V prebuilt cache, we gather pipeline creation data for our Standard style, serialize that data, embed it in our build artifacts, and replay those pipelines on application start filling VkPipelineCache. How do we handle uniforms? Contrary to OpenGL and WebGL, Vulkan doesn’t accept free uniforms. Uniforms can be passed only via push constants or by buffer memory. Our aim was to make as few modifications to our high level API as possible. When compiling from GLSL to SPIR-V, we pack all uniforms in a single uniform buffer object. That UBO is set dynamically for each draw call. Vulkan accepts shader resources in the form of descriptor sets. In order to minimize descriptor set updates, we maintain a single large UBO which accepts all writes from uniform updates. This single UBO is used in cooperation with a single dynamic UBO descriptor. Essentially, we have a single descriptor responsible for all the rendering commands. <figcaption>A single memory block and single buffer hold all the uniforms used for rendering. Uniform blocks are organized in a queue-like fashion. Uniform blocks are removed from the queue after frame in flight count frames (usually two).</figcaption> Memory In Vulkan, it is the developers’ responsibility to make all the allocations in appropriate memory heaps. We opted for VMA, a well-known library that takes most of the allocation burden on its shoulders. VMA allows a certain amount of configuration. We settled on the following configuration: - Render targets, large buffers, and textures are always allocated dedicated - Vertex & index buffers, textures, staging buffers, uniform buffers all to separate pools It’s also worth mentioning that due to frames in flight, all resources that imply per-frame or frequent updates have a number of frames in flight duplicates under the hood. <figcaption>A brief visualization of how frames in flight are handled. On Frame N + 1, the buffer we update could still be used for rendering, or it can be in pending to be rendered state. In order not to create a synchronization point between CPU and GPU, all the buffers that are subject for per-frame updates work in a circular fashion.</figcaption> On platforms with Unified Memory Architecture (UMA) support, where both the CPU and the GPU share a single system RAM which includes most of the mobile platforms, we omit staging memory whenever it is possible. Here is a a map view and corresponding memory layout !Standard style.<figcaption>Standard style.</figcaption> <figcaption>Two dedicated shadows maps, a pool of small textures, a pool of uniform buffer objects and a pool of vertex/index buffers.</figcaption> Validation layer Layers are a core mechanic in the Vulkan API that allow extending behaviour beyond what the spec allows. The most useful layer is the Khronos validation layer. This layer validates Vulkan function calls: checking arguments, maintaining render state, and reporting if any errors are encountered. We do execute our CI jobs with a validation layer enabled to ensure no validation errors on target devices. <figcaption>It is possible to get direct pointers to Vulkan functions inside the driver, or to go through a chain of layers including the validation layer.</figcaption> It is worth noting that the Vulkan validation layer is far more powerful than the OpenGL error reporting system, which highlights only critical errors. Checking all graphics API calls via the Vulkan Validation layer highlights far more errors, incompatible formats, and suboptimal usage, even if compared to glDebugMessageCallback. Results Here are the gains of Vulkan migration. Faster frames We gained about 10% faster frames for CPU heavy styles with multiple draw calls. This is achieved by removing some of the CPU bottlenecks that OpenGL adds behind the scenes, such as validation optional and pipeline caching. The gains can be seen both in lightweight and heavy scenes: !Standard style. Pixel 7 device. 50, 90, 95, 99 frame time percentile. Vulkan is about 10% faster.Standard style. Pixel 7 device. 50, 90, 95, 99 frame time percentile. Vulkan is about 10% faster. !Pixel 7 device. [Tracy (profiler built into our engine) gathered frame time histograms comparison for a high zoom, layer intense view point. Yellow - Vulkan. Red - OpenGL. About 10% from OpenGL median between OpenGL and Vulkan medians. ](dev-to-uploads.s3.us-east-2.amazonaws.com)<figcaption>Pixel 7 device. Tracy (profiler built into our engine) gathered frame time histograms comparison for a high zoom, layer intense view point. Yellow - Vulkan. Red - OpenGL. About 10% from OpenGL median between OpenGL and Vulkan medians.</figcaption> <figcaption>Standard style zoom-in/zoom-out scenario. P75 frame time. Both Galaxy S22 and Moto G22 show better performance with Vulkan.</figcaption> Fewer hiccups and faster cold start times By embedding most common SPIR-V & pipeline variants we get fewer hiccups and faster cold start times. <figcaption>Standard style. A typical high zoom level frame with about 150 unique shaders.</figcaption> <figcaption>Pixel 4 device. All shaders/pipelines compilation time (lower is better) for the above camera position.</figcaption> Better integration for multi-graphics library Vulkan-ecosystem For developers who have Vulkan initialization handled on their side and render loop set up, we provide an API to inject global Vulkan objects into our library. Vulkan global objects we accept include: VkInstance, VkDevice, VkPhysicalDevice, VkQueue, and per frame data as well including VkCommandPool, VkCommandBuffer, VkImage for render target color and depth. Conclusion Vulkan is gaining more and more share of the mobile market. While OpenGL will not go away (at least via ANGLE), using it comes with sacrifices: a certain runtime penalty, less control on what is actually happening on the GPU, and less access to all the features the hardware exposes. Since the Vulkan release in 2016, the drivers have matured enough to be production ready. The same is true for developer tooling. There are frame inspectors and shaders analyzers for all the major mobile vendors, clearly making Vulkan a viable option. As a result, Vulkan support was added to our map rendering engine, without any major infrastructural changes, and with immediate gains like better frame rates and more control on stalls. In addition to the immediate gains, Vulkan offers the potential for more intrusive engine optimizations. We can spread command buffer construction across multiple threads. We can move parts of the map engine logic to the GPU, like symbol placement. And we can reuse command buffers between frames for static data, and much more. In the coming months we’ll be exploring these optimizations further.
Pixelmatch, my image comparison library for visual regression testing, got a major overhaul: a new color difference metric (OKLab + HyAB) that's much closer to how we see color, a windowSize option to fight scattered noise, and more — while staying fast github.com/mapbox/pixelmatch/releases/tag/v8.0.0 t.co