MPOファイルのヘッダを解析しJPEGに変換する

ニンテンドー3DSなどステレオ3Dカメラで撮影したMPO画像ファイルについての話の続きです。このファイルはJPEGが単純に複数枚連結したような構成になっており、先日の記事ではファイルの全バイト中からJPEGの開始数バイト(SOIなど)を探索することで切り分けました。

http://d.hatena.ne.jp/Schima/20110228/1298890341

今回は、マルチピクチャフォーマットのヘッダを解析することで切り分けてみます。

1. マルチピクチャフォーマットの仕様

再度リンクを示しておきます。まずはこれを熟読。マルチピクチャフォーマットはExifと同じ構造の形式で情報を記録することになっており、よって読むにあたってExifについての知識があることが前提になっています。最初はとっつきにくいかもしれません。

http://www.cipa.jp/std/documents/e/DC-007_E.pdf


仕様書9ページの図1からわかるように、複数枚の個別JPEG画像からなり、それぞれ画像領域の手前にSOI・APP1・APP2という付属情報があります。SOIはStart of Imageの略で、JPEGの開始の合図です。続くAPP1はExifメタデータで、カメラの機種や撮影時の条件などが書かれています。ここまでは普通のJPEGと同様ですが、続くAPP2にてマルチピクチャフォーマット独自の情報が書かれています。

1枚1枚の個別画像にそれぞれAPP2がありますが、1枚目は特殊で、2枚目以降より情報が多く書かれています。その中に、含まれるJPEG画像それぞれの位置やサイズが書かれた部分があります。これを取得できれば個々のJPEGに分割できるというわけです。



2. MPOファイルをバイト列として読み込む

前回と同じ。「ちゃんとした」ソフトウェアとして作る場合には、このように一気に読み込まずに必要な領域だけ逐次読み込んだ方が良いでしょう。

byte[] buffer = System.IO.File.ReadAllBytes(fileName);   

3. 最初のAPP2マーカを探す

バイト列bufferの中から、1枚目の個別画像におけるAPP2が現れるインデックスを求めます。マルチピクチャフォーマットにおけるAPP2のマーカは以下のような値になっています。

アドレス コード(Hex)
+00 FF
+01 E2
+02
+03
+04 4D ('M')
+05 50 ('P')
+06 46 ('F')
+07 00 (NULL)

面倒なのでバイト列の最初から普通に探索します。本来ならAPP1から解釈を始めますからこのような形にはならないかもしれません。

// APP2マーカのバイト長
const int MarkerLength = 8;

// APP2が最初に出てくるインデックスを返す
private int FindApp2Offset(byte[] bytes, int startIndex)
{
    byte[] marker = new byte[] { 0xff, 0xe2 };
    int length = bytes.Length - MarkerLength;

    for (int i = startIndex; i < length; )
    {
        // マーカを探す
        int j = SearchArray(bytes, marker, i);
        // マーカが見つかったら、MPフォーマット識別コードをチェック
        if (j != -1)
        {
            // 'M', 'P', 'F', NULL
            if (bytes[j + 4] == 0x4D && bytes[j + 5] == 0x50 && bytes[j + 6] == 0x46 && bytes[j + 7] == 0x00)
                return j;
            else
                i = j + MarkerLength;
        }
        else
            break;
    }
    return -1;
}
// バイト列同士のマッチング
private int SearchArray(byte[] bytes, byte[] pattern, int startIndex)
{
    int length = bytes.Length - pattern.Length + 1;
    for (int i = startIndex; i < bytes.Length; i++)
    {
        bool matched = true;
        for (int j = 0; j < pattern.Length; j++)
        {
            if (bytes[i + j] != pattern[j])
            {
                matched = false;
                break;
            }
        }
        if (matched)
            return i;
    }
    return -1;
}

4. オフセットの基点

APP2マーカが見つかったインデックスから後ろのバイト列を示した例です。

FF E2 00 9E 4D 50 46 00 | 4D 4D 00 2A 00 00 00 08 ...
      (APP2マーカ)

8バイト目の後ろに入れた縦線より前がAPP2マーカです。さてこれ以降、「オフセット」によって値がある場所を示すことが多いです。このオフセットの基点となるのが、APP2マーカが終わった場所であるこの縦線のインデックスです。記録しておきます。

int offsetStart = FindApp2Offset(buffer, 0) + MarkerLength;

5. MPヘッダを解釈

見つけたAPP2マーカのすぐ後ろには、MPヘッダという部分があります(仕様書10ページ図2参照)。バイト列の例を示します。

FF E2 00 9E 4D 50 46 00  | 4D 4D 00 2A    | 00 00 00 08  |  00 03 B0 00 00 07 ...
      (APP2マーカ)         (エンディアン)   (オフセット)

最初の4バイト分がエンディアンを示す部分です。4D 4D 00 2A ならビッグエンディアン、49 49 2A 00 ならリトルエンディアンです。その後ろ4バイトは、MPインデックスIFDという、今回欲しいデータがある部分への「オフセット」です。どこからのオフセットかといえば、先ほど求めたoffsetStartです。

まずはエンディアンを判定します。

private Endian GetEndian(byte[] bytes, int o)
{
    byte[] b = bytes;
    if (b[o + 0] == 0x49 && b[o + 1] == 0x49 && b[o + 2] == 0x2A && b[o + 3] == 0x00)
        return Endian.Little;
    if (b[o + 0] == 0x4D && b[o + 1] == 0x4D && b[o + 2] == 0x00 && b[o + 3] == 0x2A)
        return Endian.Big;
    throw new Exception("予期しないエンディアンです");
}

求めたエンディアンは重要です。これからバイト列の解釈をするにあたって全てはこのエンディアンに従って行います。さっそく次のオフセット部の4バイトをintとして解釈するにあたり使います。先日の記事で紹介したエンディアンを指定できるBitConverterを利用します。

Endian endian = GetEndian(buffer, offsetStart + 0);
int firstIFDOffset = BitConverterEx.ToInt32(buffer, offsetStart + 4, endian);

firstIFDOffset は8であることが多いです。起点は今見ているMPヘッダの先頭ですから、つまりこのすぐ後ろからMPインデックスIFDが始まるということです。



6. MPインデックスIFDを解釈

Exifでは、データはIFDという12バイトのブロック単位に保存されています(仕様書13ページ図3参照)。マルチピクチャフォーマットも構造はExifそのものですから同様です。

IFDをあらわすクラスを定義します。OffsetはTypeで示される型に従って解釈が様々に変わるため、byte[]として持っておかなければなりませんが、普通の整数としてアクセスしたいことも多いので、便利なプロパティを作っておきます。

public class IFD
{
    // データ識別コード
    public ushort Tag { get; set; }
    // データの書式
    public ushort Type { get; set; }
    // 書かれるデータの個数。Typeで示される1単位のバイト数 * Count がデータ長になる。
    public uint Count { get; set; }
    // データの長さが4バイトまでなら、ここにデータが書かれる。
    // 5バイト以上なら別の場所にデータが書かれ、そこまでのオフセットを示す。
    public byte[] Offset { get; set; }
    // Offsetをuintに変換したもの
    public uint OffsetUInt32
    {
        get { return BitConverter.ToUInt32(Offset, 0); }
    }

    public IFD()
    {
    }
}

このプロパティ1つ1つにBitConverterExを使ってバイト列から読み取った値を設定していきます。コンストラクタにして使いやすくしました。

class IFD
{
    /* --- 中略 --- */

    public IFD(byte[] bytes, int o, Endian endian)
    {
        Tag = (ushort)BitConverterEx.ToInt16(bytes, o + 0, endian);
        Type = (ushort)BitConverterEx.ToInt16(bytes, o + 2, endian);
        Count = (uint)BitConverterEx.ToInt32(bytes, o + 4, endian);
           
        Offset = new byte[4];
        Array.Copy(bytes, o + 8, Offset, 0, Offset.Length);
        if (BitConverter.IsLittleEndian ^ endian == Endian.Little)
            Array.Reverse(Offset);
    }

ではMPインデックスIFDをカウントからMPエントリのところまで読み取ってみます。

int o = offsetStart + firstIFDOffset;

ushort count = BitConverterEx.ToUInt16(buffer, o + 0, endian),
IFD version = new IFD(buffer, o + 2, endian),
IFD number = new IFD(buffer, o + 14, endian),
IFD entryIndex = new IFD(buffer, o + 26, endian),

この後ろの個別画像ユニークIDリストだとか撮影時総コマ数だとかは必須情報ではなく、存在しないことがあります。それに今回の話では利用しないので割愛します。



7. 個別画像のオフセットとバイト長を得る

さて目標としているのは、個別画像のオフセットとバイト長の情報が存在する、MPエントリという場所です。まずはこのMPエントリを表すクラスを作っておきます。IFDと同様です。

public class MPEntryValue
{
    // 個別画像種別管理情報
    public uint ImageAttr { get; set; }
    // 個別画像サイズ
    public uint ImageSize { get; set; }
    // 個別画像データオフセット
    public uint ImageDataOffset { get; set; }
    // 従属画像1エントリNo.
    public ushort DependentImage1 { get; set; }
    // 従属画像2エントリNo.
    public ushort DependentImage2 { get; set; }

    public MPEntryValue()
    {
    }
    public MPEntryValue(byte[] bytes, int o, Endian endian)
    {
        ImageAttr = (uint)BitConverterEx.ToInt32(bytes, o + 0, endian);
        ImageSize = (uint)BitConverterEx.ToInt32(bytes, o + 4, endian);
        ImageDataOffset = (uint)BitConverterEx.ToInt32(bytes, o + 8, endian);
        DependentImage1 = (ushort)BitConverterEx.ToInt16(bytes, o + 12, endian);
        DependentImage2 = (ushort)BitConverterEx.ToInt16(bytes, o + 14, endian);
    }
}


MPエントリにアクセスするのに必要な情報が前の節で求めた number と entryIndex から求められます。MPエントリが存在する位置へのオフセットが、 entryIndex.OffsetUInt32 から得られます。また、この.mpoファイルに含まれる個別画像の枚数が number.OffsetUInt32 から得られます。以下はMPエントリを読み取るコードです。

MPEntryValue[] mpEntries = new MPEntryValue[number.OffsetUInt32];
int offset = offsetStart + (int)entryIndex.OffsetUInt32;
            
for (int i = 0; i < mpEntries.Length; i++)
    mpEntries[i] = new MPEntryValue(buffer, offset + (16 * i), endian);


MPEntryValue.ImageDataOffset が個別画像がある場所へのオフセット、MPEntryValue.ImageSize が個別画像のバイト長です。


8. Bitmapへ変換

ここまでくれば出来たも同然。求められたオフセットとバイト長でバイト配列を区切って、System.Drawing.Bitmapへ放り込むだけです。

for (int i = 0; i < mpEntries.Length; i++)
{
    int size = (int)mpEntries[i].ImageSize;
    int offset = (i == 0) ? 0 : (offsetStart + (int)mpEntries[i].ImageDataOffset);
    MemortStream ms = new MemoryStream(buffer, offset, size);
    Bitmap bitmap = new Bitmap(ms);
    .
    .
    .
}




おわりに

コードが長くてうまく説明できません。以下のリポジトリから全てのコードを落とせます。

svn checkout http://schima-code-snippets.googlecode.com/svn/trunk/Mpo2Jpg schima-code-snippets-read-only


また、実行ファイルはこちらから落とせます。実行するとダイアログが開くので、MPOファイルを選択してやると、抽出したJpeg画像をその場所に吐きます。またはコマンドライン引数でファイル名を指定しても良いです。

Mpo2Jpg.exe hoge.mpo fuga.mpo piyo.mpo

http://code.google.com/p/schima-code-snippets/downloads/list

リポジトリ
https://github.com/shimat/mpo2jpg