Получили звонок из агрохолдинга: 50 тысяч гектаров, есть спутниковые снимки, поля считают вручную в QGIS. Можно автоматизировать? Смотрим рынок — готовые решения от $10K в год, требуют GPU-кластер. У заказчика сервер стоит в бухгалтерии рядом с принтером, видеокарты нет. Решаем писать сами. Задача звучит просто: найти зелёное поле на зелёном фоне, ограниченное лесополосами, иногда это незасеянные, но распаханные поля коричневого цвета.
Решение нашлось на Random Forest. Классика, работает на CPU из коробки. Вечером первого дня готов первый прототип — четырнадцать признаков на пиксель. RGB, HSV, LAB, индекс растительности, градиенты Собеля, лапласиан, размытие Гаусса, позиция пикселя в кадре. Обучается за три минуты, качество не идеально, но контуры полей видны. Dice 0.72 на тестовом снимке. Мы решаем ехать на Random Forest не потому что он лучше, а потому что работает сегодня на железе заказчика.
Когда заканчивается память
Загружаем большой снимок — десять тысяч на десять тысяч пикселей. Python съедает шестнадцать гигабайт и падает. Оказывается, Random Forest держит всю обучающую выборку в памяти. Сто миллионов пикселей умножить на четырнадцать признаков умножить на четыре байта — пять с половиной гигабайт только на признаки. Плюс модель, плюс накладные расходы.
Нам нужна подвыборка, но не простая случайная. Если случайно выкинуть все пиксели полей, модель научится находить только фон. Придумываем стратифицированную подвыборку — сохраняем пропорцию классов. Из каждого изображения берём максимум пятьдесят тысяч пикселей, но сохраняем соотношение поле к фону как в оригинале. Лимитируем общий объём пятьсот тысяч пикселей. RAM не превышает гигабайт. Спорим, не испортим ли качество. Тесты показывают разницу в Dice всего 0.02, терпимо. Зато система работает на ноутбуке заказчика.

Шесть чисел от ада
Заказчик присылает архив: JPG + JGW. JGW - файл привязки JPEG World File, шесть чисел, переводящих пиксели в реальные координаты. Добавляем автоопределение системы координат: если координаты в градусах от минус ста восьмидесяти до ста восьмидесяти, это WGS84. Если миллионы — метровая проекция.

Маска плывёт
Для скорости мы масштабируем изображение до 512 пикселей при извлечении признаков. Но заказчику нужна маска в оригинальном разрешении для векторизации. Проблема в том, что предсказали на 512 на 512, а оригинал 5000 на 5000. Если растянуть маску простым ресайзом, границы полей размоются. Решаем двойным масштабированием. Предсказываем на уменьшенном быстро, возвращаем к оригиналу точно. Интерполяция линейная для вероятностей сохраняет градиенты, ближайший сосед для финальной маски даёт целые пиксели. Проверяем на тестовых данных — границы совпадают с реальностью в пределах погрешности съёмки.
Интерфейс без стыда
Фронтенд пишем на чистом JavaScript, никаких React, дедлайн через два дня. Главная фича — drag-and-drop загрузка. Прогресс обучения делаем через polling, фронтенд раз в секунду спрашивает статус. Не элегантно, но работает без WebSocket и сложностей. Обнаруживаем, что пользователи не знают про системы координат. Добавляем автоопределение и выпадающий список для экспертов — WGS84, Web Mercator, UTM-зоны, Пулково-1942.

Боевой запуск
Тестируем на реальных данных заказчика. Снимки Sentinel-2, разрешение десять метров, облачность пятнадцать процентов. Работает. Проверяем на космоснимках высокого разрешения — метр. Требуется переобучение, на мелких полях другая текстура, границы резче. Добавляем в интерфейс загрузку собственных масок. Заказчик загружает двенадцать снимков, обучает модель, распознаёт новый регион. Весь цикл пятнадцать минут. Раньше на это уходил день ручной работы.
Как пользоваться
Нужны спутниковые снимки вашего региона и маски к ним — чёрно-белые картинки, где белое это поле. Правило именования простое: если снимок называется image.jpg, маска должна быть image_mask.png. Загружаете на страницу обучения, проверяете что количество снимков равно количеству масок, запускаете с параметрами по умолчанию. Ждёте три минуты, смотрите на метрику Dice. Если больше 0.75 — переходите к распознаванию, если меньше — добавляете качественных данных или проверяете маски.

Без масок можно загрузить только снимки, но обучения не получится. Система сообщит что нет размеченных данных. Маски можно сделать в QGIS векторной разметкой с последующей растеризацией, в специализированных инструментах вроде LabelMe или CVAT, или просто в GIMP обвести поля и залить белым.

При распознавании загружаете новый снимок и опционально файл геопривязки JGW. Система автоматически определит систему координат или возьмёт вашу из выпадающего списка. Получаете маску полей, наложение на оригинал, GeoJSON с контурами и площадями. Скачиваете и открываете в QGIS или ArcGIS.

Если модель обучена на юге России, а нужно распознать поля в Сибири — переобучайте. Почвы, культуры, структуры полей и съёмка отличаются. Добавьте пять-десять снимков нового региона к старым данным, качество вырастет.
Код открытый. Если разбираетесь в Python и хотите добавить интеграцию с Росреестром — добро пожаловать.