#はじめに
冬休みにThetaの360°画像をぼかすWebアプリSphereBlur.comを作った際に、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://jsbin.com/tovowo/edit?js,output
#ソースコード解説
まず、ローカルにあるファイルをJavaScriptで読み込むために、<input type="file">
エレメントを使って得られたファイルからFileReader.readAsArrayBuffer()
を使ってArrayBufferに読み込む。
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()
で先頭を確認する。
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バイト読んでいる。
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
で始まる。
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でパースする。
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の葉の要素だけをリストにして返している。
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()
で、必要となる姿勢情報だけを取り出して返している。
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