A few months back I fell down a rabbit hole reading philo's writeup about building a slit-scan camera out of an industrial line-scan sensor and pointing it out of train and ferry windows. The camera itself is fascinating — a $70 conveyor-belt inspection sensor reading out a single line of pixels 4,000 times a second while a laptop stitches them together as the train moves — but the number that actually stuck with me was the output size. One capture off the San Francisco to Oakland ferry came out at 56,894 by 2,048 pixels. That's a photograph almost 28 times wider than it is tall, and it's literally too wide for a baseline JPEG to hold, since that format's own dimension field tops out at 65,535 pixels and the assembled images kept bumping into that ceiling, which is why the project ended up saving finished images as TIFF instead.

What grabbed me wasn't the capture hardware, though, it was the part of the writeup that got maybe two sentences: actually putting an image like that on a web page where someone can scroll around it. I've hit a smaller version of this problem before on a gallery of scanned museum negatives, and it is genuinely a different discipline from normal image handling. Drop a 100-megapixel TIFF into a plain <img> tag and the tab will sit there decoding it for several seconds, and on a phone it just as often gets killed by the OS before it finishes.

So I spent a weekend building the display side of a project like this myself: slicing a giant image into a pyramid of small tiles with libvips, feeding that pyramid into OpenSeadragon, running into the specific way panoramas break a deep-zoom viewer's default framing, and wiring up URLs that link straight to a spot 40,000 pixels into the image. None of it is complicated in isolation, but there were three or four points where the "obvious" approach quietly falls apart, and I'd rather write those down than rediscover them next time.

The trick to displaying an image that's tens of thousands of pixels wide isn't finding a way to show all of it at once — it's making sure the browser only ever decodes the few hundred kilobytes' worth of pixels that are actually on screen, and convincingly faking the rest until someone zooms in.

Why you can't just drop a 56,894-pixel image in an <img> tag

The instinct to just use a regular <img> tag makes sense, because it's what works for every other photo on the internet. The problem is what the browser has to do behind the scenes to render one. However the file is encoded on disk, the browser eventually has to decode it into an uncompressed bitmap in memory before it can paint a single pixel of it, and that bitmap is a flat function of width times height times channels, with no regard for how well the source file compressed.

For a 56,894x2,048 grayscale image that's about 116 million pixels, which doesn't sound too bad until you remember the browser typically decodes into RGBA regardless of the source format, so you're looking at something north of 450MB of raw bitmap sitting in memory for one photo. Do that twice, or open it on a phone with a tenth of the RAM of your laptop, and you'll see why this falls over.

The nasty part is that this often doesn't fail loudly. The image can appear to load fine, sitting there as a very short, very wide strip, and only when you try to pinch-zoom or scroll does the tab start stuttering or the browser silently discards other tabs to free memory. It looks like a rendering bug rather than what it actually is, which is the browser quietly running out of room.

The fix isn't a smarter image format, it's not sending the whole image to the browser at all. That's the entire premise behind deep-zoom viewers, and it's the same idea flatbed scanner software and mapping tools have used for decades: only ship the pixels that are currently visible, at the resolution they're actually being viewed at.

Tiles, not pixels: what a deep-zoom pyramid actually is

A deep-zoom image isn't one file, it's a directory tree of small JPEG tiles at multiple resolutions, plus a tiny XML file describing how they fit together. The lowest level in the tree is the full 56,894x2,048 image downscaled until it fits in a single tile; each level above that is roughly double the resolution of the one below it, all the way up to the original pixels, chopped into a grid of typically 256x256 squares.

OpenSeadragon, the viewer library the original project used, reads that XML file and figures out which tiles it actually needs based on your current pan and zoom position, then requests just those over HTTP. Zoomed all the way out, it might load a single low-resolution tile for the whole image. Zoomed into one container crane on that ferry photo, it loads maybe a dozen full-resolution tiles covering that crane and nothing else. Your browser never has to hold more than a few megapixels in memory regardless of how big the source image actually is, because it's only ever looking at a small, constantly-swapped window into the whole thing.

This format predates OpenSeadragon by a while — it's the same Deep Zoom Image (DZI) convention that Microsoft's Seadragon and Silverlight tools used, and it's close enough to the Zoomify and IIIF conventions that most tiling tools can target any of the three. I stuck with plain DZI because it's the one libvips writes out of the box, and I had no reason to make my life harder.

Turning a giant TIFF into a tile pyramid with libvips

Generating that tile pyramid from a 56,894x2,048 TIFF turned out to be a one-line problem, which was a relief after everything else about this project. libvips ships a command-line tool called dzsave built for exactly this, and it's deliberately frugal with memory since it streams the image rather than loading the whole thing at once:

vips dzsave ferry-oakland.tif ferry-oakland \
  --layout dz \
  --tile-size 256 \
  --overlap 1 \
  --suffix .jpg[Q=82]

That produces ferry-oakland.dzi, a small XML file describing the pyramid's dimensions and tile layout, alongside a ferry-oakland_files directory holding one subfolder per zoom level, each full of 256-pixel JPEG tiles. Running that against the ferry-sized TIFF took under thirty seconds on my laptop and used a few hundred megabytes of RAM the entire time, which was a nice contrast to the postprocessing step upstream of it that reportedly took hours and occasionally failed outright because the finished image was too big for the output format to even save.

The one setting worth actually thinking about is tile size, because it's a straight trade-off between the number of HTTP requests a client makes and how much of a tile gets wasted redownloading when only part of it is newly visible. I went with 256 pixels, which is the DZI default and works fine on a normal broadband connection, but if I were serving this to people on flaky mobile connections I'd size up to 512 to cut the request count roughly fourfold at the cost of slightly chunkier downloads per pan.

Wiring OpenSeadragon up to a 27-to-1 aspect ratio image

Pointing OpenSeadragon at the pyramid is normally a five-minute job. You give it a container element and a tileSources object describing the DZI, and it handles tile loading, panning, and pinch-zoom for you. Here's the setup I started with, lifted almost directly from the library's own docs:

const viewer = OpenSeadragon({
  id: "linecam-viewer",
  prefixUrl: "/openseadragon/images/",
  tileSources: {
    Image: {
      xmlns: "http://schemas.microsoft.com/deepzoom/2008",
      Url: "/tiles/ferry-oakland_files/",
      Format: "jpg",
      Overlap: "1",
      TileSize: "256",
      Size: { Width: "56894", Height: "2048" }
    }
  }
});

That loaded tiles correctly, but the initial "home" view looked broken: a thin horizontal ribbon of image floating dead center in a mostly empty gray box, zoomed so far out you couldn't tell it was a ferry photo at all. OpenSeadragon's default home behavior fits the entire image inside the viewer while preserving its aspect ratio, which for a normal 3:2 photo looks fine, but for something 27.8 times wider than it is tall in a viewer div that isn't itself absurdly wide, you get enormous letterboxing above and below a sliver of actual content.

The fix that worked for me was giving the container an aspect ratio close to the image's own instead of a generic rectangle, and telling the viewport to fill that container on load rather than fit the whole image inside it with padding:

const viewer = OpenSeadragon({
  id: "linecam-viewer",
  prefixUrl: "/openseadragon/images/",
  homeFillsViewer: true,
  minZoomImageRatio: 0.1,
  tileSources: {
    Image: {
      xmlns: "http://schemas.microsoft.com/deepzoom/2008",
      Url: "/tiles/ferry-oakland_files/",
      Format: "jpg",
      Overlap: "1",
      TileSize: "256",
      Size: { Width: "56894", Height: "2048" }
    }
  }
});

homeFillsViewer tells OpenSeadragon that the home position should fill the container completely rather than fit the whole image with letterboxing, which for a panorama means the initial view crops off the far left and right rather than shrinking everything to an unreadable sliver. Combined with a container that's already wide and short via CSS, that gave visitors an actually legible chunk of the photo the moment the page loaded, with the rest a scroll away. It's a compromise — nobody sees the entire 56,894-pixel width at once on a normal monitor no matter what you do — but a readable crop beats a technically-complete thumbnail nobody can parse.

Deep-linking into an image that's 56,000 pixels wide

Once the viewer worked, I wanted to be able to link a friend to the exact spot in the photo I was talking about, the way you'd link to a timestamp in a video. OpenSeadragon expresses your current position as viewport coordinates rather than raw pixels — a normalized rectangle where the image's width is always 1.0 — and it turns out there's already a small official plugin, openseadragon/bookmark-url, built around exactly this idea, which was a good sign I wasn't inventing something silly.

I ended up writing my own minimal version rather than pulling in the plugin, mostly so I could control the exact hash format used in the gallery links on the page:

function boundsToHash(viewport) {
  const b = viewport.getBounds();
  return `#x=${b.x.toFixed(4)}&y=${b.y.toFixed(4)}&w=${b.width.toFixed(4)}`;
}

function hashToBounds(hash) {
  const params = new URLSearchParams(hash.slice(1));
  if (!params.has("x")) return null;
  return new OpenSeadragon.Rect(
    parseFloat(params.get("x")),
    parseFloat(params.get("y")),
    parseFloat(params.get("w")),
    parseFloat(params.get("w")) * (2048 / 56894)
  );
}

viewer.addHandler("open", () => {
  const initial = hashToBounds(location.hash);
  if (initial) viewer.viewport.fitBounds(initial, true);
});

viewer.addHandler("animation-finish", () => {
  history.replaceState(null, "", boundsToHash(viewer.viewport));
});

I only need three numbers because I never let anyone rotate these images, so height falls out of width and the fixed aspect ratio. On load, if the hash has coordinates in it, I skip straight to that viewport rectangle instead of the home view, and every time an animation finishes I quietly rewrite the URL so the current view is always shareable without the visitor having to click anything. The one gotcha was debouncing that animation-finish handler, since firing history.replaceState on every single frame of a zoom animation is wasteful, but the event only fires once the animation settles, so it turned out I got that for free.

Was the extra complexity worth it

It's worth being honest that a tiled deep-zoom viewer is a lot more moving parts than a photo on a web page has any right to need: a build step, a tile directory with a few thousand small files in it, and a JavaScript library to wire up. For a normal photo gallery, none of this is worth it. For something in this specific size range, the comparison against just serving the raw file looks pretty stark once you write it down.

ApproachInitial page weightPeak memory while zoomed inDeep linking
Raw TIFF/JPEG in an <img> tagTens of megabytes, all at once400MB+ decoded bitmap, regardless of what's visibleNot really possible
Tiled DZI + OpenSeadragonA few tiles worth, tens of kilobytesRoughly bounded by viewport sizeA few numbers in the URL hash

The gap only gets more lopsided as the image gets wider, which is exactly the direction this kind of slit-scan photography pushes things. A 56,894-pixel image is already past the point where the naive approach works reliably on a phone, and there's nothing stopping a longer capture, or a longer train ride, from producing something twice that width.

What I'd change next time

If I were doing this again for a real gallery rather than a weekend experiment, I'd generate two tile pyramids per image up front instead of one, with different JPEG quality settings for the lowest zoom levels versus the highest, since the far-zoomed-out tiles get requested by literally everyone who opens the page while the full-resolution tiles only get pulled by someone who actually zooms in on a container crane. Right now I'm shipping the same quality setting across the whole pyramid, which wastes bytes on levels almost nobody looks at closely.

I'd also stop assuming every image has the same fixed aspect ratio hardcoded into the deep-link parser, since that was a shortcut that'll bite me the moment I try to reuse this for a color capture with a different sensor width. None of that changes the core approach, though. Tiling the image and letting a viewer library decide what to fetch is clearly the right shape for this problem; the rest is just cleanup I didn't get to on a Sunday afternoon.