💨

Leaflet-IIIFでのアノテーション座標変換の完全ガイド

に公開

概要

IIIF (International Image Interoperability Framework) Presentation API v3のマニフェストに含まれるアノテーション座標(xywh形式)を、Leaflet-IIIFを使用したマップビューアー上で正確に表示する方法について解説します。

この問題は一見シンプルに見えますが、Leaflet-IIIFの内部動作を理解しないと正確な座標変換ができません。

問題の背景

IIIFマニフェストのアノテーション形式

IIIF Presentation API v3では、アノテーションの対象領域は以下のようなxywh形式で指定されます:

{
  "id": "https://example.org/iiif/canvas/1/annotation/1",
  "type": "Annotation",
  "motivation": "commenting",
  "body": {
    "type": "TextualBody",
    "value": "雅屯河",
    "language": "ja"
  },
  "target": "https://example.org/iiif/canvas/1#xywh=41012,81,115,49"
}

このxywh=41012,81,115,49は:

  • x: 41012(左端のピクセル位置)
  • y: 81(上端のピクセル位置)
  • w: 115(幅)
  • h: 49(高さ)

を意味します。これらは元画像のピクセル座標です。

Leaflet-IIIFの座標系

Leaflet-IIIFは、IIIF Image APIで提供される高解像度画像をタイル形式で表示するLeafletプラグインです。内部的には:

  1. CRS.Simple座標参照系を使用
  2. 画像を複数のズームレベルで縮小して管理
  3. ズームレベルごとに異なる座標スケールを使用

この複雑な座標系のため、単純にmap.unproject()pointToLatLng()を使っても正しい位置に配置できません。

試行錯誤の過程

失敗した試み1: map.unproject()の直接使用

// ❌ これは動かない
const point = L.point(x, y);
const latLng = map.unproject(point, 3);  // ズームレベル3を指定

問題点: unproject()は現在のマップの座標系を前提としており、Leaflet-IIIFが内部で使用している座標系とは異なります。

失敗した試み2: マップ境界からの比例計算

// ❌ これも不正確
const bounds = map.getBounds();
const normX = imgX / imageWidth;
const normY = imgY / imageHeight;
const lng = bounds.getWest() + (normX * (bounds.getEast() - bounds.getWest()));
const lat = bounds.getNorth() - (normY * (bounds.getNorth() - bounds.getSouth()));

問題点: Leaflet-IIIFは画像のアスペクト比を保持するため、マップ境界と実際の画像境界は異なります。

失敗した試み3: アスペクト比を考慮した境界計算

// ❌ 近いが完全には一致しない
const imageAspect = imageWidth / imageHeight;
const mapAspect = mapWidth / mapHeight;

if (imageAspect > mapAspect) {
    // 横長の画像:上下に余白
    const actualHeight = mapWidth / imageAspect;
    const verticalPadding = (mapHeight - actualHeight) / 2;
    // ... 境界を調整
} else {
    // 縦長の画像:左右に余白
    // ... 境界を調整
}

問題点: この方法でも近い位置には配置できますが、Leaflet-IIIFが内部で使用している縮小された画像サイズを考慮していないため、わずかにずれが生じます。

正解:Leaflet-IIIFの_fitBoundsメソッドを再現

Leaflet-IIIFのソースコードを分析

Leaflet-IIIFの_fitBoundsメソッドを見ると、以下のロジックで画像を配置していることがわかります:

_fitBounds: function() {
    var _this = this;
    var initialZoom = _this._getInitialZoom(_this._map.getSize());
    var offset = _this._imageSizes.length - 1 - _this.options.maxNativeZoom;
    var imageSize = _this._imageSizes[initialZoom + offset];
    var sw = _this._map.options.crs.pointToLatLng(L.point(0, imageSize.y), initialZoom);
    var ne = _this._map.options.crs.pointToLatLng(L.point(imageSize.x, 0), initialZoom);
    var bounds = L.latLngBounds(sw, ne);
    _this._map.fitBounds(bounds, true);
}

重要なポイント:

  1. _getInitialZoom()で最適なズームレベルを計算
  2. _imageSizes[initialZoom + offset]で、そのズームレベルでの縮小された画像サイズを取得
  3. その縮小されたサイズを使ってpointToLatLng()で座標変換

正しい実装

// ✅ これが正解
function imageToLatLng(imgX, imgY) {
    // 1. Leaflet-IIIFの初期ズームレベルを取得
    const initialZoom = iiifLayer._getInitialZoom(map.getSize());

    // 2. オフセットを計算
    const offset = iiifLayer._imageSizes.length - 1 - iiifLayer.options.maxNativeZoom;

    // 3. そのズームレベルでの縮小された画像サイズを取得
    const imageSize = iiifLayer._imageSizes[initialZoom + offset];

    // 4. 元画像座標を0-1の範囲に正規化
    const normX = imgX / iiifLayer.x;  // iiifLayer.x = 元画像の幅
    const normY = imgY / iiifLayer.y;  // iiifLayer.y = 元画像の高さ

    // 5. 縮小された画像での座標に変換
    const scaledX = normX * imageSize.x;
    const scaledY = normY * imageSize.y;

    // 6. Leaflet座標に変換(Leaflet-IIIFと同じ方法)
    return map.options.crs.pointToLatLng(L.point(scaledX, scaledY), initialZoom);
}

完全な実装例

let map;
let iiifLayer;

async function init() {
    try {
        // マニフェストを読み込み
        const manifestUrl = '../data/manifest_v3_compact.json';
        const response = await fetch(manifestUrl);
        const manifest = await response.json();

        // キャンバスと画像情報を取得
        const canvas = manifest.items[0];
        const paintingAnno = canvas.items[0].items[0];
        const imageServiceId = paintingAnno.body.service[0].id;

        // マップを作成
        map = L.map('map', {
            center: [0, 0],
            crs: L.CRS.Simple,
            zoom: 0
        });

        // IIIF画像レイヤーを追加
        iiifLayer = L.tileLayer.iiif(imageServiceId + '/info.json').addTo(map);

        // アノテーションデータ
        const annotationPage = canvas.annotations[0];

        // IIIF画像が読み込まれるまで待つ
        setTimeout(() => {
            const imageWidth = iiifLayer.x;
            const imageHeight = iiifLayer.y;

            // Leaflet-IIIFの内部ロジックを再現
            const initialZoom = iiifLayer._getInitialZoom(map.getSize());
            const offset = iiifLayer._imageSizes.length - 1 - iiifLayer.options.maxNativeZoom;
            const imageSize = iiifLayer._imageSizes[initialZoom + offset];

            // 画像座標からマップ座標への変換関数
            function imageToLatLng(imgX, imgY) {
                const normX = imgX / imageWidth;
                const normY = imgY / imageHeight;
                const scaledX = normX * imageSize.x;
                const scaledY = normY * imageSize.y;
                return map.options.crs.pointToLatLng(L.point(scaledX, scaledY), initialZoom);
            }

            // アノテーションを描画
            annotationPage.items.forEach((anno, index) => {
                const target = anno.target;
                const xywh = target.split('#xywh=')[1];
                const [x, y, w, h] = xywh.split(',').map(Number);

                const topLeft = imageToLatLng(x, y);
                const bottomRight = imageToLatLng(x + w, y + h);
                const bounds = L.latLngBounds(topLeft, bottomRight);

                L.rectangle(bounds, {
                    color: '#ff0000',
                    weight: 3,
                    fillColor: '#ffff00',
                    fillOpacity: 0.5
                }).addTo(map).bindPopup(`
                    <strong>${anno.body.value}</strong><br>
                    ID: ${index + 1}
                `);
            });

        }, 2000);  // 2秒待つ(画像が読み込まれるまで)

    } catch (error) {
        console.error('エラー:', error);
    }
}

document.addEventListener('DOMContentLoaded', init);

なぜこの方法が正しいのか

ズームレベルごとの画像サイズ

Leaflet-IIIFは、パフォーマンスのために画像を複数のズームレベルで管理します:

// 例:43890 x 38875 の画像の場合
_imageSizes = [
    L.point(172, 152),    // zoom 0
    L.point(343, 304),    // zoom 1
    L.point(686, 608),    // zoom 2
    L.point(1372, 1215),  // zoom 3
    // ...
    L.point(43890, 38875) // zoom 8 (maxNativeZoom)
]

initialZoomの役割

_getInitialZoom()は、マップのサイズに対して最適なズームレベルを計算します:

_getInitialZoom: function (mapSize) {
    var tolerance = 0.8;
    var offset = this._imageSizes.length - 1 - this.options.maxNativeZoom;

    for (var i = this._imageSizes.length - 1; i >= 0; i--) {
        imageSize = this._imageSizes[i];
        if (imageSize.x * tolerance < mapSize.x &&
            imageSize.y * tolerance < mapSize.y) {
            return i - offset;
        }
    }
    return 2;  // デフォルト
}

このズームレベルで、画像がマップにぴったり収まるように配置されます。

offsetの意味

offsetは、_imageSizes配列のインデックスとズームレベルの対応を調整するためのものです:

const offset = iiifLayer._imageSizes.length - 1 - iiifLayer.options.maxNativeZoom;

例えば、maxNativeZoom = 8_imageSizes.length = 9の場合、offset = 0となります。

デバッグのヒント

座標変換がうまくいかない場合、以下の情報をコンソールに出力して確認してください:

console.log('元画像サイズ:', iiifLayer.x, 'x', iiifLayer.y);
console.log('initialZoom:', initialZoom);
console.log('offset:', offset);
console.log('_imageSizes.length:', iiifLayer._imageSizes.length);
console.log('maxNativeZoom:', iiifLayer.options.maxNativeZoom);
console.log('imageSize (zoom=' + (initialZoom + offset) + '):',
    imageSize.x, 'x', imageSize.y);

// 四隅のテスト
const sw = map.options.crs.pointToLatLng(L.point(0, imageSize.y), initialZoom);
const ne = map.options.crs.pointToLatLng(L.point(imageSize.x, 0), initialZoom);
console.log('SW (左下):', sw);
console.log('NE (右上):', ne);

まとめ

Leaflet-IIIFでIIIFアノテーションを正確に表示するには:

  1. Leaflet-IIIFの内部ロジックを理解する: _fitBoundsメソッドがどのように画像を配置しているか
  2. 縮小された画像サイズを使用する: 元画像サイズではなく、_imageSizes[initialZoom + offset]を使用
  3. 同じズームレベルで変換する: pointToLatLng(point, initialZoom)で変換

この方法により、ピクセル単位で正確にアノテーションを配置できます。

参考資料

プロジェクト情報

  • 使用ライブラリ:
    • Leaflet 1.9.4
    • Leaflet-IIIF 3.0.0
  • 作成日: 2025年10月19日

Discussion