Skip to content

slideio formats (CZI, VSI): pyramid levels are displaced against each other, images wobble while tiles load #165

Closed

Description

@maubreville

Bug: slideio-based formats (CZI, VSI): pyramid levels are displaced against each other, images wobble while tiles load

Follow-up to #163 (sub-pixel shift between Deep Zoom levels). #163 fixes the tile geometry; formats read through slideio (Zeiss CZI, Olympus VSI, DICOM) have three further problems of their own. All three show as the same symptom: neighbouring zoom levels are displaced by a fraction of a pixel and the image "wobbles" while the viewer blends levels.

1. Reads at positions that are not a multiple of the downsample are displaced (VSI and CZI)

SlideIOSlide.level_downsamples returns 1 / scale, and slideio derives the scale from the (rounded down) level size, so an exact 16:1 level is reported as 16.0007. The tile generator therefore asks for level-0 positions such as int(16.0007 * ix), which are not multiples of 16. slideio snaps such a region to its own level grid and the result is displaced by up to a level pixel. Measured with a VSI (level 4, 256 px read, compared with level 0 reduced by exactly 16 from the same position):

x0 (level 0) x0 mod 16 displacement
96000 0 0.00 px
96001 1 0.76 px
96004 4 0.91 px
96008 8 1.12 px
96012 12 1.39 px

The levels themselves are exact decimation grids: read at aligned positions, every level of the VSI agrees with the level below it to 0.00 px.

2. A level covers level 0 size / downsample pixels, not its stored size

For the same reason the scale between a slide level and a Deep Zoom level must come from the downsample, not from the stored size (ceil/floor rounding of a 204409 px wide slide gives 12775 px at 16:1, which covers 204400 px). Using the stored size drifts by 0.2-0.5 px between coarse levels at the far end of the slide.

3. CZI pyramids store levels that are displaced against each other

Even with aligned reads, the levels of the CZI test slide are not registered: consecutive levels are displaced by 0.2-0.8 px of the coarser level, in a regular alternating pattern, identical at different positions (so it is a property of the file, not of the read):

level 1 vs reduced level 0: (dy 0.12, dx 0.66)   level 5 vs level 4: (0.25, 0.74)
level 2 vs reduced level 1: (0.17, 0.19)         level 6 vs level 5: (0.26, -0.26)
level 3 vs reduced level 2: (0.76, -0.20)        level 7 vs level 6: (0.24, 0.74)
level 4 vs reduced level 3: (-0.26, 0.25)        level 8 vs level 7: (0.19, -0.20)

Measurements (real server, 3 slides, 2 regions each, 7 level pairs per region)

Average displacement between neighbouring Deep Zoom levels (cross-correlation with sub-pixel refinement, mapping as the viewer does it), with #164 alone and with the fix in the linked PR:

slide #164 only with the fix
CZI A 0.355 px (max 0.56) 0.063 px (max 0.15)
CZI B 0.336 px (max 0.65) 0.095 px (max 0.18)
VSI 0.258 px (max 0.56) 0.041 px (max 0.14)

Possible fixes

  1. (in the PR) Reads: report the exact integer downsample for slideio levels, snap positions to it and request the region as size * downsample. Geometry: derive the Deep Zoom scale from the downsample. Registration: measure the displacement of each level once per image (each level against the level below on up to three textured spots, integer shift search + parabolic refinement, chained to level 0; a level is corrected only if two spots agree), store it, and apply it as an offset of the fractional sampling box. Level 0 is never shifted.
  2. Only the first two points (no stored data, no migration): removes the VSI problem and most of the coarse-level drift, but leaves the CZI displacement.
  3. Re-export the CZI pyramids with a tool that produces registered levels: not something the server can assume.

Where the offsets are stored

New table images_pyramidregistration (one row per image: offsets jsonb, algorithm, file_size, file_mtime); rows are only used for the file version and algorithm version they were measured on. The Rust port of the server (exact-rust) shares the table; the migration creates it with CREATE TABLE IF NOT EXISTS, so either side can create it first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions