Skip to content

fix(server-rs): derive info.json scaleFactors/sizes from one tile-grid pyramid - #785

Merged
subotic merged 2 commits into
mainfrom
worktree-info-json-fix
Aug 19, 2026
Merged

fix(server-rs): derive info.json scaleFactors/sizes from one tile-grid pyramid#785
subotic merged 2 commits into
mainfrom
worktree-info-json-fix

Conversation

@subotic

@subotic subotic commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Fixes DEV-6981

Motivation

SIPI's IIIF info.json advertised scaleFactors as consecutive ordinals
([1,2,3,4]) instead of powers of two, and a sizes array that described a
different pyramid than scaleFactors. OpenLayers derives
maxZoom = round(log2(max(scaleFactors))) and keeps every resolution, so any
level beyond maxZoom gets no tile URL — assets black out at deep zoom (root
cause of DEV-6933). OpenSeadragon masks the defect by discarding our sizes and
rebuilding its own power-of-two pyramid, so OSD rendering correctly was never
evidence the descriptor was sound.

This is a descriptor-only fix. The server already serves every level (all
HTTP 200; SipiSize::REDUCE resamples on the fly), so correcting the advertised
arrays repairs the entire existing JP2 corpus with no re-encoding. It also
lands the pyramid contract the pyramidal-TIFF writer will later produce by
construction (project T1).

Summary

  • scaleFactors are now contiguous powers of two [2^0..2^n] where the deepest
    factor puts the whole image inside a single tile on both axes.
  • sizes and scaleFactors are derived from one function, so they describe the
    same pyramid (sizes.length == scaleFactors.length); both emitted ascending.
  • Untiled images (tile_width == 0) derive sizes from a 512 reference tile and
    keep tiles omitted.

Key Changes

src/server-rs/src/info.rs

  • New pyramid_scale_factors(w, h, tile_w, tile_h) — the single source of truth,
    n = max(levels_w, levels_h), u64 shift so a deep pyramid can't overflow.
  • Replaced size_pyramid() with sizes_for(w, h, &scale_factors) — a pure
    transform of the factor list (native-inclusive, ascending), so the two arrays
    cannot drift apart.
  • image_info_json computes the factor list once and feeds both sizes and the
    tiles block from it; the clevels read and the ordinal (1..cnt).collect()
    are gone (clevels was the source of the bug and is no longer consulted for the
    descriptor).
  • tiles[].width/height still pass through the stored tile size verbatim (no
    regression of report finding 1).

Tests

  • Updated the two defect-encoding fixtures to the corrected pyramid:
    inline info_json_matches_lena512_golden (now scaleFactors [1], sizes [{512,512}]) and the e2e golden snapshot
    iiif_compliance__info-json-lena512.snap. Reviewer: confirm these are the
    corrected pyramid, not a re-blessed bug.
  • Updated untiled_image_omits_tiles for the 512-reference ladder.
  • New descriptor_conformance test pins acceptance criteria 1–5 plus §5.4 tile
    uniqueness across a deep pyramid (3505×5156/1024 → [1,2,4,8]), a single-tile
    image (512×512/512 → [1]), and a non-square-tile case (4096×4096, 1024×256 →
    [1,2,4,8,16]).

Challenges and Decisions

Local Bazel gates could not run

Problem: the local Bazel graph failed to fetch Kakadu — gh release download kakadu-v8.7 from the private dasch-swiss/dsp-ci-assets repo hit a stable REST
404 on GitHub's release-by-tag endpoint, even though GraphQL (gh release list) saw the release and repo access was fine (a concurrent gh api user
returned 503, so GitHub's release service looked degraded). Every gate
(bazel-test-unit, bazel-test-e2e, bazel-rustfmt-check, bazel-clippy-check)
configures //src/server-rs:lib, which transitively depends on Kakadu, so all
four were unrunnable.
Solution: the change is pure Rust arithmetic. Verified the pyramid math
standalone with rustc against hand-computed expected values (lena512, the 0803
deep-pyramid worked example, the untiled ladder, and the non-square-tile case all
match). just commit-lint passed. CI runs the full gate set on this PR.

Coupling sizes to scaleFactors is convention, not spec

Problem: the IIIF spec (§5.9) deliberately ships two different ladders, so
coherence is not a MUST.
Solution: we couple them anyway because it is what makes OpenSeadragon use
our sizes (OSD keeps sizes only when sizes.length ∈ {maxLevel, maxLevel+1},
verified against OSD v5.0.1). Including the native size makes our count
n+1 = maxLevel+1, satisfying the check. §5.3 already requires the server to
serve any advertised size — verified HTTP 200 — so coupling adds no capability the
server lacks.

Gotchas

  • The two updated fixtures encoded the defect. Do not "fix" the snapshot back
    to [1,2,3,4,5,6,7] — that is the bug. descriptor_conformance pins 2^i to
    prevent regression.
  • clevels stays in the FFI struct (SipiImageDims, ABI-asserted in
    ffi.rs); this change only stops info.rs from consulting it. No FFI change.
  • OpenLayers is the conformance canary, not OpenSeadragon — OSD masks the bug.
    Verify a previously-blacking-out asset against OL locally (just run + OL
    viewer) before relying on the exact orphaned-level count.

Test Plan

  • Pyramid arithmetic verified standalone with rustc (all cases match the
    plan's worked examples)
  • just commit-lint — green
  • CI: bazel-test-unit, bazel-test-e2e, bazel-rustfmt-check,
    bazel-clippy-check (deferred to CI; blocked locally by the Kakadu fetch)
  • Post-merge: OpenLayers reaches native zoom on an 0803-class asset with no
    orphaned levels (needs running SIPI + real asset)
  • Post-merge: stage corpus sweep — no asset advertises non-power-of-two
    scaleFactors or a sizes/scaleFactors length mismatch (operator, read-only)

@linear-code

linear-code Bot commented Aug 17, 2026

Copy link
Copy Markdown

DEV-6981

@subotic
subotic force-pushed the worktree-info-json-fix branch 2 times, most recently from b9c6a22 to ec0ed00 Compare August 18, 2026 07:40
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Overview

Image reference daschswiss/sipi:latest-arm64 daschswiss/sipi:latest
- digest 39e05acccf2b 75cb7501feb8
- tag latest-arm64 latest
- environment production
- provenance 7ee93b4 e8c6323
- vulnerabilities critical: 0 high: 0 medium: 0 low: 0 critical: 0 high: 0 medium: 0 low: 0
- platform linux/arm64 linux/amd64
- size 192 MB 201 MB (+8.3 MB)
- packages 63 63
Base Image distroless/static-debian12:latest distroless/static-debian12:latest
- vulnerabilities critical: 0 high: 0 medium: 0 low: 0 critical: 0 high: 0 medium: 0 low: 0
Labels (1 changes)
  • ± 1 changed
  • 5 unchanged
 org.opencontainers.image.description=IIIF-compatible media server.
 org.opencontainers.image.licenses=AGPL-3.0-only
-org.opencontainers.image.revision=7ee93b4b22e93e60e49663a4b4c13e68bec49df0
+org.opencontainers.image.revision=e8c63231e7d6ceae7324d4ad4dca5a9db285c27e
 org.opencontainers.image.source=https://github.com/dasch-swiss/sipi
 org.opencontainers.image.title=Sipi
 org.opencontainers.image.version=v6.4.0
Policies (0 improved, 0 worsened, 2 missing data)
Policy Name daschswiss/sipi:latest-arm64 daschswiss/sipi:latest Change Standing
Default non-root user ❓ No data ⚠️
No AGPL v3 licenses ❓ No data
No fixable critical or high vulnerabilities ❓ No data
No high-profile vulnerabilities ❓ No data
No outdated base images ❓ No data ❓ No data
No unapproved base images ❓ No data ❓ No data
Supply chain attestations ❓ No data ⚠️ 2

subotic and others added 2 commits August 19, 2026 12:54
…d pyramid

scaleFactors advertised consecutive ordinals ([1,2,3,4]) instead of powers of
two, and sizes described a different pyramid. OpenLayers derives
maxZoom = round(log2(max(scaleFactors))) and orphans deeper levels, so assets
blacked out at deep zoom (DEV-6933); OpenSeadragon silently discarded our sizes
and rebuilt its own pyramid.

Replace the two divergent generators in info.rs with a single pyramid derived
from the tile grid, feeding both arrays from one source of truth:
scaleFactors = [2^0..2^n] where n = max(levels_w, levels_h) puts the whole image
inside one tile on both axes (clamped to 31 so a degenerate 1px tile can't
truncate a factor to zero and divide-by-zero the sizes ladder); sizes = the
native-inclusive dimensions at each factor; both ascending. clevels is no longer
consulted for the descriptor (it was the source of the ordinal bug), and the
FFI-seam comments in ffi.rs / sipi_ffi.h / sipi_ffi.cpp that still described it
as feeding sizes[]/tiles[] are corrected. Untiled images derive sizes from a 512
reference tile and keep tiles omitted.

Descriptor-only fix: the server already serves every level (SipiSize::REDUCE
resamples on the fly, all HTTP 200), so correcting the advertised arrays repairs
the existing corpus with no re-encoding. Updates the two defect-encoding fixtures
(inline lena512 assertion and the e2e golden snapshot) to the corrected pyramid,
and adds a conformance test pinning power-of-two factors, sizes/scaleFactors
coherence (as literals), single-tile coverage, §5.4 tile uniqueness, and the
exponent clamp.

Fixes DEV-6981

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The info.json descriptor now derives its pyramid from the tile grid, so
nothing on the Rust side reads clevels off the seam. Remove it from both
SipiImageDims definitions, its population site, and the ABI guards; the
C++ decode/cache paths keep using the engine's own info.clevels. It was
the last field, so no offsets shift — the struct narrows 24 → 20 bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@subotic
subotic force-pushed the worktree-info-json-fix branch from 91a1a73 to 267eedf Compare August 19, 2026 10:59
@subotic
subotic merged commit 9016a1d into main Aug 19, 2026
8 checks passed
@subotic
subotic deleted the worktree-info-json-fix branch August 19, 2026 11:15
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