100000 × 100000 ピクセルの画像を1ピクセルあたり4バイトでデコードすると、約40 GBのメモリが必要になる。しかし、ビューアが一度に表示するのは画像の一部分にすぎない。画像全体を読み込む方式では、表示に必要なデータ量と読み込みコストが大きく乖離する。
ZYC.TilePyramidでは、画像ピラミッドとタイルのオンデマンド読み込みを採用した。ズーム倍率に応じて解像度レベルを選び、ビューポートの位置から必要な領域を求め、その領域のタイルを非同期で読み込んで描画する。
本記事では、マンデルブロ集合をデータソースとして、この処理の実装を紹介する。これは今後構築する画像解析Agentの基盤でもある。ビューアは画像の表示と操作を担当し、Agentはツールインターフェースを通じて解析対象の領域を指定する。両者で画像データソースと全解像度の座標系を共有することで、解析結果をビューア上で直接位置合わせし、表示・アノテーションできるようにする。
ソースコード: ZYC.TilePyramid.Agent (GitHub)
マンデルブロ集合、拡大表示した領域、タイルの診断情報を示す。
画像ピラミッドは、同じ画像を複数の解像度レベルで扱う仕組みである。本実装ではレベル0を最高解像度とし、マンデルブロのデータソースでは各レベルの幅と高さを半分ずつにして、レベル全体が1枚のタイルに収まるまで縮小する。
例えば、16384 × 12288 ピクセルの画像は、次のような階層になる。
| レベル | 画像サイズ | ダウンサンプリング倍率 |
|---|---|---|
| 0 | 16384 × 12288 | 1 |
| 1 | 8192 × 6144 | 2 |
| 2 | 4096 × 3072 | 4 |
| … | … | … |
| 6 | 256 × 192 | 64 |
各レベルはさらにタイルに分割される。縮小表示では低解像度のレベルを、拡大表示では高解像度のレベルを読み込むことで、1回のリクエストで扱う領域を小さく保つ。
ここでは、全解像度の画像座標、現在のレベルのピクセル座標、ビュー座標という3つの座標系を区別する。最初の2つはダウンサンプリング倍率で、画像座標とビュー座標はビューポート変換で結び付ける。後続のアノテーションや認識結果は全解像度の画像座標で保持し、表示レベルが変わっても位置の意味が変わらないようにする。
3つの解像度レベルを示し、元画像の同じ領域が各レベルのどこに対応するかを示す。
データソースは ITilePyramidSource を通じて、画像の構成情報とタイルリソースを提供する。
public interface ITilePyramidSource : IDisposable
{
TilePyramidInfo Info { get; }
TilePyramidMetadata Metadata { get; }
ValueTask<TileResource> ResolveTileAsync(
TileKey key,
CancellationToken cancellationToken);
}
Info は画像サイズ、レベル構成、タイル配置を記述する。TileKey はレベル、列番号、行番号、焦点面を指定する。タイルの列番号と行番号には long を使用し、非常に大きな画像のアドレス指定に対応する。
TileResource は3種類の形式をサポートする。
| リソース形式 | 内容 |
|---|---|
Address |
ローカルまたはHTTPのリソースアドレス |
Encoded |
PNG、JPEGなどのエンコード済みデータ |
PixelBuffer |
デコード済みのピクセルバッファ |
このインターフェースにより、ファイル、ネットワークサービス、プログラムで生成する画像を同じビューで扱える。データソースは画像リソースを提供し、WPF側が BitmapSource への変換を担当する。
この抽象化は、Agentの接続点にもなる。今後、データソース層の上に領域取得用のツールを実装すれば、AgentはWPFのビューポートや描画処理に依存せず、画像座標を指定して解析対象の画素を取得できる。ビューアとAgentは同じデータと座標の基準を利用できる。
マンデルブロのデータソースは、ピクセルバッファを直接返す。仮想画像のサイズは次のように定義している。
private const long ImageWidth = 1L << 42;
private const long ImageHeight = 3L << 40;
private const int TileSize = 256;
これらの値はアドレス指定可能な範囲を定義するものであり、画像全体のピクセルを事前に生成するわけではない。実際の画素は、タイルのリクエストを受けたときに計算する。
各ピクセルは複素平面上の点 c に対応する。z₀ = 0 から、次の漸化式で反復計算する。
zₙ₊₁ = zₙ² + c
絶対値が2を超えた点は発散すると判定できる。反復回数の上限に達しても発散しなかった点は、集合の内部として着色する。有限回の反復によるため、結果は近似である。
タイル生成時には、現在のレベルのピクセル位置を全解像度の画像座標へ変換し、そこから複素平面へ写像する。主要な計算は次のとおりで、left と top は現在のレベルにおけるタイルの実際の開始位置である。
var fullX = (left + pixelX + 0.5) * level.DownsampleX;
var fullY = (top + pixelY + 0.5) * level.DownsampleY;
var real = MinimumReal
+ fullX / ImageWidth
* (MaximumReal - MinimumReal);
var imaginary = MaximumImaginary
- fullY / ImageHeight
* (MaximumImaginary - MinimumImaginary);
0.5 はピクセルの中心をサンプリングするための補正である。画像のY軸は下方向に増加するため、虚部への写像では減算を用いる。
隣接するタイルのサンプリング位置を一致させるため、すべてのタイルで共通のグローバル座標変換を使用する。また、現在の実装では1ピクセルのオーバーラップ領域を設けているため、内部のタイルは実際には 258 × 258 ピクセルになる場合がある。画像の端では、残りの領域に合わせてサイズを切り詰める。
マンデルブロの計算はバックグラウンドタスクで実行し、1行処理するたびにキャンセル状態を確認する。ユーザーがビューポートを移動した場合、表示に不要となった計算を早期に終了できる。ただし、深いズームで得られる有効な細部は、浮動小数点数の精度と反復回数の上限に制約される。
マンデルブロ集合の全体像と境界付近の拡大図を並べ、別途タイルグリッドとオーバーラップ領域を示す。
データソースが決まったら、ビューアは「どの解像度レベルを使うか」「そのレベルのどのタイルが必要か」を判断する。
レベル選択の基準は、現在のレベルの1ピクセルが画面上でどれだけの大きさになるかである。画像から画面へのスケールを s、あるレベルのダウンサンプリング倍率を d とすると、そのレベルの1ピクセルは画面上でおよそ s × d ピクセルに相当する。この値が1に近いレベルを選ぶことで、不要な読み込みを抑えつつ、拡大によるぼやけも軽減する。
本実装では各レベルとの対数距離を比較し、縦横それぞれの方向で評価することで、縦横のダウンサンプリング倍率が異なる場合にも対応する。また、WPFのDPIスケーリングをレベル選択に反映し、デバイス非依存ピクセルを物理ピクセルと混同しないようにしている。
ビューポートは、画像上の中心座標、ズーム倍率、回転角度、ウィンドウサイズで表す。回転を考慮しない場合、元画像の座標からビュー座標への変換は次のようになる。
viewX = (imageX - centerX) × scale + viewportWidth / 2
viewY = (imageY - centerY) × scale + viewportHeight / 2
逆変換によってウィンドウが覆う元画像の領域を求め、現在のレベルでタイル1枚が覆う元画像上の幅と高さで割れば、候補となる行・列の範囲を計算できる。
tileWorldWidth = tileWidth × downsampleX
firstColumn = floor(left / tileWorldWidth)
lastColumnExclusive = ceil(right / tileWorldWidth)
求めた範囲は、有効なタイルの行・列の範囲に収める必要がある。回転をサポートする場合、ビューポートは画像座標上で四角形になる。その外接矩形から候補を求めた後、交差判定によって四隅の不要なタイルを除外する。オーバーラップ領域がある場合は、隣接タイルも候補に含めて判定する。
マウス位置を基準としたズームにも同じ座標変換を使用する。ズーム前にマウス位置に対応する画像座標を記録し、ズーム後にビューポートの中心を調整することで、その点が画面上の同じ位置に留まるようにする。
ビューポートが変化するたびに新しいリクエストが発生するため、ローダーは各変化を独立したタスク群として積み上げるのではなく、必要なリクエストの集合を管理する。
現在の実装では、リクエストを可視タイルと周辺の先読みタイルに分けている。スケジューリングでは可視タイルを優先し、先読みタスクが同時実行枠を占有している一方で可視タイルが待機している場合には、一部の先読みタスクをキャンセルし、リソースが空いた後で再試行する。
この優先順位によって、現在の表示に必要なデータを先に読み込む。なお、現時点ではビューポートの中心からの距離による優先順位付けは行っていない。
同時実行数は MaximumConcurrentLoads で制御し、デフォルト値は16である。同時に実行する読み込み、デコード、ピクセル生成の数を制限することで、高速なパン操作中に大量のアクティブタスクが蓄積するのを防ぐ。
リクエストのキャンセル後には、遅れて到着する結果にも対処する必要がある。下位の処理がすでに完了間近であったり、キャンセルにすぐ反応できなかったりする場合があるためである。結果をビューに反映する前に、その結果が現在のリクエストに属しているかを再確認する。
cancellationToken.ThrowIfCancellationRequested();
if (generation != _loadGeneration
|| source != Source
|| !_tiles.TryGetValue(key, out var currentEntry)
|| !ReferenceEquals(entry, currentEntry))
{
return;
}
ここでは読み込み世代、データソース、リクエストエントリの同一性を確認している。同じタイルが一度キャンセルされ、再度リクエストされた場合でも、古いタスクの結果が新しいリクエストの状態を上書きすることはない。
読み込み済みのタイルがリクエスト範囲から外れると、キャッシュに移される。再び必要になった場合はビットマップを再利用し、読み込みや計算の繰り返しを減らす。キャッシュ容量はデコード後の推定バイト数を基準に管理し、古いエントリから削除する。
オーバーラップ領域を含まない 256 × 256 ピクセルのBGRA32タイルは、ピクセルデータだけで256 KiBになる。デフォルトのキャッシュ予算は1 GiBで、CacheCapacityBytes から変更できる。ただし、この予算が制限するのはキャッシュ内のデータだけであり、表示中のタイル、先読みタイル、切り替え中の画像、一時バッファは別途メモリを消費する。
レベル切り替え時には、画面の連続性も考慮する必要がある。ズーム後、新しいレベルのタイルがすべて即座に届くとは限らない。
本実装では、読み込み済みの旧レベルのタイルを一時的な表示画像として保持し、まず旧画像を描画してから、新しく到着したタイルで順次上書きする。現在の可視タイルがすべて読み込まれた後、旧画像はキャッシュへ移す。
これにより、レベル切り替え中も既存の画像を表示し続けられる。画面全体の読み込み完了を待たずに、新しいデータをタイル単位で反映できる。
WPF側では独自の FrameworkElement を使用し、OnRender 内で DrawingContext.DrawImage によってタイルを描画する。各タイルはデータエントリとして管理し、タイルごとに独立したコントロールを持たせない。
非常に大きな座標を描画する際は、まず double 精度でビューポートの中心を差し引き、ズーム倍率を適用する。その後、ウィンドウサイズに近い値になった座標を描画処理に渡す。回転はビューの中心を基準に別途適用する。これにより、巨大な絶対座標を描画パイプラインへ直接渡すことによる精度低下を抑えられる。
エンコードされた画像はバックグラウンドでデコードし、再利用できるようにビットマップを Freeze する。一方、マンデルブロのデータソースから返されたピクセルバッファは、そのまま BitmapSource に変換する。リソースの準備が完了したらタイルの状態を更新し、再描画を要求する。
全体表示から局所領域までのズームをGIFで示し、旧レベルの保持と新レベルのタイルによる段階的な置き換えを確認する。
マンデルブロのデータソースを組み込む場合は、ビューを生成してリソース予算を設定するだけでよい。
var source = new MandelbrotTilePyramidSource();
var view = new TilePyramidView
{
Source = source,
MaximumConcurrentLoads = 16,
CacheCapacityBytes = 256L * 1024 * 1024
};
この例ではキャッシュ予算を256 MiBに設定している。データソースのライフサイクルは呼び出し側が管理し、破棄する前に進行中の読み込みを終了またはキャンセルしておく必要がある。
実行中は診断情報から、現在のレベル、可視タイル数、アクティブな読み込み数、先読みタイル数、キャッシュ使用量、切り替え中のタイル数を確認できる。高速なパン操作ではタスクが適切にキャンセルされるか、連続ズームではレベル切り替えがどう行われるか、既に閲覧した領域に戻る際にはキャッシュが再利用されるかを観察する。
次回は、この全解像度の座標系を使ってアノテーションを実装する。幾何オブジェクト、レイヤー管理、永続化を扱い、Agentによる認識結果を編集・保存できるようにする。



