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:
- Uses the
CRS.Simplecoordinate reference system - Manages the image at multiple zoom levels with reduced sizes
- 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:
_getInitialZoom()calculates the optimal zoom level_imageSizes[initialZoom + offset]retrieves the reduced image size at that zoom level- 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:
- Understand Leaflet-IIIF’s internal logic: How the
_fitBoundsmethod positions the image - Use the reduced image size: Use
_imageSizes[initialZoom + offset], not the original image size - 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