Первая дошедшая до нас фотокарточка была чёрно-белой и размытой. Потом в фотографию пришла резкость. Позже – цвет. Ещё один шаг вперёд – цифра. Популярность и распространение «светописи» постоянно росли и растут. Вот уже и коты делают селфи. Что дальше? А дальше (вернее – прямо сейчас) цифровые снимки, которые, помимо миллионов цветных точек, хранят информацию о глубине запечатлённого на них пространства.



Это открывает потрясающие возможности. Среди них – эффекты движения, такие, как параллакс и «наезд-отъезд». В «глубинах» снимков таятся новые подходы к художественным фильтрам, к настройке резкости, к редактированию изображений, к измерениям по фото. И это – только начало.

Сегодня мы поговорим о JavaScript-реализации парсера фотографий с поддержкой глубины. Он работает с графическими файлами формата eXtensible Device Metadata (XDM), извлекая из них встроенные метаданные и сохраняя полученные материалы в виде XML-файлов. Кроме того, программа умеет извлекать из XML сведения о цвете и глубине пространства. В результате, на выходе получаются XML-файлы, цветные изображения и файлы карт глубины.

Если вам не терпится попробовать в деле то, о чём пойдёт речь ниже, взгляните на Depthy, наш проект с открытым исходным кодом. В нём задействован описываемый анализатор фотографий. Практика показала, что свою работу он делает качественно и быстро.

Прежде чем рассматривать код, остановимся на формате XDM.

Формат XDM


На вход скрипта подаются XDM-файлы. В формате XDM метаданные хранятся в изображениях-контейнерах, при этом изображения совместимы с существующими приложениями для просмотра графики. Этот формат разработан для технологии Intel RealSense. Метаданные содержат технические сведения. А именно, это карта глубины, пространственное положение устройства и камеры, модель перспективы объектива, информация о производителе оборудования, облако точек. Вот, как выглядит цветное изображение (справа) и соответствующая ему карта глубины в формате XDM (слева), которая хранится в файле изображения в виде метаданных.


Цветное изображение и его карта глубины

Данные формата XDM нужно как-то интегрировать в файл-контейнер. Для этого используется стандарт Adobe XMP.

Стандарт Adobe XMP


Сейчас спецификация XDM предусматривает использование графических файлов-контейнеров четырёх форматов: JPEG, PNG, TIFF и GIF. Метаданные XDM сериализуются и внедряются в графический файл-контейнер. Способ хранения метаданных основан на стандарте Adobe Extensible Metadata Platform (XMP). Рассматриваемое здесь приложение рассчитано на использование контейнеров в формате JPEG. Кратко остановимся на том, как XMP-метаданные встраиваются в JPEG-файлы, и на том, как программа обрабатывает XMP-пакеты.

В стандарте XMP фрагменты данных маркируются 2-х байтовыми последовательностями. Маркеры типа 0xFFE0–0xFFEF обычно используются для данных приложений. Их имена имеют вид APPn. Такие маркеры принято начинать со строки, описывающей их назначение. Это – так называемая строка пространства имён или строка подписи. Маркер APP1 идентифицирует метаданные Exif и TIFF. Кодом APP13 маркируют данные формата Photoshop Image Resources. Они содержат IPTC-метаданные. На расположение XMP-пакета или пакетов указывают ещё один или несколько APP1-маркеров.

Вот как выглядит запись формата StandardXMP в JPEG-файле.

Поля записи формата StandardXMP
Смещение, байт
Длина, байт
Значение
Имя
Комментарии
0
2
0xFFE1
APP1
Маркер APP1 указывает на раздел метаданных
2
2
2 + 29 + длина XMP пакета
Lp
Размер в байтах, равный сумме размеров этого раздела и двух следующих
4
29
Строка ASCII без кавычек, заканчивающаяся нулевым символом
namespace
URI пространства имён XMP, используется как уникальный идентификатор: ns.adobe.com/xap/1.0
33
< 65503
XMP-пакет
Обязательно использование кодировки UTF-8

Если после сериализации размер XMP-пакета оказывается больше, чем 64 Кб, его можно разделить на части и сохранить эти части в нескольких местах JPEG-файла. А именно, при таком подходе данные пакета будут представлены главным (StandardXMP) и расширенным (ExtendedXMP) сегментами. ExtendedXMP использует тот же формат записи, что и StandardXMP. Единственное исключение – в поле, хранящем сведения о пространстве имён (namespace), указывается http://ns.adobe.com/xmp/extension/.

Вот, как выглядят данные XMP-пакета, внедрённые в JPEG-файл в виде записей форматов StandardXMP и ExtendedXMP.


Записи форматов StandardXMP и ExtendedXMP в JPEG-файле

Рассмотрим три функции.

  • Функция findMarker анализирует JPEG-файл в поиске маркера 0xFFE1, начиная с заданной позиции. Содержимое файла представлено параметром функции buffer, позиция – параметром position. Если маркер найден – функция вернёт его адрес, если не найден – значение -1.

  • Функция findHeader занимается поиском пространств имён StandardXMP (http://ns.adobe.com/xap/1.0/) и ExtendedXMP (http://ns.adobe.com/xmp/extension/) в JPEG-файле. Ей передаются, опять же, буфер с данными файла (buffer) и позиция, с которой надо начинать поиск (position). Если совпадение найдено – функция вернёт строку, соответствующую обнаруженному пространству имён. Если нет – будет возвращена пустая строка.

  • Функция findGUID занимается поиском GUID, который хранится в элементе xmpNote:HasExtendedXMP в JPEG-файле (параметр buffer), начиная с переданного ей места в файле (position) и заканчивая позицией в файле, вычисляемой как position+zize-1. Найдя искомый элемент, она возвращает его адрес.

Вот код этих функций.

// Возвращает позицию в файле (buffer),в которой содержится маркер 0xFFE1, начиная поиск с заданного места (position)
// Возвращает -1, если совпадений не найдено
function findMarker(buffer, position) {
    var index;
    for (index = position; index < buffer.length; index++) {
        if ((buffer[index] == marker1) && (buffer[index + 1] == marker2))
            return index;
    }
    return -1;
}

// Возвращает строку, указывающую на пространство имён, либо – пустую строку, если ничего не найдено.
 function findHeader(buffer, position) {
    var string1 = buffer.toString('ascii', position + 4, position + 4 + header1.length);
    var string2 = buffer.toString('ascii', position + 4, position + 4 + header2.length);
    if (string1 == header1)
        return header1;
    else if (string2 == header2)
        return header2;
    else
        return noHeader;
}

// Возвращает адрес GUID
function findGUID(buffer, position, size) {
    var string = buffer.toString('ascii', position, position + size - 1);
    var xmpNoteString = "xmpNote:HasExtendedXMP=";
    var GUIDPosition = string.search(xmpNoteString);
    var returnPos = GUIDPosition + position + xmpNoteString.length + 1;
    return returnPos;
}

128-битный GUID хранится в виде 32-байтовой шестнадцатеричной ASCII-строки в каждом сегменте ExtendedXMP, за пространством имён. Он же хранится и в StandardXMP-сегменте, как значение свойства xmpNote:HasExtendedXMP. Благодаря этому мы можем обнаруживать неподходящие или изменённые ExtendedXMP-сегменты.

XML


Метаданные формата XMP можно внедрять непосредственно в XML-документы. В соответствии со спецификацией XDM, структуру данных XML можно задать так, как показано в таблице.

XML-представление XMP-данных


Графический файл содержит вышеописанные элементы в формате RDF/XML. Нужно отметить, что изображение-контейнер является внешним, по отношению к XDM-данным, объектом. Оно остаётся совместимым с обычными приложениями для просмотра графики, не поддерживающими XDM.

Вот фрагмент кода, в котором продемонстрировано ядро парсера. Именно здесь осуществляется анализ входного JPEG-файла, поиск APP1-маркера 0xFFE1. Если маркер найден, выполняется поиск строковых представлений пространств имён StandardXMP и ExtendedXMP. Если найдено первое, вычисляется размер метаданных и их начальный адрес, данные извлекаются и создаётся XML-файл StandardXMP. Если найдено второе, процедура повторяется, но формируется уже XML-файл ExtendedXMP. На выходе приложения оказываются два XML-файла.

// Главная функция для разбора XDM-файла
function xdmParser(xdmFilePath) {
 try {
     //Получаем размер JPEG-файла в байтах
     var fileStats = fs.statSync(xdmFilePath);
     var fileSizeInBytes = fileStats["size"];

     var fileBuffer = new Buffer(fileSizeInBytes);

        //Получаем дескриптор JPEG-файла
     var xdmFileFD = fs.openSync(xdmFilePath, 'r');

     //Читаем JPEG-файл в двоичный буфер
     fs.readSync(xdmFileFD, fileBuffer, 0, fileSizeInBytes, 0);

     var bufferIndex, segIndex = 0, segDataTotalLength = 0, XMLTotalLength = 0;
     for (bufferIndex = 0; bufferIndex < fileBuffer.length; bufferIndex++) {
         var markerIndex = findMarker(fileBuffer, bufferIndex);
         if (markerIndex != -1) {
                // Найден маркер 0xFFE1
             var segHeader = findHeader(fileBuffer, markerIndex);
             if (segHeader) {
                 // Найден заголовок
                 // Если заголовок найти не удалось, ищем следующий такой маркер, а этот пропускаем
                    // segIndex начинается с 0, А НЕ с 1
                 var segSize = fileBuffer[markerIndex + 2] * 16 * 16 + fileBuffer[markerIndex + 3];
                 var segDataStart;

                 // 2-->segSize длиной 2-байта
                    // 1-->учтём последний 0 в конце заголовка, один байт
                 segSize -= (segHeader.length + 2 + 1);
                 // 2-->0xFFE1 длиной 2-байта
                 // 2-->segSize длиной 2 байта
                 // 1-->учтём последний 0 в конце заголовка, один байт
                 segDataStart = markerIndex + segHeader.length + 2 + 2 + 1;
                
                 if (segHeader == header1) {
                        // StandardXMP
                     var GUIDPos = findGUID(fileBuffer, segDataStart, segSize);
                     var GUID = fileBuffer.toString('ascii', GUIDPos, GUIDPos + 32);
                     var segData_xap = new Buffer(segSize - 54);
                     fileBuffer.copy(segData_xap, 0, segDataStart + 54, segDataStart + segSize);
                     fs.appendFileSync(outputXAPFile, segData_xap);
                 }
                 else if (segHeader == header2) {
                        // ExtendedXMP
                     var segData = new Buffer(segSize - 40);
                     fileBuffer.copy(segData, 0, segDataStart + 40, segDataStart + segSize);
                     XMLTotalLength += (segSize - 40);
                     fs.appendFileSync(outputXMPFile, segData);
                 }
                 bufferIndex = markerIndex + segSize;
                 segIndex++;
                 segDataTotalLength += segSize;
             }
         }
         else {
                // Больше маркеров нет, остановим цикл
             break;
         };
     }
 } catch(ex) {
  console.log("Something bad happened! " + ex);
 }
}

Вот фрагмент кода, который анализирует XML-файл и формирует цветное изображение и его карту глубины. Потом этими данными можно пользоваться для обработки фото с поддержкой глубины. Здесь всё очень просто. Функция xmpMetadataParser() ищет атрибут IMAGE:DATA и извлекает соответствующие ему данные в JPEG-файл. Получается цветное изображение. Если найдено несколько таких атрибутов, будет создано несколько JPEG-файлов. Кроме того, функция выполняет поиск атрибута DEPTHMAP:DATA и извлекает соответствующие данные в PNG-файл. Это и есть карта глубины. Если найдено несколько таких атрибутов, соответственно, создаётся несколько PNG-файлов. На выходе получаем один или несколько JPEG- и PNG-файлов.

// Обработка XMP-метаданных и поиск атрибутов, соответствующих цветным изображениям и картам глубины
function xmpMetadataParser() {
    var imageIndex = 0, depthImageIndex = 0, outputPath = "";
    parser = sax.parser();

    // Когда нужный атрибут найден, извлекаем данные
    parser.onattribute = function (attr) {
        if ((attr.name == "IMAGE:DATA") || (attr.name == "GIMAGE:DATA")) {
            outputPath = inputJpgFile.substring(0, inputJpgFile.length - 4) + "_" + imageIndex + ".jpg";
            var atob = require('atob'), b64 = attr.value, bin = atob(b64);
            fs.writeFileSync(outputPath, bin, 'binary');
            imageIndex++;
        } else if ((attr.name == "DEPTHMAP:DATA") || (attr.name == "GDEPTH:DATA")) {
            outputPath = inputJpgFile.substring(0, inputJpgFile.length - 4) + "_depth_" + depthImageIndex + ".png";
            var atob = require('atob'), b64 = attr.value, bin = atob(b64);
            fs.writeFileSync(outputPath, bin, 'binary');
            depthImageIndex++;
        }
    };

    parser.onend = function () {
        console.log("All done!")
    }
}

// Обработка XMP-метаданных
function processXmpData(filePath) {
    try {
        var file_buf = fs.readFileSync(filePath);
        parser.write(file_buf.toString('utf8')).close();
    } catch (ex) {
        console.log("Something bad happened! " + ex);
    }
}

Итоги


Итак, XDM-файлы разобраны, превращены в JPEG и PNG, в цветные изображения и карты глубины. Всё это сделано исключительно средствами нашего скрипта, без привлечения дополнительных библиотек. Хотите внедрить в свой веб-проект инструменты для обработки фото с поддержкой глубины? JavaScript-парсер, о котором мы рассказали, способен стать фундаментом, на котором подобные инструменты можно построить.

P.S. Пишете на Java и хотите обрабатывать фото с поддержкой глубины в своих проектах? Если так – значит вам сюда.

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


  1. xenohunter
    03.03.2016 01:31
    +6

    И, кроме постскриптума, ни слова про Java.


    1. dougrinch
      03.03.2016 15:52
      +1

      Ну так ведь JavaScript же.


      1. Milliard
        03.03.2016 17:38

        В тегах JAVA.


  1. coolspot
    03.03.2016 04:33

    Что-то depthy не работает в Firefox (показывает три треугольника снатянутой на них картинкой), а в Chromium просто чёрный экран вместо картинки.

    Ubuntu 14.04 LTS
    Chromium
    Intel Corporation Core Processor Integrated Graphics Controller


  1. kashey
    03.03.2016 08:02
    +1

    OSX — под "Хромами" практически не работает. FireFox — немного по другому не работает.
    Явно не правильно интепретируется Z.
    Пришлось смотреть с телефона (iOS) — и там это дело выглядит очень круто.
    PS: Тестировщиков на мыло!


  1. Busla
    03.03.2016 15:46

    По-моему это уже нездоровое увлечение XML: кодировать картинку в base64, чтобы хранить в XML, чтобы вложить в другую картинку. Почему просто не сделать расширение контейнера TIFF?