JavaScriptでThetaの360°JPEGファイルからXMPメタデータを取得

#はじめに
冬休みにThetaの360°画像をぼかすWebアプリ[SphereBlur.com](https://sphereblur.com)を[作った](http://qiita.com/horo/items/abb8bf40c23b21ce6e5b)際に、JPEG画像から姿勢情報を取得する必要があった。Thetaの開発者向けサイトによると、天頂補正情報は機種によってEXIFもしくはPhoto Sphere XMP Metadata準拠のXMPに保存されているとのこと。

> https://developers.theta360.com/en/docs/introduction/
>
>|                                 | EXIF | XMP |
>|:--------------------------------|:----:|:---:|
>| RICOH THETA                     | OK   | NG  |
>| RICOH THETA m15 / RICOH THETA S | OK   | OK  |

いろいろ調べたが、EXIFにどのような形式で保存されているのか分からなかったので、とりあえずXMPから姿勢情報を取得することにした。XMPの方は[こちら](https://developers.google.com/streetview/spherical-metadata)に詳細な情報が記載されている。

#デモ
**https://jsbin.com/tovowo/edit?js,output**

#ソースコード解説
まず、ローカルにあるファイルをJavaScriptで読み込むために、`<input type="file">`エレメントを使って得られたファイルから`FileReader.readAsArrayBuffer()`を使ってArrayBufferに読み込む。

```js
var fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/jpeg';
fileInput.name = 'files[]';
fileInput.addEventListener('change', function () {
  var fileReader = new FileReader();
  fileReader.onload = function(e) {
    var arrayBuffer = e.target.result;
    var poseInfo = getPoseInfo(arrayBuffer);
    if (!poseInfo) {
      alert('Failed to get the pose info.');
      return;
    }
    alert('heading: ' + poseInfo.heading + '\n' +
          'pitch: ' + poseInfo.pitch + '\n' +
          'roll: ' + poseInfo.roll);
  };
  fileReader.readAsArrayBuffer(fileInput.files[0]);
}, false);
document.body.appendChild(fileInput);
```

JPEGファイルは、`FFD8`で始まって、「2バイトのマーカー値、2バイトのセグメントサイズ値、セグメントの中身」というセグメントが複数個並んでいる。マーカー値が`FFDA`のセグメントまでくるとこれ以降にはイメージデータが入っているので、このマーカーが出るまで順番に読んでいく。
XMPのデータは`FFE1`というマーカーで始まるAPP1と呼ばれるセグメントに入っている。ただし、XMP以外にEXIFも別のAPP1セグメントに入っているので、`isXmp()`で先頭を確認する。

```js
function getPoseInfo(arrayBuffer) {
    var pos = 0;
    try {
      if (read2Bytes() != 0xFFD8)
        return null;
      while (pos + 4 < arrayBuffer.byteLength) {
        var marker = read2Bytes();
        var size = read2Bytes();
        if (marker == 0xFFDA)
          break;
        if (marker == 0xFFE1) {
          if (isXmp()) {
            var dom = getXmp(size);
            if (!dom)
              return null;
            var list = getLeafs(dom);
            return getHadingPitchRoll(list);
          }
        }
        pos += size - 2;
      }
    } catch (e) {
      console.error(e.toString());
    }
    return null;
    // 後略
}
```

`read2Bytes()`では、`Uint8Array`を使って`arrayBuffer`から2バイト読んでいる。

```js
    function read2Bytes() {
      var bytes =  new Uint8Array(arrayBuffer, pos, 2);
      pos += 2;
      return bytes[0] * 0x100 + bytes[1];
    }
```

XMPを格納したセグメントは`http://ns.adobe.com/xap/1.0/\0`で始まるので、それを確認する。ちなみに、EXIFは`Exif\0\0`で始まる。

```js
    function isXmp() {
      var bytes = new Uint8Array(arrayBuffer, pos, 29);
      var t = String.fromCharCode.apply(null, bytes);
      return t == 'http://ns.adobe.com/xap/1.0/\0';
    }
```

先頭の`http://ns.adobe.com/xap/1.0/\0`を読み飛ばした部分から、`<x:xmpmeta `で始まって`</x:xmpmeta>`で終わる領域を抜き出して、DOMParserでパースする。

```js
    function getXmp(size) {
      var xmpStr = String.fromCharCode.apply(
          null,
          new Uint8Array(arrayBuffer, pos + 29, size - 31));
      var startPos = xmpStr.search('<x:xmpmeta ');
      var endPos = xmpStr.search('</x:xmpmeta>');
      if (startPos == -1 || endPos == -1)
        return null;
      xmpStr = xmpStr.substr(startPos, endPos - startPos + 12);
      var dom = (new DOMParser()).parseFromString(
          xmpStr, 'text/xml');
      return dom;
    }
```

`getLeafs(node)`では、XMLの葉の要素だけをリストにして返している。

```js
    function getLeafs(node) {
      var list = [];
      if (node.childNodes.length == 1 &&
          node.childNodes[0].nodeType == 3) {
        return [[node.nodeName, node.childNodes[0].nodeValue]];
      }
      for (var i = 0; i < node.childNodes.length; ++i)
        list = list.concat(getLeafs(node.childNodes[i]));
      return list;
    }
```

`getHadingPitchRoll()`で、必要となる姿勢情報だけを取り出して返している。

```js
    function getHadingPitchRoll(list) {
      var heading = 0, pitch = 0, roll = 0;
      list.forEach(function(item) {
        if (item[0] == 'GPano:PoseHeadingDegrees')
          heading = item[1];
        if (item[0] == 'GPano:PosePitchDegrees')
          pitch = item[1];
        if (item[0] == 'GPano:PoseRollDegrees')
          roll = item[1];
      });
      return {heading: heading, pitch: pitch, roll: roll};
    }
```

#参考リンク
- RICOH THETA Developers: https://developers.theta360.com/
- Photo Sphere XMP Metadata: https://developers.google.com/streetview/spherical-metadata
- Adobe XMP standard: http://www.adobe.com/devnet/xmp.html