2ГИС Ситискан — это мобильное приложение для автоматизированной съёмки городской среды. Оно устанавливается на смартфон, закреплённый в автомобиле, и во время движения делает снимки, собирает координаты, скорость и другие параметры. Эти данные обрабатываются с помощью ИИ, чтобы находить проблемы в инфраструктуре: ямы, мусор, повреждённые объекты и другое.

На просторах России
На просторах России

Одна из ключевых задач приложения — снимать изображения с высокой частотой, особенно при объездах сложных участков. Но на практике оказалось, что стандартный метод съёмки takePicture во Flutter может занимать до 3 секунд на один кадр. Это делает невозможной съёмку даже 1 кадра в секунду, не говоря уже о 4 кадрах, которые нам нужны для точного анализа.

В этой статье мы — Руслан Цицер и Арген Жукеев @zhukeev— расскажем, как исследовали узкие места, перепробовали разные подходы — от RepaintBoundary до нативной обработки на C и Java — и в итоге добились стабильной съёмки с минимальной задержкой. Наш кейс будет полезен Flutter-разработчикам, которым необходима высокая частота съёмки (до 4 fps и выше) для своих приложений и инженерам в области компьютерного зрения и машинного обучения или разработчики AR/VR-решений на Flutter, где критичны задержки и качество кадров.

В чём проблема

Итак, наше приложение делает фотографии с определённой частотой. Это не видеосъёмка, а именно отдельные снимки — как в слайд-шоу. Существует несколько алгоритмов по снятию фрейма:

  • Фиксированная частота: например, 1 кадр в секунду, 2 кадра в секунду и т.д. 

  • В зависимости от пройденного расстояния: например, каждые 25 или 50 метров. 

  • В зависимости от скорости: чем выше скорость, тем выше частота съёмки.

Поначалу использовался алгоритм, основанный на пройденном расстоянии. Проблемы, конечно, возникали, но другого характера. Например, запомнились перегрев из-за одновременного использования камеры и карты во время проезда, неточность GPS или его полное отсутствие в некоторых населённых пунктах.

Однако проблема, о которой пойдёт речь в этой статье, появилась, когда возникло требование делать снимки чаще, чем каждые 25 метров. Выяснилось, что процесс съёмки одного кадра может занимать до 3 секунд. Это крайне долго, так как при объездах иногда требуется частота до 4 фреймов в секунду.

Где теряется время?

Прежде чем что-то фиксить, мы начали разбираться, что именно работает не так. Было несколько разных гипотез и предложений. Начали с замеров.

Процесс снятия фрейма состоит из следующих шагов:

  1. Вызов метода takePicture. Тут всё понятно — без него не получить снимок. Этот процесс занимает до 500 мс в дебаге. И про его оптимизацию поговорим чуть позже.

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

  3. Сохранение метаданных и пути к файлу фотографии в базе данных SQLite. Обычно это происходит тоже быстро — до 100 мс.

  4. Поворот фотографии на 90 градусов. Этот шаг может показаться неочевидным, но на практике — необходимость.

Всё потому, что здесь вступает в игру множество факторов, которые ведут себя по-разному на различных устройствах:
  • Включён ли автоповорот экрана у пользователя?

  • Поворачивается ли превью камеры при переходе в горизонтальный режим?

  • Если пользователь держит телефон горизонтально, то в какую сторону он его повернул — влево или вправо?

  • Как именно плагин камеры интерпретирует ориентацию устройства и сохраняет изображение?

Вот поэтому, чтобы гарантировать, что фотография будет отображаться корректно (в нужной ориентации), нужно повернуть её «вручную» на 90 градусов. 

Про поворот на 90 градусов

Повернуть UI при наклоне телефона не так сложно. Но какая при этом будет ориентация у камеры? Знатоки скажут, что есть метод lockCaptureOrientation, и будут правы. Этот метод позволяет зафиксировать ориентацию камеры в нужном положении. И всё было бы ок, если бы landscapeLeft поворачивал камеру в одну сторону на разных девайсах. Но, как можно догадаться, это не так: на iOS поворачивается в одну сторону, на Android — в другую сторону.

Окей, можно добавить if для определения платформы, и теперь камера находится в нужном положении. Но почему фотографии получаются перевёрнутыми? Причём это происходит не на всех устройствах, а выборочно — в зависимости от того, заблокирована ли ориентация экрана, какой у телефона производитель (например, на Samsung и Xiaomi  поведение отличается ) и так далее… В общем, здесь ещё целый вагон проблем и неопределённостей. Поэтому было принято решение зафиксировать ориентацию камеры с помощью lockCaptureOrientation в портретном (вертикальном) положении. Теперь мы точно знаем, что фотографии будут «неправильными» — повернутыми на 90 градусов. Поэтому после съёмки нужно выполнить поворот изображения в нужную сторону.

Ок, задача получена — фича успешно реализована с помощью библиотеки Image. Эта библиотека написана на Dart и выполняет различные манипуляции с изображениями: от изменения размера до наложения фильтров. Чтобы не блокировать основной поток UI, предусмотрена возможность обработки в отдельном изоляте, а также множество других полезных фич.

Но, как вы уже, наверное, догадались, именно это и стало слабым звеном. Выполнение поворота изображения занимало до 2–3 секунд. Очевидно, что так дело не пойдёт — нужно либо ускорять сам процесс поворота, либо выполнять его асинхронно.

Кстати, про асинхронность

Почему снятие снимков, которое выполняется до 3 секунд, не делали асинхронно? Зачем ждать окончания? Ведь можно «щёлкать» каждые 250 мс, а изображения обрабатывать в бэкграунде. 

Ответ. Во-первых, камера не позволяет делать новый снимок, пока делается предыдущих. Во-вторых, даже если и делать снимки каждые 250 мс, то остальные процессы, которые выполняются долго, из-за высокой частоты и большого количества кадров будут накапливаться в памяти и не успевать обрабатываться. Так недалеко и до ANR (application not responding).

Решаем проблемы

Итак, у нас теперь есть как минимум две проблемы: относительно медленный takePicture и очень медленный rotatePicture. Начали с последнего — и это оказалось проще всего пофиксить.

Очевидно стало, что обработку изображений нельзя выполнять на стороне Dart. Поэтому мы написали код на C, который делает то же самое. Dart и C связали через FFI. В результате rotatePicture теперь работает за 50–60 мс — в разы быстрее, чем раньше.

Итак, сам снимок делается 300–500 мс в зависимости от загрузки телефона. Нужно это дело ускорить. А это уже нетривиальная задача, для решения которой у нас было несколько вариантов:

  1. Попробовать использовать другой плагин для камеры, например, CameraAwesome, поменять реализацию виджета, перейти на другую API. При хорошей архитектуре не такой сложный переход. 

Решит ли это проблему? Есть сомнения. 

  1. Использовать другой метод камеры, который возвращает стрим изображений. Брать снимки сразу из этого стрима — можно использовать тот же плагин. Быстро проверили и после теста обнаружили, что при использовании стрима всё лагает. 

Не подходит.

  1. Записывать видео, фиксируя таймстемпы и координаты точек, а затем извлекать фреймы из видео. Эта реализация сложная: запись видео, обработка записанного видео, использование других дополнительных плагинов типа FFmpeg, проработка кейсов, когда запись слетает, возможная перегрузка устройства. 

В общем, много других подводных камней уже на старте.

  1. Форкнуть библиотеку камеры и изменить реализацию, чтобы можно было делать снимки быстрее. Это перспективный вариант, но требует времени и отдельной реализации под разные платформы

  2. Изменить сам подход к снятию фрейма — делать скриншот виджета камеры через RepaintBoudnary. Кроссплатформенно, относительно быстро.

Решили ресерчить два направления: форк библиотеки и RepaintBoundary. Мой коллега Арген Жукеев @zhukeev занялся форком библиотеки и начал реализацию с Android, а я в это время занялся реализацией своего варианта.

Вариант 1: RepaintBoudnary

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

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

Во Flutter есть специальный виджет, который создаёт отдельный слой рендеринга — RepaintBoundary.

Реализация

Реализация проста как два пальца об асфальт: оборачиваем нужный визуальный компонент в RepaintBoudnary и получаем изображение слоя. Минимум усилий, кроссплатформенная реализация.

Основные шаги:

1. Создаём глобальный ключ для доступа к виджету.

final GlobalKey _globalKey = GlobalKey();

2. Оборачиваем нужный виджет в RepaintBoundary.

@override
Widget build(BuildContext context) {
  return RepaintBoundary(
    key: _globalKey,
    child: SomeWidget(),
  );
}

3. По ключу получаем изображение.

Future<ui.Image> getImage() async {
  final boundary = _globalKey.currentContext?.findRenderObject();
  if (boundary is! RenderRepaintBoundary) {
    throw Exception('Не найден RenderRepaintBoundary');
  }

  final ui.Image image = await boundary.toImage(
    pixelRatio: 1,
  );
  return image;
}

4. Преобразуем полученное изображение в байты. 

Future<Uint8List?> getImageAsBytes() async {
  final image = await getImage();
  final bytes = await image.toByteData(
    format: ui.ImageByteFormat.rawRgba,
  );
  image.dispose(); // необходимо освобождать изображение
  return bytes?.buffer.asUint8List();
}

Теперь полученные байты можно сохранить в файл для последующего использования.

Какие подводные камни реализации

  1. Метод toImage при pixelRatio = 1 возвращает изображение низкого разрешения.

  2. Метод toByteData выгружает байты в память Dart. Если увеличить pixelRatio, то повысится разрешение, но будет затрачиваться больше памяти.

  3. Формат ui.ImageByteFormat.rawRgba возвращает сырые необработанные байты. Это не jpeg или png. Нужна дополнительная обработка. Знатоки могут сказать, что есть формат ui.ImageByteFormat.png, и будут правы. Но этот метод отрабатывает дольше по сравнению с получением сырых байтов, а также наш бэкенд ждет именно jpeg-изображение, так что нам такой вариант не подходит.

  4. Реализация требует постоянного наличия виджета в дереве виджетов или, если упрощённо, он должен всегда присутствовать на экране.

Принятые решения по реализации

  1. Оборачиваем виджет камеры в RepaintBoudnary.

  2. Получаем изображение не очень высокого качества, чтобы не перегружать оперативную память.

  3. При смене экрана не убираем виджет, а переносим в бэкграунд. В нашем случае за виджет с картой.

  4. Получаем байты в формате RGBA и сохраняем в файл

  5. Делаем постобработку сырых байтов*

  6. Ждём реализацию Аргена (форк библиотеки на Android) :) 

* Данную постобработку реализовали на чистом C, и чтобы не блокировать основной поток UI, всё перенесли в отдельный изолят. 

Описанную выше реализацию вынес в библиотеку на pub.devwidget_to_image_converter | Flutter package.

Итоги такого решения

Это было лишь временное решение, которое имело ряд недостатков: относительно невысокое качество изображений, риск словить ANR на слабых девайсах из-за перерасхода оперативной памяти. Но в свою очередь как альтернативный вариант решение оказалось вполне достойным. 

Вариант 2: форк библиотеки

Почему takePicture медленный

Процесс захвата фото через takePicture включает несколько последовательных этапов, каждый из которых вносит задержку.

  • Подготовка временного файла. Создаётся temp-файл для JPEG (синхронный I/O). На быстрых девайсах — десятки миллисекунд, на загруженных — больше.

  • Настройка ImageReader и слушателя. Создаётся ImageReader для JPEG, вешается OnImageAvailableListener. Выделяются буферы, система поднимает пайплайн.

  • Предзахват: автофокус/экспозиция/баланс. Ожидание сходимости AF/AE/AWB: 100–500 мс, в сложных условиях — больше.

  • Захват кадра (still). Выполняется разовый CaptureRequest по шаблону TEMPLATE_STILL_CAPTURE (CONTROL_CAPTURE_INTENT_STILL_CAPTURE) поверх текущего repeating-превью. Сессия не меняется. На момент кадра фиксируются AF/AE/AWB, возможна предвспышка или вспышка. Превью может кратко просесть.

  • Получение и сохранение. В onImageAvailable читается JPEG из ImageReader и пишется на диск.

  • Возврат результата в Flutter. Путь прилетает в Dart только после записи файла.

Итого: фото на флагманах занимает около 300–500 мс, на среднем сегменте (телефоны в районе 15 000 рублей) задержка легко достигает больше 1 с. И получается, что для сценариев, где требуется задержка до 4 fps, такой подход не годится.

Нужно было что-то менять. 

Шаг 1: startPreviewWithImageStream

Здесь идея не дёргать тяжёлый still-capture, а читать кадры из непрерывного превью.

На практике возникла проблема: на каждый кадр через MethodChannel уходят мегабайты. Уже при частоте 2–4 кадра в секунду мегабайты превращались в секунду — Flutter-приложение перегружается, устройство ощутимо нагревалось, появлялись лаги. 

Стало ясно, что сам источник кадров — поток превью — вполне ок, но обработку необходимо перенести на нативную сторону.

Шаг 2: свой frameStreamReader + LastFrameStore

Теперь будем хранить только последний кадр на нативной стороне и брать его синхронно по запросу.

frameStreamReader = ImageReader.newInstance(
    resolutionFeature.getPreviewSize().getWidth(),
    resolutionFeature.getPreviewSize().getHeight(),
    ImageStreamReader.computeStreamImageFormat(imageFormatGroup),
    4 // maxImages — баланс между backpressure и памятью
);

frameStreamReader.setOnImageAvailableListener(reader -> {
    Image image = null;
    try {
        image = reader.acquireLatestImage(); // отбросить старые, взять актуальный
        if (image == null) return;

        lastFrameStore.accept(image); // ВАЖНО: внутри корректно закрываем предыдущий
        image = null;                 // право владения ушло в хранилище
    } catch (Exception e) {
        if (image != null) try { image.close(); } catch (Exception ignore) {}
        Log.e(TAG, "onImageAvailable", e);
    }
}, backgroundHandler);

acquireLatestImage() отбрасывает старые буферы и отдаёт актуальный. Рекомендация: всегда закрывать Image, иначе выйдет W/ImageReader_JNI: Unable to acquire a buffer item...

class LastFrameStore {
    private Image lastImage;

    synchronized void accept(Image image) {
        if (lastImage != null) lastImage.close(); // обязательно закрыть старый
        lastImage = image;                         // «последний выигрывает»
    }

    synchronized Image take() {
        Image result = lastImage;
        lastImage = null;
        return result; // ответственность закрыть — на вызывающем
    }
}

Шаг 3: LastFrameStore + C-плагин

Итак, конвертация в JPEG делалась в C-плагине, но мегабайты всё ещё летели через MethodChannel. В таком варианте синхронность давала 150–200 мс/кадр при 4 FPS на длинной дистанции.

Значит, убираем тяжёлую конвертацию из Dart и массивные передачи по каналу.

Финальный подход: всё нативно

После серии экспериментов мы пришли к финальной реализации, в которой вся тяжёлая работа выполняется на нативной стороне (Java). Вместо того чтобы передавать мегабайты данных через MethodChannel, мы:

  • Берём актуальный Image из LastFrameStore;

  • Преобразуем его в YuvImage и сжимаем в JPEG с помощью compressToJpeg;

  • Сохраняем результат во временный файл;

  • Возвращаем во Flutter только путь к файлу.

Такой подход позволил добиться стабильной частоты съёмки около 4 кадров в секунду, при этом не перегружая Dart и канал связи. 

Итого: про эволюцию подходов и результаты

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

  • takePicture() — даёт качество с полной обработкой ISP и EXIF, но жизненный цикл тяжёлый, а задержки достигают сотен миллисекунд.

  • startPreviewWithImageStream — обеспечивает непрерывный поток кадров (источник без still-capture), но перегружает канал MethodChannel. 

  • frameStreamReader + LastFrameStore + C-плагин — ускоряет обработку, но остаётся узкое место: синхронность и передача данных через канал.

  • Улучшенный LastFrameStore (Java) — вся обработка на native, Flutter получает только путь к JPEG. Это даёт стабильную производительность и минимальные задержки ~20–30 мс/кадр.

Финальный подход показал отличные метрики:
  • Среднее время обработки кадра: ~12.5 мс 

  • 99-й перцентиль: ~47 мс 

  • Максимум: 54 мс 

  final filename = '$name.jpg';

       final logTag = ' TakePhotoUseCase filename $filename:';

       final file = File('${AppDirectory.tracksDirectory.path}/$filename');

       final sw = Stopwatch()..start();

       await _cameraController!.capturePreviewFrameJpeg(

         file.path,

         rotation: _rotationDegrees,

         quality: _jpegQuality,

       );

       sw.stop();

       logDebug('$logTag Take picture took ${sw.elapsedMilliseconds} ms');

Для наглядности мы сравнили финальный подход с базовой реализацией takePicture() из стандартного плагина камеры.

Вот результаты замеров на первых 200 кадрах:
  • Среднее время: ~2065 мс 

  • 99-й перцентиль: ~3457 мс 

  • Максимум: 3476 мс

  final filename = '$name.jpg';

       final logTag = ' TakePhotoUseCase filename $filename:';

       final file = File('${AppDirectory.tracksDirectory.path}/$filename');

       final sw = Stopwatch()..start();

       final tempFile = await _cameraController!.takePicture();

       await tempFile.saveTo(file.path);

       sw.stop();

       logDebug('$logTag Take picture took ${sw.elapsedMilliseconds} ms');

Выводы

Оптимизация съёмки кадров в Flutter — это всегда компромисс между качеством, скоростью и сложностью реализации. Перенос всей обработки на нативную сторону позволил нам добиться стабильной частоты съёмки до 4 кадров в секунду с минимальной задержкой и без перегрузки UI-потока. Такой подход отлично подойдёт для приложений, где важна скорость получения изображений: от AR и CV до трекинга и аналитики в реальном времени.

Для удобства Арген оформил решение в виде отдельной библиотеки camera_frame — ей можно свободно пользоваться!

Комментарии (6)


  1. VBDUnit
    22.10.2025 19:54

    Интересно получилось, и довольно контринтуитивно. Кажется, что кодирование H264 аппаратно ускоряется, и проще его снимать и сразу стримить на серв, попутно впихивая в пакеты данных инфу с GPS и всяких акселерометров. Никогда бы не подумал, что сохранение фоток в jpeg будет оптимальнее. Но практика — критерий истины, как говорится.

    Делал в своё время очень тяжелое ПО под реалтайм обработку видео, но на десктопе (кстати там есть возможность писать видео в PNG/JPG сиквенс). Работа с устройствами захвата видео/аудио всегда сопровождалась даже не танцами с бубном, а целым карнавалом. Особенно если это устройства из разных миров (какой‑нибудь SDI Blackmagic, вебкамера и 1394, и все, естественно, снимают в разных форматах — вообще туши свет).

    И мне как‑то раньше казалось, что в мобильных ОС учли весь этот опыт, и там должно всё быть как‑то цивилизованнее. В конце концов, там камера является неотъемлемой частью системы.

    Но прочитав статью, я понял, что там те же грабли, только потехнологичнее :)


  1. yourich
    22.10.2025 19:54

    О каком разрешении идет речь?


    Лет 10 назад разработал небольшое приложение под Android, которое решает очень похожую задачу: забирает картинки с камеры и записывает их в JPEG-файлы (плюс их таймстемпы). Работало примерно так: один поток, принимая callback от камеры, копировал raw RGB-картинку (или YUV, не помню точно) в промежуточный буфер; другой поток параллельно забирал эти фреймы из буфера, кодировал их в JPEG и сохранял в файлы. Такое приложение на не самых мощных смартфонах тех времен (например на https://ru.wikipedia.org/wiki/HTC_One_Mini), работая с разрешением фреймов 640x480, выдавало от 20 до 30 кадров в секунду. Интересно, что FPS падал из-за низкого уровня освещения: в этом случае камера автоматически выставляла нужную экспозицию, увеличивая выдержку. Но и экспозицию в Android SDK можно зафиксировать, получая в итоге стабильные FPS.
    Да, и сохранение в JPEG было сделано просто из-за лени, по идее нужно сжимать кадры в видео при помощи H264 (или H265, AV1), благо аппаратная поддержка кодирования видео встроена сейчас в каждый утюг: так вы получите гораздо более хорошее качество при гораздо меньшем объеме занимаемого места.