Андроид всё еще не готов к RAW-видео

от автора

Уверен, что многих возмутит уже самоназвание этой статьи. А некоторые сразу же побегут в комментарии указывать на приложение, которое «смогло». Но не стоит спешить, друзья! Сегодня вам предстоит увлекательное путешествие по стыку технологий, кода и технических решений, которые и расскажут вам то, о чем адепты съемки мобильного RAW-видео предпочитают не говорить.

Я разберу лишь основные моменты, которые и убедили меня в том, что эффективная съемка RAW‑видео на Андроид на сегодняшний день невозможна без »костылей» и ухищрений. Костылей, которые нивелируют все те преимущества RAW, которые так жаждут получить на своих смартфонах видеографы. Ухищрений, которые по итогу делают менее ресурсоемкие форматы записи видео на смартфоне даже более эффективными и качественными, чем RAW.

Да, будет интересно!

А еще для демонстрации и подтверждения общих положений я опубликую рабочий код на языке Java. Чтобы повторить описанное или даже что-то улучшить, вам нужно знать Java и уметь читать документацию Android.

Но сначала представлюсь

Меня зовут Александр Трофимов, я программист и энтузиаст мобильной видеографии. А еще я разработчик уже довольно известного приложения профессиональной видеосъемки для Андроид-смартфонов mcpro24fps.

Что побудило меня написать эту статью?

Прежде всего, желание показать реальное положение вещей. Ведь сами понятия RAW-видео, как и Log-видеоперекочевали на смартфоны из мира «взрослых» и больших камер. А маркетологи приложили все усилия к тому, чтобы пользователь считал, что его смартфон за 300 долларов уже давно снимает лучше профессиональной камеры за 300 тысяч долларов.

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

С момента написания предыдущих статей (тык), (тык) и (тык) мне удалось существенно прокачать Log-съемку и все, что с ней связано. В том числе, дать возможность адаптировать Log гамма‑кривые от «взрослых» камер с большими сенсорами под маленькие сенсоры каждого конкретного смартфона.

И вот, казалось бы, эффективность Log на смартфоне доведена до предела, но..

ВСЕ ХОТЯТ ещё и RAW!

Почему? А потому что:
«У меня вообще‑то флагман, я деньги заплатил!»
«А у меня восемнадцать ядер в телефоне, внешний аккумулятор и карта памяти внешняя — должен всё тянуть!»
Ну и так далее..

Говоря о самых мощных процессорах, стопитсотмегапиксельных сенсорах многие как‑то забывают об ограничениях наших с вами компактных Андроид‑смартфонов. Ограничениях, которые мы и разберем подробно далее..

Что же такое RAW?

И начнем мы с самого простого — определения того, как выглядит RAW‑видео на взрослых камерах, какие стандарты существуют. И здесь негде разбежаться. Их всего два:

  1. Старый добрый ZIP с бесконечным количеством кадров в формате DNG.

  2. MXF контейнер, с метаданными CinemaDNG и «колбасой» данных в формате DNG. Преимущество этого формата заключается в том, что здесь можно указать много всяких метаданных, включая скорость кадров, которые понимают монтажные программы.

Всё. Остальные подходы — это костыли и уловки, к которым индустрия видео не приучена. А значит, монтажные приложения не будут это поддерживать.

Погружаемся в код

Сейчас будет много кода и пояснений к нему. Те, кто не очень умеет в код, могут сразу переходить к разделу с практическими экспериментами!

Очень хотелось бы воспользоваться контейнером MXF, но, похоже, для этого придется писать свой Muxer, поддерживающий этот контейнер. Для этого надо прочитать документацию, понять ее, и правильно реализовать. На это надо достаточно много времени, а ниже мы поймем, что и это нас бы не спасло (хотя надежда остается до момента ее «убийства»). Отбрасываем этот вариант и возвращаемся к классике, которой пользуются разные видео‑камеры среднего ценового сегмента.

И так, наша задача выглядит проще некуда. Разложим ее по шагам.

  1. Взять сырые данные с сенсора камеры.

  2. Сформировать на их основе файл DNG.

  3. Положить DNG в ZIP.

Шаг первый.

Запускаем сессию захвата, где хотя бы одна поверхность (Surface) настроена на RAW_SENSOR. RAW_SENSOR есть почти у всех, и этот 16-битный формат сразу готов для работы с нативным DNGCreator. Можно не заморачиваться о том, сколько бит выдает сенсор. Для того, чтобы сессия захвата могла сконфигурировать нужную нам поверхность, мы будет использовать ImageReader.

rawImageReader = ImageReader.newInstance(rawResolution.getWidth(),    rawResolution.getHeight(), ImageFormat.RAW_SENSOR, 2);

rawResolution это поддерживаемое разрешение, взятое из системной информации.
Для размера буфера взято всего 2 кадра, потому что нас не интересует отсрочка проблемы производительности. Если есть проблема, мы хотим ее увидеть сразу.
ImageReader готов, теперь надо добавить OnImageAvailableListener, чтобы получать кадры и иметь возможность обработать их. Сначала самый простой вариант:

ImageReader.OnImageAvailableListener listener = r -> {  Image i = null;  if (!RECORDING_STARTED) {    try {      i = r.acquireLatestImage();    } finally {      if (i != null)        try {          i.close();        } finally {          i = null;        }    }    return;  }  try {    i = r.acquireNextImage();  } catch (IllegalStateException e) {    e.printStackTrace();    i = null;  } finally {    if (i != null)      try {        i.close();      } finally {        i = null;      }  }};mRAWImageReader.setOnImageAvailableListener(listener, handler);

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

Шаг второй.

DngCreator dngCreator = new DngCreator(cameraCharacteristics, captureResult);dngCreator.writeImage(outputStream, i);

Создаем DNGCreator на основе характеристик камеры и результата захвата. Но… откуда у нас результат захвата? А ни от куда. У нас его нет, мы должны его получить, где-то временно сохранить, и выдать его при создании файла DNG.

Как это сделать? Очевидно, нужен кеш. Для этого мы будем использовать LruCache, где Long это таймкод кадра, а CaptureResult результат захвата в onCaptureCompleted в функции обратного вызова сессии захвата. LruCache это кеш, который имеет ограниченное количество элементов, что защищает нас от утечки памяти.

if (RECORDING_STARTED) {  rawTime = result.get(CaptureResult.SENSOR_TIMESTAMP);  if (rawTime != null) {    сaptureResultsCache.put(rawTime, result);  }}

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

long timestamp = i.getTimestamp();captureResult = captureResultsCache.get(timestamp);

Казалось бы, вот оно, осталось только сохранить/упаковать в ZIP готовый файл. Но нет. У нас две проблемы:

  1. CaptureResult в onCaptureCompleted приходит позже, чем приходит кадр в OnImageAvailableListener.

  2. DNG может создаваться так долго, что мешает сессии захвата, опуская скорость кадров почти до нуля и в конце концов вешая приложение.

Чтобы решить первую проблему, нам, очевидно, нужен кеш для кадров, из которого мы будем пытаться получить кадр при получении CaptureResult в onCaptureCompleted. Для кеша мы можем использовать LruCache, но этим мы только усугубим проблему 2, потому что Image, а с ним и буфер кадра, будет заблокирован до момент, пока не будет прочитан из кеша и закрыт. Поэтому мы пойдем путем решения обеих проблем одновременно. Перво-наперво нам надо как можно быстрее освободить Image, закрыть его. Также всю обработку DNG надо вынести в отдельную ветку. Для этого мы будем получать ByteBuffer из Image, конвертировать его в byte[], и сохранять в кеш, тут же освобождая Image через close(); Для того, чтобы в кеш можно было сохранить дополнительные данные размера кадра и CaptureResult (так будет удобней), мы создаем свой класс объекта DngPacket.

public class DngPacket {  final byte[] dngData;  final Size size;  final long timestamp;  CaptureResult result = null;  DngPacket(byte[] dngData, Size size, long timestamp) {    this.dngData = dngData;    this.timestamp = timestamp;    this.size = size;  }  DngPacket(byte[] dngData, Size size, long timestamp, CaptureResult result) {    this.dngData = dngData;    this.timestamp = timestamp;    this.size = size;    this.result = result;  }}

Для отдельных веток мы используем ExecutorService mExecutor и execute();

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

dngQueue = new LinkedBlockingQueue<>(4);

И создаем ветку, в которой эта очередь будет читаться и обрабатываться.

dngWriterThread = new Thread(() -> {  try {    while (RECORDING_STARTED || dngQueue.isEmpty()) {      DngPacket packetOriginal = null;      try {        packetOriginal = dngQueue.poll(300, TimeUnit.MILLISECONDS);      } catch (InterruptedException e) {        Thread.currentThread().interrupt();        break;      }      if (packetOriginal != null) {        final DngPacket packet = packetOriginal;        if (mExecutor != null && !mExecutor.isShutdown()) {          mExecutor.execute(() -> {            try {              ByteArrayOutputStream byteArrayOutputStream =                  new ByteArrayOutputStream();              DngCreator dngCreator =                  new DngCreator(mCameraCharacteristics, packet.result);              dngCreator.writeByteBuffer(byteArrayOutputStream, packet.size,                  ByteBuffer.wrap(packet.dngData), 0);              try {                dngCreator.close();              } finally {                //              }              byte[] dngBytes = byteArrayOutputStream.toByteArray();              try {                zipQueue.offer(new DngZipPacket(dngBytes,                    packet.timestamp + ".dng")); // да, здесь снова очередь, но                                                 // теперь для сохранения в ZIP.              } catch (Exception e) {              }            } catch (IOException e) {            }          });        }      }      if (!RECORDING_STARTED && dngQueue.isEmpty()) {        dngQueue.clear();        break;      }    }  } finally {    //  }}, "DNGWriterThread");dngWriterThread.start();

Здесь мы снова используем mExecutor, чтобы создание DNG происходило в отдельной ветке и не блокировало ветку получения данных из очереди.

Еще наблюдательные могут заметить новый объект DngZipPacket. Это тоже отдельный класс для более простой передачи в очередь и последующего чтения.

public class DngZipPacket {  final byte[] dngData;  final String entryName;  DngZipPacket(byte[] dngData, String entryName) {    this.dngData = dngData;    this.entryName = entryName;  }}

В результате получаем такой setOnImageAvailableListener.

ImageReader.OnImageAvailableListener listener = r -> {  Image i = null;  if (!RECORDING_STARTED) {    try {      i = r.acquireLatestImage();    } finally {      if (i != null)        try {          i.close();        } finally {          i = null;        }    }    return;  }  if (zipOutputStream == null) {    stopRAWRecording(); // функция для остановки записи    return;  }  try {    i = r.acquireNextImage();  } catch (IllegalStateException e) {    e.printStackTrace();    i = null;    return;  }  if (i == null || i.getFormat() != ImageFormat.RAW_SENSOR) {    return;  }  final Image rawImage = i;  if (mExecutor != null && !mExecutor.isShutdown()) {    mExecutor.execute(() -> {      try {        if (сameraCharacteristics == null) {          rawImage.close();          return;        }        long timestamp = rawImage.getTimestamp();        Size size = new Size(rawImage.getWidth(), rawImage.getHeight());        byte[] bytes =            new byte[rawImage.getPlanes()[0].getBuffer().remaining()];        rawImage.getPlanes()[0].getBuffer().get(bytes);        try {          rawImage.close();        } finally {          //        }        CaptureResult captureResult = captureResultsCache.get(timestamp);        if (captureResult == null) {          // если не находим CaptureResult, сохраняем кадр в кеш          mDNGCache.put(timestamp, new DngPacket(bytes, size, timestamp));          return;        }        dngQueue.offer(new DngPacket(bytes, size, timestamp, captureResult))      } catch (Exception e) {        e.printStackTrace();      }    });  }};

Отдельное внимание хочу обратить на dngQueue.offer. Мы используем offer вместо put, потому что put ждет, пока освободится место в очереди, чем блокирует функцию. offer пытается вставить элемент в очередь, но если места нет, просто откидывает его. Нам нет смысла пытаться впихнуть все. Если производительности не хватает, то так тому и быть.

Кеш для DNG без CaptureResult выглядит так.

LruCache<Long, DngPacket> mDNGCache;mDNGCache = new LruCache<>(4);

Все RAW буферы, у которых нашлись данные CaptureResult в кеше captureResultsCache отправляются в очередь на создание DNG и последующее сохранение в ZIP.

Теперь мы вспоминаем, для чего вообще нам был нужен кеш для DNG. Для того, чтобы реагировать в ситуации, когда CaptureResult приходит позже кадра. Для этого мы редактируем код в onCaptureCompleted.

if (RECORDING_STARTED) {  rawTime = result.get(CaptureResult.SENSOR_TIMESTAMP);  if (rawTime != null) {    DngPacket packet = mDNGCache.get(rawTime);    if (packet != null) {      packet.result = result;      try {        dngQueue.offer(packet);      } catch (Exception e) {        //      }      mDNGCache.remove(rawTime);    } else {      captureResultsCache.put(rawTime, result);    }  }}

На этом второй шаг завершается. У нас получилось временно сохранить RAW-данные, данные CaptureResult и создать DNG file, не мешая сессии работать.

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

Шаг третий.

Нам осталось запустить процесс складывания файлов в ZIP. Для этого мы создаем очередь для DNG файлов и отдельную ветку, в которой файлы будут подготавливаться и сохраняться в ZIP.

LinkedBlockingQueue zipQueue;zipQueue = new LinkedBlockingQueue<>(4);

Для работы с ZIP мы используем ZipOutputStream zipOutputStream.

// открываем стрим файлаOutputStream stream = resolver.openOutputStream(fileUri);// оборачиваем его в BufferStream, чтобы данные скидывались не сразу, а// чуть-чуть накапливалисьFileOutputStream bos = new BufferedOutputStream(stream);// оборачиваем в BufferStream в ZipOutputStreamzipOutputStream = new ZipOutputStream(bos);// Настройки, чтобы не происходило сжатияzipOutputStream.setMethod(ZipOutputStream.STORED);zipOutputStream.setLevel(Deflater.NO_COMPRESSION);

А дальше запускаем ветку для работы с ZipOutputStream.

zipWriterThread = new Thread(() -> {  try {    while (RECORDING_STARTED || !zipQueue.isEmpty()) {      DngZipPacket packet = null;      try {        packet = zipQueue.poll(300, TimeUnit.MILLISECONDS);      } catch (InterruptedException e) {        Thread.currentThread().interrupt();        break;      }      if (packet != null) {        try {          ZipEntry entry = new ZipEntry(packet.entryName);          entry.setSize(packet.dngData.length);          entry.setCompressedSize(packet.dngData.length);          CRC32 crc = new CRC32();          crc.update(packet.dngData);          entry.setCrc(crc.getValue());          zipOutputStream.putNextEntry(entry);          zipOutputStream.write(packet.dngData, 0, (int) packet.dngData.length);          zipOutputStream.closeEntry();        } catch (IOException e) {        }      }      if (!RECORDING_STARTED && zipQueue.isEmpty()) {        break;      }    }  } finally {    try {      if (zipOutputStream != null) {        zipOutputStream.finish();        zipOutputStream.close();      }    } catch (IOException e) {    }    zipOutputStream = null;  }}, "ZipWriterThread");zipWriterThread.start();

Обращаю внимание, что мы для сохранения в ZIP не используем многопоточность, чтобы не было соревнования за создание записей. Каждый пакет должен быть сохранен отдельно только со своими данными. Нельзя, чтобы после putNextEntry туда вписалось несколько DNG-файлов.

Выше мы установили настройки «без сжатия», потому что это самые легкие настройки для процессора. Для начала нам и этого достаточно. А потом окажется, что это единственно возможное.

Вот и весь механизм записи RAW-видео, как это делают «взрослые» камеры. Давайте повторим его уже на устройствах.

А теперь непосредственно к опытам!

Для проведения опытов я взял несколько достаточно свежих и мощных смартфонов на OC Android: Samsung S24 Ultra, Xiaomi 14 Ultra, Sony Xperia 5 mk IV и Samsung S25 Ultra.

Размер одного RAW буфера — это 24-25 Мегабайт при примерном разрешении сенсора 12 Мп.

Запускаем эксперимент и что мы видим:

  • Samsung S24 Ultra не справляется с задачей даже при скорости 24 к/с.

  • Xiaomi 14 Ultra при скорости 24 к/с не справляется вовсе (хотя под “справляется” мы даже допускаем наличие нескольких выпавших кадров).

  • Sony Xperia 5 IV вообще роняет скорость до 10-13 кадров в секунду, т.е. тоже не справляется.

Напомню, эти девайсы уж точно не назовешь слабыми. Но они не справляются с записью RAW-видео.

А теперь давайте посмотрим более детально на то, как в моем тесте проявил себя один из флагманов этого года — Samsung S25 Ultra.

Начинается все просто прекрасно, однако когда размер файла приближается к 5 — 8 Гб, что в эквиваленте всего 10 секунд записи, флагманская карета превращается в тыкву.

На старте:

  • создание DNG 20-25 мс

  • запись в ZIP-файл 10-15 мс

Уже видно, что суммарно весь процесс занимает больше 33 мс, необходимых для бесперебойной работы. Дальше происходит накопительный эффект, и время обработки существенно меняется:

  • создание DNG 40-50 мс

  • запись в ZIP-файл 10-15 мс.

Выводы из опытов

Опыты показали, что сжать файл на лету задача сложная, и с ней не справился ни один из испытуемых аппаратов. И если даже премиальные флагманы из мира Андроид не справляются с этой задачей, очевидно, что Андроид все еще не готов к съемке RAW‑видео.

А как же оптимизация (костылизация)?

Была у меня мысль о том, что некоторые девайсы умеют выдавать RAW10, и это было бы неплохим подспорьем для оптимизации процесса и позволило бы существенно снизить размер одного кадра: с 25 Мб до 15 Мб. Но оказалось, что нативный DNGCreator работает только с 16-битным RAW_SENSOR.

В остальном же методы оптимизации очевидны.

Сначала мы должны срезать пустые биты, т.к. большинство сенсоров у нас 10-битные, то 6 старших бит можно отрезать.

В случае, если система поддерживает RAW12, я бы задумался о том, чтобы отрезать только 4 бита.

Следующий шаг — это использование кропа. Можно уменьшить кадр или усреднить через складывание значений и получить на выходе 1080p вместо 2160p. Но для всего этого придется придумывать свой контейнер, который впоследствии должен иметь свой распаковщик для Windows и Mac.

Но всё это усложняет задачу в разы, а заодно и подводит нас к еще одному важному выводу:

Трудозатраты на оптимизацию и та разница в качестве, которую дает RAW в сравнении с правильно снятым YUV (h265/h264), не говорят в пользу RAW.

А если вспомнить, что мы все же говорим о мобильных устройствах, в которых крайне важным ресурсом являются и место на диске, и расход батареи, да и возможность снимать на Андроид‑смартфоны не за все деньги мира — то съемка в Log гамма‑кривых оказывается интереснее во всех отношениях.

Доверяем, но проверяем!

В свежем обновлении видеокамеры mcpro24fps я решил открыть доступ к данной функции в режиме Лаборатории для всех пользователей. За 2 месяца работы над RAW-видео параллельно написанию статьи мне удалось внедрить некоторые оптимизации и улучшения в код. Все, кто не хочет писать свое собственное приложение, могут самостоятельно активировать Запись RAW и провести все эксперименты прямо на своем Андроид смартфоне. Правда, для этого придется поддержать нас покупкой приложения, к примеру, в Google Play или RuStore.

Важно понимать: работоспособность всех функций в режиме Лаборатории не гарантирована. Со временем они могут исчезнуть из приложения, видоизмениться или же потребовать дополнительной оплаты (In-app покупки, подписка). Активируете их исключительно под вашу ответственность.

Вместо послесловия

Спасибо, что дочитали! В комментариях я готов прочитать всё, что только придет вам в голову: от критики моего мнения, до каких-либо решений, касаемо оптимизации записи RAW-видео. Не обещаю, что ваши предложения и идеи будут использованы в дальнейшем, но они могут навести на интересные мысли.

ссылка на оригинал статьи https://habr.com/ru/articles/947578/