OpenSeaDragon Zoom Levels

An old man is sitting at a table, bent over a piece of paper showing the website of the OpenSeaDragon project. He holds a magnifying 
glass to inspect the paper more closely.

I played a bit with OpenSeaDragon, a JavaScript widget for tiled deep-zoom images. I wanted to create custom tiles for different levels, but failed to get the algorithm right. Now it's working and these are my findings.

OpenSeaDragon is a library that can show deep zoom images. Imagine you have a very large image, and you can to show it in the browser. It probably does not make sense to load the whole image into the browser, because the user may not want to see it in full detail. And if the user zooms into the image, the browser does not need to load all the parts that are outside the viewport. It only needs to load high resolution images of the part that is displayed.

This is usually done with tiles. We divide the image into tiles of a certain size and at multiple zoom levels. OpenSeaDragon can then decide which tiles it needs to load. For that, it supports a variety of standards, but since I want to write a dynamic backend for delivering the tiles, I tried to go with the custom tile sources approach.

OpenSeaDragon configuration

Imaging the following image:

A placeholder image showing the labels 10000x1500

If you can provide a backend that delivers the correct tiles of the image, you can use OpenSeaDragon like this:

<div id="mapContainer" style="width: 600px; height: 300px;"></div>
import OpenSeaDragon from "openseadragon";

OpenSeaDragon({
  element: document.getElementById("mapContainer"),
  debugMode: false,
  prefixUrl:
    "https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.4.2/images/",
  tileSources: [
    {
      width: 5000,
      height: 1500,
      tileSize: 256,
      getTileUrl: function (level, x, y) {
        return `/tile.webp?level=${level}&x=${x}&y=${y}`;
      },
    },
  ],
});

We assume that the tile.webp endpoint returns the correct part of the image based on level, x, and y. But which part should it return?

Zoom levels

I got this wrong for a long time. In particular, I was wondering why OpenSeaDragon never queried with zoom level 0 or 1. It always used 9 or 10. At some I asked Gemini the right question. Here is my personal write-up on this:

Powers of two

Zoom levels are powers of 2. If a tile of level 9 covers an area of 4096x4096 pixels in the input image, a tile on level 10 covers 2048x2048. The size in the source image is half as big, but of course the scaled-down size remains.

What is zoom level 1?

Zoom level 0 means that the whole image is compressed in to single pixel. Not very useful, but a good definition for a starting point. It has nothing to do with the tile size, nor with the viewport size. Although we will see that OpenSeadragon uses the viewport size to determine the initial zoom level.

So, for our 5000x1500 pixel image, we would have the following tiles at different levels:

Zoom levelScaled image widthScaled image heightZoom factor
0110,0002
1210,0004
2420,0008
3830,0016
41650,0032
532100,0064
664200,0128
7128390,0256
8256770,0512
95121540,1024
1010243080,2048
1120486150,4096
12409612290,8192
13819224581,6384

The width’s of the image at different levels are exactly the powers of 2, because the width is the longer edge of our image. The height depends on the aspect ratio (but not on the image size).

As you can see, zoom level 13 means that the displayed image is actually larger than the original, which means that we will see the pixels if we zoom in any further.

This is also the point where OpenSeadragon stop zooming in.

Which initial zoom level does OpenSeaDragon use?

OpenSeadragon attempts to show the complete image to the user. I will choose the first zoom level for which the scaled image is larger than the viewport. Then it will scale down the image to the viewport.

In our case, it will request tiles for the zoom level 10.

Correction

What I just wrote is not entirely true. The network tab in the browser dev tools shows otherwise. There is network request for zoom level 9 as well. I believe this is to show an initial image quickly. If the requests for tiles at level 10 fail, this image will also stay in the viewport.

How can I compute the area that my tile will take.

For my backend, I came up with the following algorithm to compute the correct bounds of a tile.


const imageWidth = 5000
const imageHeight = 1500
const tileSize = 512

function getTile(level: number, x: number, y: number) {
  // The longer edge of our image is the interesting one
  const maxSize = Math.max(this.width, this.height);
  // Find the level that brings the whole image to size 1
  const baseLevel = Math.ceil(Math.log2(maxSize))
  // Then compute the zoom to get our image to the image size
  // that matches the level (see table above)
  const zoom = Math.pow(2, level - baseLevel);
  // How big is a tile in the image's coordinate system?
  const zoomedTileSize = this.tileSize / zoom;

  // Compute the top left corner of the tile, in the image's coordinate system
  const tileX = x * zoomedTileSize;
  const tileY = y * zoomedTileSize;

  // If the tile is outside the image, we need special treatment
  if (tileX >= this.width || tileY >= this.height) return null

  // Make sure the tile does not go over the right or lower edge of the image. 
  const tileWidth  = Math.min(zoomedTileSize, this.width - tileX)
  const tileHeight  = Math.min(zoomedTileSize, this.height - tileY)
  
  return {
    extract: { left: tileX, top: tileY, width: tileWidth, height: tileHeight },
    // What is the target size of the tile? Usually, that is 512x512
    // but if the tile hits the right or lower edge, it is smaller, so that
    // target size is also smaller.
    resizeTo: { width: tileWidth * zoom, height: tileHeight * zoom}
  }
}

With that information, we can use sharp.js to extract and resize the tile.

Round

The sharp.js, you need to round the resulting values. Otherwise, you will get errors.

Conclusion

I have outlined and explained the levels of tiled images and how to compute the tile for use with OpenSeadragon’s custom tile source. You may have noticed that the tile size does not have anything to do with the computation of the initial level and with the image size after scaling.

Also, the size of an image at a given zoom level is only indirectly related to the original image size. The aspect ratio is the relevant factor, not the size. A 10000x3000 pixel image has that same target size at level 8 as a 5000x1500 pixel image.

This is mostly a note for myself, but I hope you find it helpful as well.