Overview

This article explains how to accurately display annotation coordinates (in xywh format) from IIIF (International Image Interoperability Framework) Presentation API v3 manifests on a map viewer using Leaflet-IIIF.

While this problem may seem simple at first glance, accurate coordinate conversion is not possible without understanding the inner workings of Leaflet-IIIF.

Background

Annotation Format in IIIF Manifests

In IIIF Presentation API v3, the target region of an annotation is specified in xywh format as follows:

{
  "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"
}

This xywh=41012,81,115,49 means:

  • x: 41012 (pixel position of the left edge)
  • y: 81 (pixel position of the top edge)
  • w: 115 (width)
  • h: 49 (height)

These are pixel coordinates in the original image.

The Leaflet-IIIF Coordinate System

Leaflet-IIIF is a Leaflet plugin that displays high-resolution images provided via the IIIF Image API in tile format. Internally, it:

  1. Uses the CRS.Simple coordinate reference system
  2. Manages the image at multiple zoom levels with reduced sizes
  3. Uses different coordinate scales for each zoom level

Due to this complex coordinate system, simply using map.unproject() or pointToLatLng() will not place elements in the correct position.

The Trial-and-Error Process

Failed Attempt 1: Direct Use of map.unproject()

// This does not work
const point = L.point(x, y);
const latLng = map.unproject(point, 3);  // Specifying zoom level 3

Problem: unproject() assumes the current map coordinate system, which differs from the coordinate system used internally by Leaflet-IIIF.

Failed Attempt 2: Proportional Calculation from Map Bounds

// This is also inaccurate
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()));

Problem: Since Leaflet-IIIF preserves the image aspect ratio, the map bounds differ from the actual image bounds.

Failed Attempt 3: Bounds Calculation Considering Aspect Ratio

// Close, but not an exact match
const imageAspect = imageWidth / imageHeight;
const mapAspect = mapWidth / mapHeight;

if (imageAspect > mapAspect) {
    // Landscape image: padding on top and bottom
    const actualHeight = mapWidth / imageAspect;
    const verticalPadding = (mapHeight - actualHeight) / 2;
    // ... adjust bounds
} else {
    // Portrait image: padding on left and right
    // ... adjust bounds
}

Problem: This method gets close to the correct position, but slight misalignment occurs because it does not account for the reduced image sizes used internally by Leaflet-IIIF.

The Correct Solution: Reproducing Leaflet-IIIF’s _fitBounds Method

Analyzing the Leaflet-IIIF Source Code

Looking at Leaflet-IIIF’s _fitBounds method reveals the following logic for positioning the image:

_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);
}

Key points:

  1. _getInitialZoom() calculates the optimal zoom level
  2. _imageSizes[initialZoom + offset] retrieves the reduced image size at that zoom level
  3. Coordinate conversion is performed with pointToLatLng() using that reduced size

The Correct Implementation

// This is the correct solution
function imageToLatLng(imgX, imgY) {
    // 1. Get Leaflet-IIIF's initial zoom level
    const initialZoom = iiifLayer._getInitialZoom(map.getSize());

    // 2. Calculate the offset
    const offset = iiifLayer._imageSizes.length - 1 - iiifLayer.options.maxNativeZoom;

    // 3. Get the reduced image size at that zoom level
    const imageSize = iiifLayer._imageSizes[initialZoom + offset];

    // 4. Normalize original image coordinates to the 0-1 range
    const normX = imgX / iiifLayer.x;  // iiifLayer.x = original image width
    const normY = imgY / iiifLayer.y;  // iiifLayer.y = original image height

    // 5. Convert to coordinates in the reduced image
    const scaledX = normX * imageSize.x;
    const scaledY = normY * imageSize.y;

    // 6. Convert to Leaflet coordinates (same method as Leaflet-IIIF)
    return map.options.crs.pointToLatLng(L.point(scaledX, scaledY), initialZoom);
}

Complete Implementation Example

let map;
let iiifLayer;

async function init() {
    try {
        // Load the manifest
        const manifestUrl = '../data/manifest_v3_compact.json';
        const response = await fetch(manifestUrl);
        const manifest = await response.json();

        // Get canvas and image information
        const canvas = manifest.items[0];
        const paintingAnno = canvas.items[0].items[0];
        const imageServiceId = paintingAnno.body.service[0].id;

        // Create the map
        map = L.map('map', {
            center: [0, 0],
            crs: L.CRS.Simple,
            zoom: 0
        });

        // Add the IIIF image layer
        iiifLayer = L.tileLayer.iiif(imageServiceId + '/info.json').addTo(map);

        // Annotation data
        const annotationPage = canvas.annotations[0];

        // Wait for the IIIF image to load
        setTimeout(() => {
            const imageWidth = iiifLayer.x;
            const imageHeight = iiifLayer.y;

            // Reproduce Leaflet-IIIF's internal logic
            const initialZoom = iiifLayer._getInitialZoom(map.getSize());
            const offset = iiifLayer._imageSizes.length - 1 - iiifLayer.options.maxNativeZoom;
            const imageSize = iiifLayer._imageSizes[initialZoom + offset];

            // Conversion function from image coordinates to map coordinates
            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);
            }

            // Draw annotations
            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(`
                    ${anno.body.value}
                    ID: ${index + 1}
                `);
            });

        }, 2000);  // Wait 2 seconds (for image loading)

    } catch (error) {
        console.error('Error:', error);
    }
}

document.addEventListener('DOMContentLoaded', init);

Why This Method Is Correct

Image Sizes per Zoom Level

Leaflet-IIIF manages images at multiple zoom levels for performance:

// Example: for a 43890 x 38875 image
_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)
]

The Role of initialZoom

_getInitialZoom() calculates the optimal zoom level for the map size:

_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;  // Default
}

At this zoom level, the image is positioned to fit exactly within the map.

The Meaning of offset

The offset adjusts the correspondence between the _imageSizes array index and the zoom level:

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

For example, with maxNativeZoom = 8 and _imageSizes.length = 9, offset = 0.

Debugging Tips

If coordinate conversion is not working correctly, output the following information to the console for verification:

console.log('Original image size:', 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);

// Test the four corners
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 (bottom-left):', sw);
console.log('NE (top-right):', ne);

Summary

To accurately display IIIF annotations in Leaflet-IIIF:

  1. Understand Leaflet-IIIF’s internal logic: How the _fitBounds method positions the image
  2. Use the reduced image size: Use _imageSizes[initialZoom + offset], not the original image size
  3. Convert at the same zoom level: Convert using pointToLatLng(point, initialZoom)

This method allows annotations to be placed with pixel-level accuracy.

References

Project Information

  • Libraries used:
    • Leaflet 1.9.4
    • Leaflet-IIIF 3.0.0
  • Created: October 19, 2025