Skip to content

Keep Deep Zoom levels registered (fix sub-pixel shift between zoom levels) - #164

Merged
maubreville merged 3 commits into
masterfrom
fix-deepzoom-level-registration
Oct 27, 2026
Merged

maubreville merged 3 commits into
masterfrom
fix-deepzoom-level-registration

Conversation

@maubreville

Copy link
Copy Markdown
Collaborator

Fixes #163.

What

zDeepZoomGenerator.get_tile now samples every tile from a fractional source box instead of shrinking an integer region with Image.thumbnail:

  • the tile rectangle (in Deep Zoom pixels, including the 1 px overlap) is converted to slide-level pixels with the true scale level_size / slide_level_size;
  • the covering integer region is read from the slide handler as before (frame/plane arguments untouched, ICC profile kept, background compositing kept);
  • tile.resize(z_size, LANCZOS, box=...) resamples the fractional box to exactly the tile size (no extra pass when the region already fits).

Tiles therefore always have the size the Deep Zoom grid expects and all tiles of a level share the same scale, so neighbouring levels stay registered. See #163 for the cause, measurements and alternatives.

Measured

Real server, PNG 1920×1371, level 8: shift against a reference reduction −0.85 px → −0.01 px; PNG 1969×4030, levels 8–10: up to −0.39 px → ≤ 0.01 px, mean absolute difference 10–12 → 4 grey levels. NDPI, MRXS, TIFF, MP4 and NIfTI images still serve tiles.

Test

exact/util/test_deepzoom_registration.py (plain unittest, no database): synthetic slides of 1920×1371 and 1969×1301 px; for five levels each it asserts the exact size of every tile and the registration of the level mosaic against a reference reduction (< 0.15 px, sub-pixel cross-correlation). Fails on master ((240, 171) != (240, 172), …), passes with this change.

cd exact && python -m unittest util.test_deepzoom_registration

Notes

  • Needs Pillow ≥ 4.3 for resize(box=...); the requirements pin ≥ 10.3.
  • Relies on the same private DeepZoomGenerator attributes the class already depends on (_get_tile_info, _z_dimensions, _l_dimensions, _slide_from_dz_level, _l0_l_downsamples, _l0_offset, _z_t_downsample, _z_overlap).
  • Tiles are cached by the tile cache; existing cached tiles keep the old geometry until they expire or the cache is cleared.

🤖 Generated with Claude Code

…ce box

zDeepZoomGenerator.get_tile read an integer region and shrank it with
Image.thumbnail, which keeps the region's aspect ratio. The last tile row and
column of a level could come out one pixel smaller than the Deep Zoom grid
expects, and their scale differed slightly from the other tiles, so
neighbouring levels were displaced by up to ~0.9 px (visible as wobbling while
the viewer blends levels).

Tiles are now sampled from a fractional source box with the true scale between
the level and the slide level (Image.resize(..., box=...)) and always have
exactly the grid size. Adds a test that builds synthetic slides and checks tile
sizes and the registration of each level against a reference reduction.

Fixes #163

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Levels of slideio images were displaced against each other by up to ~0.65 px,
which shows as wobbling while the viewer blends zoom levels:

- slideio reports 1 / scale (16.0007 for a 16:1 level) but the levels are exact
  decimation grids; reads at positions that are not multiples of the downsample
  are displaced by up to a level pixel. SlideIOSlide now reports the exact
  integer downsample, snaps positions to it and requests size * downsample.
- the scale from a slide level to a Deep Zoom level is derived from the
  downsample (level 0 size / downsample) instead of the stored level size.
- CZI pyramids store levels that are displaced against each other (0.2-0.8 px).
  util.registration measures the displacement once per image (each level
  against the level below, three textured spots, parabolic refinement, chained
  to level 0, a level is corrected only if two spots agree) and get_tile
  applies it as an offset of the fractional sampling box. Level 0 is never
  shifted.

The offsets are stored in the new table images_pyramidregistration (with the
file size/mtime and the algorithm version they were measured on). The table is
shared with the Rust port of the server, which creates it with the same DDL;
the migration uses CREATE TABLE IF NOT EXISTS so either side can create it first.

Fixes #165

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ation

Register the pyramid levels of slideio formats (CZI, VSI)
@maubreville
maubreville merged commit 3870fa4 into master Oct 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Neighbouring Deep Zoom levels are displaced by up to ~0.9 px (images wobble while tiles load)

1 participant