Skip to content

Register the pyramid levels of slideio formats (CZI, VSI) - #166

Merged
maubreville merged 1 commit into
fix-deepzoom-level-registrationfrom
fix-slideio-level-registration
Oct 27, 2026
Merged

maubreville merged 1 commit into
fix-deepzoom-level-registrationfrom
fix-slideio-level-registration

Conversation

@maubreville

Copy link
Copy Markdown
Collaborator

Fixes #165. Stacked on #164 (it changes the same function): the base of this PR is that branch, merge #164 first.

What

Three fixes for slideio-based formats (CZI, VSI, DICOM), all aimed at the displacement between neighbouring zoom levels (see the issue for measurements):

  1. Aligned reads (util/slideio.py): level_downsamples reports the exact integer downsample when the level is within 1 % of one (slideio reports 16.0007 for an exact 16:1 level); read_region snaps the position to it and requests the region as size * downsample. slideio snaps a region to its level grid, so unaligned positions were displaced by up to a level pixel.
  2. Level extent (zDeepZoomGenerator.get_tile): the scale from slide level to Deep Zoom level is derived from the downsample (level 0 size / downsample pixels of extent) instead of the stored size of the level.
  3. Pyramid registration (util/registration.py): some CZI pyramids store levels displaced by 0.2-0.8 px against each other. The displacement is measured once per image and applied as an offset of the fractional sampling box in get_tile:
    • each level is compared with the level below it (reduced by the integer ratio) on up to three textured spots (4x4 cells of the smallest level ranked by contrast), integer shift search (±3 px) with parabolic refinement, chained down to level 0;
    • a level is corrected only if the two closest spots agree within 0.15 px; offsets below 0.04 px are treated as noise;
    • level 0 (full resolution) is never shifted: its offset is fixed to (0, 0), get_tile ignores offsets for slide level 0, and a test checks that level-0 tiles are bit-identical even with an absurd offset;
    • formats whose levels are registered (needs_registration is only set on SlideIOSlide) are not measured; VSI measured zero offsets.

Database

New model images.PyramidRegistration (table images_pyramidregistration): image (OneToOne), offsets (JSON, [dx, dy] per level in pixels of that level), algorithm, file_size, file_mtime, updated. A row is only used if size and mtime of the file and the ALGORITHM version match; otherwise the image is measured again, so replacing a file or improving the algorithm needs no manual step. Measuring takes 1.6-5 s once per image (first tile/DZI request), afterwards the offsets come from the database.

Migration 0041_pyramidregistration creates the table with CREATE TABLE IF NOT EXISTS inside SeparateDatabaseAndState. The table is shared with the Rust port of the server (exact-rust), which creates the same table with the same DDL; whichever side runs first creates it, the other is a no-op, and the columns are identical either way (checked in both orders by scripts/check-schema-sync.sh in exact-rust, which builds two databases from a backup and compares the result column by column). makemigrations --check reports no changes.

Deployment: python manage.py migrate.

Measured

Real server, three slides (two CZI, one VSI), two regions each, 7 level pairs per region; average displacement between neighbouring levels: CZI A 0.355 → 0.063 px, CZI B 0.336 → 0.095 px, VSI 0.258 → 0.041 px (max over all pairs 0.65 → 0.18 px), compared with #164 alone. Tiles of the other formats (NDPI, MRXS, TIFF, OME-TIFF, MP4, NIfTI, MKT) still match the previous output.

Tests

  • util/test_registration.py (no database): the estimator on synthetic pyramids with a known displacement (recovers (-0.6, +0.4) within 0.15 px), a registered pyramid gets no offsets, a single level has nothing to register; and the generator: offsets move level > 0 but leave level-0 tiles bit-identical.
  • exact/images/test_pyramid_registration.py (Django TestCase): measured once then read from the database; measured again when mtime or algorithm version change; registered formats are never measured or stored.
  • Existing exact.images tests: no new failures (ImageRegistrationTestCase already fails in setUp with the installed django-passkeys: "request is required for passkeys.backend.PasskeyModelBackend").

Notes

  • slide.level_offsets is set once per cached slide (slide_for_image, used by the tile and Deep Zoom views and update_image_cache); thumbnails and slide_information do not need it.
  • Multi-file z-stacks (depth > 1) are not measured (offsets are per image, files differ per z).
  • Tiles already in the tile cache keep the old geometry until they expire or the cache is cleared.

🤖 Generated with Claude Code

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>
@maubreville

Copy link
Copy Markdown
Collaborator Author

tested. works

@maubreville
maubreville merged commit 449d3dc into fix-deepzoom-level-registration 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.

1 participant