備忘録 blog

Docker/Machine Learning/Linux

DeepZoom フォーマット (DZI) の解説

Google Maps のような、もともとは1枚絵であるが、拡大すればするほど高精細な画像が表示される、あるいは拡大するたびに、情報の密度が異なる画像が表示されるようなアーキテクチャを Web 上で実装したいと思う。

こうしたアーキテクチャを実装するにあたっては、以下の2つの要素が必要になる。

  • バックエンドでズームレベルに応じて異なる解像度の画像を提供する
  • フロントエンド側では、拡大縮小やスクロールといった操作に応じて、各レイヤーの画像を適切に配置する

フロントエンド側では、オープンソースで OpenSeadragon とよばれる実装が入手できる中でもクオリティの高い実装の1つであると考えられる。OpenSeadragon は複数の、フロントエンドとバックエンドの間のインターフェースとなるフォーマットをサポートしている。

openseadragon.github.io

そのなかで本稿では、DZI フォーマットに焦点をあてて解説する。DZI フォーマットはフォーマット内の規約によって様々なことが決まっていてカスタマイズ性が少ない反面、少ない実装量で拡大・縮小可能な画像を生成することができる。

DZI フォーマットは今は無き Microsoft Silverlight で利用されているフォーマットである。DZI フォーマットを利用すると、一枚の巨大な画像を、部分画像に分割することで、拡大・縮小した際に切れ目無く、解像度を保ったまま表示することができる。フォーマット自体は更新されていないが、少なくともフォーマット自体の完成度は十分なので、このフォーマットを利用したところで生じる問題は少ないと考えられる。本稿では、DZI フォーマットに従った画像群を作る上で気をつけなければならないことをまとめる。

DZI

DeepZoomフォーマットは、基本的にはユーザー側で設定する内容が極めて少なく、等倍の画像サイズと、各ズームレイヤーにおける、1タイルあたりのpixel数を予め指定するだけである。各ズームレイヤーにおいて、どの位置の画像を配置すべきかについては、フォーマットの規約に基づいて自動的に計算される。そのため、ユーザーは設定ファイルと、各ズームレイヤーの画像を配置するだけで、拡大・縮小可能な画像を提供することができる。

例えば、これらのツールのうちどれかを使って階層的な画像を作ることができる。

openseadragon.github.io

ここでは、これらのツールが具体的にどのような画像を配置しているのかを記述する。

まず、画像を配置するルートディレクトリに、 .dzi 拡張子で以下のようなJSON ファイルを置く必要がある。

{
    "Image": {
        "xmlns":    "http://schemas.microsoft.com/deepzoom/2008",
        "Format":   "jpg", 
        "Overlap":  "0", 
        "TileSize": "256",
        "Size": {
            "Height": "65536",
            "Width":  "65536"
        }
    }
}

Overlap の値を0以外に指定すると、画像と画像の間にそのpixel数だけオーバーラップした画像を容易することができる。これは、例えば画像の切れ目に文字が重なるような場合に、オーバーラップした画像を予め作っておくといったことができるが、ここではOverlap=0とした場合で議論を進めている。

たとえば、上記の場合、すなわちTileSize=256, Width=65536, Height=65536の場合を考える。この場合では、もとの全体画像が等倍の場合に65536px x 65536px であったという計算になる。このときに、 ズームレイヤーの個数はどのように決定されるかというと

 \displaystyle\left \lceil{\log_2{\max{(weight, height)}}}\right \rceil

この計算式で決まる。この場合では、どのようなタイルが生成されるのかを、以下のコードを用いてシミュレートする。(元のコードは https://www.gasi.ch/blog/inside-deep-zoom-2 にあったものを移植した。)

def getMaximumLevel(width, height)
  return (Math.log2([width, height].max)).ceil
end

def computeLevels( width, height, tileSize)
  maxLevel= getMaximumLevel( width, height )

  maxLevel.downto(0) do |level|
    columns = ( width * 1.0 / tileSize ).ceil
    rows = ( height * 1.0 / tileSize ).ceil

    puts "level #{level} is #{width} x #{height} ( #{columns} columns, #{rows} rows )"

    width  = ( width / 2.0 ).ceil
    height = ( height / 2.0 ).ceil
  end
end

computeLevels(65536, 65536, 256)

これを実行した結果は以下である。

level 16 is 65536 x 65536 ( 256 columns, 256 rows )
level 15 is 32768 x 32768 ( 128 columns, 128 rows )
level 14 is 16384 x 16384 ( 64 columns, 64 rows )
level 13 is 8192 x 8192 ( 32 columns, 32 rows )
level 12 is 4096 x 4096 ( 16 columns, 16 rows )
level 11 is 2048 x 2048 ( 8 columns, 8 rows )
level 10 is 1024 x 1024 ( 4 columns, 4 rows )
level 9 is 512 x 512 ( 2 columns, 2 rows )
level 8 is 256 x 256 ( 1 columns, 1 rows )
level 7 is 128 x 128 ( 1 columns, 1 rows )
level 6 is 64 x 64 ( 1 columns, 1 rows )
level 5 is 32 x 32 ( 1 columns, 1 rows )
level 4 is 16 x 16 ( 1 columns, 1 rows )
level 3 is 8 x 8 ( 1 columns, 1 rows )
level 2 is 4 x 4 ( 1 columns, 1 rows )
level 1 is 2 x 2 ( 1 columns, 1 rows )
level 0 is 1 x 1 ( 1 columns, 1 rows )

この結果を解釈すると、

  • ZoomLevel=0 のときに、1x1 の1タイルの画像(全体のサムネイル)だけが存在し、
  • ZoomLevel=1 のときに、2x2のタイルが1枚存在する。
  • .....
  • ZoomLevel=7 のときに、128*128のタイルが1枚存在する。
  • ZoomLevel=8のときに、256256のタイルが1枚存在する。これ以降は、画像サイズは全て256256となる。
  • ZoomLevel=9 のときに、256*256のタイルが4枚存在する。
  • ……
  • ZoomLevel=16 (等倍ズーム)のときに、256x256 のタイルが256*256枚並べられる。単純には65,536枚の画像が必要となる。

そのため、ここで注意しなければならないのは、

  • ZoomLevel が 一定以下のサイズになると、最小単位よりも小さいタイル1枚によって構成されることになる。だいたいの場合で画像を縮小しすぎになってしまうので、これらのZoomLevelは使われないことが多い。
  • ZoomLevel がある程度大きくなると、解像度が大きくなって表示したい画像サイズがタイルサイズとして指定してあるものを上回る。すると、

次に、横長になるような場合を考える。例えば65536 x 256 の場合だと、以下のようになる。

level 16 is 65536 x 256 ( 256 columns, 1 rows )
level 15 is 32768 x 128 ( 128 columns, 1 rows )
level 14 is 16384 x 64 ( 64 columns, 1 rows )
level 13 is 8192 x 32 ( 32 columns, 1 rows )
level 12 is 4096 x 16 ( 16 columns, 1 rows )
level 11 is 2048 x 8 ( 8 columns, 1 rows )
level 10 is 1024 x 4 ( 4 columns, 1 rows )
level 9 is 512 x 2 ( 2 columns, 1 rows )
level 8 is 256 x 1 ( 1 columns, 1 rows )
level 7 is 128 x 1 ( 1 columns, 1 rows )
level 6 is 64 x 1 ( 1 columns, 1 rows )
level 5 is 32 x 1 ( 1 columns, 1 rows )
level 4 is 16 x 1 ( 1 columns, 1 rows )
level 3 is 8 x 1 ( 1 columns, 1 rows )
level 2 is 4 x 1 ( 1 columns, 1 rows )
level 1 is 2 x 1 ( 1 columns, 1 rows )
level 0 is 1 x 1 ( 1 columns, 1 rows )
  • ZoomLevel=0 のときに、1x1 の1タイルの画像(全体のサムネイル)だけが存在し、
  • ZoomLevel=1 のときに、2x1のタイルが1枚存在する。
  • .....
  • ZoomLevel=7 のときに、128*1のタイルが1枚存在する。
  • ZoomLevel=8のときに、256*1のタイルが1枚存在する。
  • ZoomLevel=9 のときに、256*2のタイルが2枚存在する。
  • ……
  • ZoomLevel=16 (等倍ズーム)のときに、256x256 のタイルが256枚並べられる。単純には256枚の画像が必要となる。

横長になる場合では、以下の点に留意しなければならない。

  • 最下層のレイヤーのみは、256*256 の等幅タイルを配置することになるが、それより上位レイヤーでは、画像サイズが
  • 低いレイヤーの画像が、横長の小さい画像になるので、もとの画像が等幅の場合に比べて

画像をどう配置するか?

画像はズームレイヤー・横index・縦indexの3つの値によって example_files/<zoom_level>/<row>_<column>.jpg というように指定される。例えば、example_files/0/0_0.jpg である。基本的には、各ズームレイヤー間は縦横が2倍ずつの差になっているので、例えば横長の画像の場合は、 example_files/12/25_0.jpg の画像を拡大した場合は example_files/13/50_0.jpgexample_files/13/51_0.jpg になるし、縮小した場合は example_files/11/12_0.png の一部に相当する。(縦横幅が同じであれば、上位レイヤーの1枚が下位レイヤーの4枚に対応する。)

4096x4096 tileSize=256の場合の画像の例は以下である。

  • layer=8

f:id:sharply:20210803085956j:plain

  • layer=7

f:id:sharply:20210803090014j:plain

上記のように、レイヤー間のタイルの長さは2倍ずつ差がるので、面積としては4倍の差になる。

* deepzoom.dzi
* 0/
  * 0_0.jpg
* 1/
  * 0_0.jpg
* 2/
  * 0_0.jpg
* ...

全体として、画像は上記のようなディレクトリ構造で配置される。画像のサイズがタイルサイズの倍数で割りきれない場合は、右端ないし下端の画像サイズが短くなることによって適宜調節される。

OpenSeadragon 側ではどのような設定が必要となるか?

<div id="openseadragon1" style="width: 95%; height: 95%;"></div>
<script src="openseadragon.min.js"></script>
<script type="text/javascript">
    OpenSeadragon.DEFAULT_SETTINGS.timeout = 600000; 
    var viewer = OpenSeadragon({
        id:            "openseadragon1",
        panVertical:   true,
        prefixUrl:     "/example_files/",
        navigatorSizeRatio: 0.25,
        wrapHorizontal:     false,
        debugMode:  false,
        showNavigator:  true,
        navigatorHeight:   "10px",
        navigatorWidth:    "845px",
        defaultZoomLevel:  26,
        minZoomLevel:  10,
        visibilityRatio:   0.5,
        tileSources:   "/deepzoom.dzi",
    });
</script>

OpenSeadragon を表示したいHTMLファイルから、 openseadragon.min.js を呼び出す。パラメータについて、以下の設定を変えると便利な場合がある。

  • 画像が重くてロードに時間がかかり、タイムアウトしてしまう場合は、 OpenSeadragon.DEFAULT_SETTINGS.timeout の値を大きくする。
  • ZoomLevelの値が小さい場合は、画像サイズが小さすぎて見られないのであまり利用されない。そこで、minZoomLevel を大きめの値に設定すると、細かい画像をロードしにいかなくて済む。これは先述の場合に対応した工夫である。
  • visibilityRatio は、minZoomLevel まで縮小したとき、それ以上に縮小することを許容するかどうかを指定する。visibilityRatio=1.0 のときは、minZoomLevel の画像はそれ以上縮小されない。例えば visibilityRatio=0.5 にすると、半分のサイズになるまでは縮小される。

フロントエンドではどのような実装が考えられるか?

現状でオープンソースな実装を使おうとすると、OpenSeadragon が最有力候補であると考えられる。このライブラリはなかなか完成度が高いが、以下のような難点がある。

  • 1枚の画像が 10MB を越えると、なぜか表示されなくなる。そのため、1タイルあたりの画像が大きい場合に、高精細な画像を利用するのが難しくなるケースが存在する。
  • 拡大/縮小の方向が縦横の比率を保った方向に限定されており、例えば横方向のみの拡大/縮小などがサポートされていない。そのため、例えば年表のような画像を作る場合では、縦方向に画像が拡大されてしまって不都合になってしまう。
  • 一度キャッシュされた画像は書き換わらない。そのため、バックエンド側で動的に画像が置き換わるような仕組みを実装するのが難しい。

ところで、このalternative な実装を作ろうとすると(実際に実装しようと試みたのだが)、 (1) OpenSeadragon はスムーズに画像を切り替えられるように丁寧に実装されている、 (2) パフォーマンスを保つために、ロントエンド上に画像をキャッシュする実装が必要、(3) OpenSeadragon はズームを補間している というところで、なかなか適切に実装するのは大変だと考えられる。

参考ページ

https://openseadragon.github.io/examples/tilesource-dzi/

https://github.com/openseadragon/openseadragon/wiki/The-DZI-File-Format