diff --git a/CHANGELOG.md b/CHANGELOG.md index e8272cf..caa9628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to wsi-tools will be documented here. The format is loosely ## [Unreleased] +### Fixed + +- **Corrupt edge tiles in re-encoded SVS/TIFF/OME-TIFF/COG-WSI/IFE output (TIFF + conformance).** The retile engine encoded partial right/bottom edge tiles — and + whole pyramid levels smaller than one tile — at their truncated content size + instead of the full declared `TileWidth×TileLength`. TIFF requires uniform + full-size tiles (pixels beyond `ImageWidth/Length` are padding, ignored by + readers); OpenSlide and ImageScope reject a sub-full-size JPEG tile as a + "dimensional mismatch", rendering black/garbled edges (opentile is lenient and + masked it). Edge tiles are now padded up to the full tile size (last row/column + edge-replicated to avoid JPEG bleed) before encoding, across every engine-backed + re-encode path (`convert --to svs|tiff|ome-tiff|cog-wsi`, `--factor`, + `downsample`, `crop`, `--to ife`). DZI/SZI legitimately use partial edge tiles + and are unaffected. Verified pixel-faithful against OpenSlide on a Ventana BIF + source. +- **A pyramid level dropped in ImageScope for `--to svs` from a source without a + thumbnail (BIF, IFE/Iris, …).** Genuine Aperio SVS always carries the thumbnail + as the second IFD; ImageScope classifies IFD 1 positionally as the thumbnail, so + when no thumbnail was emitted the first reduced pyramid level landed at IFD 1 and + ImageScope dropped it (e.g. a 1×/4×/16×/64× source showed only 1×/16×/64×). + `convert --to svs` now synthesizes an Aperio thumbnail (longest edge 1024px, + baseline JPEG, rendered from L0) at IFD 1 when the source has none, matching the + genuine Aperio layout; sources that already carry a thumbnail are unchanged. + ## [0.24.0] - 2026-06-27 ### Added diff --git a/cmd/wsitools/associated_rebuild_ometiff.go b/cmd/wsitools/associated_rebuild_ometiff.go index c960189..9379ed6 100644 --- a/cmd/wsitools/associated_rebuild_ometiff.go +++ b/cmd/wsitools/associated_rebuild_ometiff.go @@ -71,7 +71,9 @@ func rebuildOMETIFF(src source.Source, outPath string, plan omeEditPlan, fsync b if err != nil { return fmt.Errorf("create output: %w", err) } - if err := writeTIFFTileCopy(w, src, "ome-tiff", l0Desc, true /*omeSynthetic*/, plan); err != nil { + // nil slide: OME-TIFF doesn't classify IFD 1 positionally, so no thumbnail + // synthesis (it's SVS-only); the existing pyramid is copied verbatim. + if err := writeTIFFTileCopy(w, src, "ome-tiff", l0Desc, true /*omeSynthetic*/, plan, nil); err != nil { w.Abort() os.Remove(tmp) return err diff --git a/cmd/wsitools/associated_rebuild_tiff.go b/cmd/wsitools/associated_rebuild_tiff.go index 353096d..7a1b833 100644 --- a/cmd/wsitools/associated_rebuild_tiff.go +++ b/cmd/wsitools/associated_rebuild_tiff.go @@ -60,7 +60,10 @@ func finalizeRebuild(src source.Source, outPath, container, l0Desc string, omeSy if err != nil { return fmt.Errorf("create output: %w", err) } - if err := writeTIFFTileCopy(w, src, container, l0Desc, omeSynthetic, plan); err != nil { + // nil slide: the associated-image edit rebuilds an existing file faithfully + // and must not synthesize a new thumbnail IFD; an SVS source that already has + // a thumbnail keeps it (emitted from src.Associated()). + if err := writeTIFFTileCopy(w, src, container, l0Desc, omeSynthetic, plan, nil); err != nil { w.Abort() os.Remove(tmp) return err diff --git a/cmd/wsitools/convert_factor.go b/cmd/wsitools/convert_factor.go index b96032d..9f4497e 100644 --- a/cmd/wsitools/convert_factor.go +++ b/cmd/wsitools/convert_factor.go @@ -1064,7 +1064,7 @@ func buildEnginePyramidCOGWSI(ctx context.Context, slide *opentile.Slide, w *cog } sink := newCogwsiSink(handles, levels) - return runEngineRetile(ctx, slide, srcRegion, outL0, levels, &codecTileEncoder{enc: enc}, sink, workers) + return runEngineRetile(ctx, slide, srcRegion, outL0, levels, &codecTileEncoder{enc: enc, tileW: outTile, tileH: outTile}, sink, workers) } // buildPyramidFromRasterCOGWSI encodes an in-memory RGB888 L0 raster into a diff --git a/cmd/wsitools/convert_ife.go b/cmd/wsitools/convert_ife.go index 93b3533..43ba472 100644 --- a/cmd/wsitools/convert_ife.go +++ b/cmd/wsitools/convert_ife.go @@ -163,7 +163,7 @@ func runConvertIFE(cmd *cobra.Command, input string, start time.Time) error { } runErr := retile.Run(cmd.Context(), retile.Spec{ Slide: slide, SrcRegion: srcRegion, OutL0: outL0, Levels: levels, - Kernel: kernel, Encoder: &codecTileEncoder{enc: enc}, Sink: ifeSink{w}, Workers: cvWorkers, + Kernel: kernel, Encoder: &codecTileEncoder{enc: enc, tileW: 256, tileH: 256}, Sink: ifeSink{w}, Workers: cvWorkers, }) if runErr != nil { w.Abort() diff --git a/cmd/wsitools/convert_stitched.go b/cmd/wsitools/convert_stitched.go index a6f852a..23bf6e1 100644 --- a/cmd/wsitools/convert_stitched.go +++ b/cmd/wsitools/convert_stitched.go @@ -70,7 +70,7 @@ func convertStitchedCOGWSI(ctx context.Context, slide *opentile.Slide, src sourc OutL0: outL0, Levels: levels, Kernel: resample.Nearest, // identity scale: ScaledStrips only stitches - Encoder: &codecTileEncoder{enc: enc}, + Encoder: &codecTileEncoder{enc: enc, tileW: tile, tileH: tile}, Sink: sink, Workers: workers, }) @@ -133,7 +133,7 @@ func convertStitchedTIFF(ctx context.Context, slide *opentile.Slide, src source. handles[0] = h0 // SVS thumbnail at IFD 1 (no-op unless container==svs) — must precede L1. - if _, err := emitSVSThumbnailAtL0(src, w, 0, container, omeSynthetic, plan); err != nil { + if _, err := emitSVSThumbnailAtL0(src, w, 0, container, omeSynthetic, plan, slide); err != nil { return err } @@ -153,7 +153,7 @@ func convertStitchedTIFF(ctx context.Context, slide *opentile.Slide, src source. OutL0: outL0, Levels: levels, Kernel: resample.Nearest, - Encoder: &codecTileEncoder{enc: enc}, + Encoder: &codecTileEncoder{enc: enc, tileW: tile, tileH: tile}, Sink: sink, Workers: workers, }) @@ -243,7 +243,7 @@ func convertTranscodeTIFF(ctx context.Context, slide *opentile.Slide, src source return fmt.Errorf("add level 0: %w", err) } handles[0] = h0 - if _, err := emitSVSThumbnailAtL0(src, w, 0, container, omeSynthetic, plan); err != nil { + if _, err := emitSVSThumbnailAtL0(src, w, 0, container, omeSynthetic, plan, slide); err != nil { return err } for e := 1; e < len(emitted); e++ { @@ -261,7 +261,7 @@ func convertTranscodeTIFF(ctx context.Context, slide *opentile.Slide, src source OutL0: l0.Size, // identity: transcode is same geometry Levels: levels, // FULL octave chain (emit + intermediate) Kernel: resample.Nearest, - Encoder: &codecTileEncoder{enc: enc}, + Encoder: &codecTileEncoder{enc: enc, tileW: levels[0].TileW, tileH: levels[0].TileH}, Sink: sink, Workers: workers, }) diff --git a/cmd/wsitools/convert_tiff.go b/cmd/wsitools/convert_tiff.go index cd9f591..7547a53 100644 --- a/cmd/wsitools/convert_tiff.go +++ b/cmd/wsitools/convert_tiff.go @@ -200,7 +200,21 @@ func runConvertTIFFTileCopy(_ *cobra.Command, src source.Source, input, target s } omeSynthetic := container == "ome-tiff" && src.Format() != string(opentile.FormatOMETIFF) - if err := writeTIFFTileCopy(w, src, container, srcImageDesc, omeSynthetic, omeEditPlan{dropAll: cvNoAssociated}); err != nil { + // For SVS output, emitSVSThumbnailAtL0 synthesizes a thumbnail at IFD 1 when + // the source carries none (ScaledStrips over L0); it needs the opentile slide. + // Non-SVS containers don't classify IFD 1 positionally, so a nil slide there + // is harmless (synthesis is gated on container=="svs"). + var slide *opentile.Slide + if container == "svs" { + s, oerr := opentile.OpenFile(input) + if oerr != nil { + w.Abort() + return fmt.Errorf("open slide for thumbnail synthesis: %w", oerr) + } + defer s.Close() + slide = s + } + if err := writeTIFFTileCopy(w, src, container, srcImageDesc, omeSynthetic, omeEditPlan{dropAll: cvNoAssociated}, slide); err != nil { w.Abort() return err } @@ -387,7 +401,7 @@ func runConvertTIFFReencode(cmd *cobra.Command, input, container, codecName, qua w.Abort() return fmt.Errorf("--tile-size %d differs from the source tiling (%d) and this path (lossless or non-octave-aligned source) cannot re-tile; omit --tile-size, or use a lossy octave-aligned conversion", cvTileSize, srcL0TileW) } - if err := transcodePyramid(cmd.Context(), src, w, fac, knobs, workers, resolvedContainer, srcImageDesc, omeEditPlan{dropAll: cvNoAssociated}, omeSynthetic); err != nil { + if err := transcodePyramid(cmd.Context(), src, w, fac, knobs, workers, resolvedContainer, srcImageDesc, omeEditPlan{dropAll: cvNoAssociated}, omeSynthetic, slide); err != nil { w.Abort() return err } @@ -478,12 +492,12 @@ func parseQualityKnobs(quality string) (map[string]string, error) { return knobs, nil } -func transcodePyramid(ctx context.Context, src source.Source, w *streamwriter.Writer, fac codec.EncoderFactory, knobs map[string]string, workers int, container, srcImageDesc string, plan omeEditPlan, omeSynthetic bool) error { +func transcodePyramid(ctx context.Context, src source.Source, w *streamwriter.Writer, fac codec.EncoderFactory, knobs map[string]string, workers int, container, srcImageDesc string, plan omeEditPlan, omeSynthetic bool, slide *opentile.Slide) error { for _, lvl := range src.Levels() { if err := transcodeLevel(ctx, lvl, w, fac, knobs, workers, container, srcImageDesc); err != nil { return fmt.Errorf("level %d: %w", lvl.Index(), err) } - if _, err := emitSVSThumbnailAtL0(src, w, lvl.Index(), container, omeSynthetic, plan); err != nil { + if _, err := emitSVSThumbnailAtL0(src, w, lvl.Index(), container, omeSynthetic, plan, slide); err != nil { return err } } @@ -724,7 +738,7 @@ func jpegTilePhotometric(tile []byte) uint16 { } } -func writeTIFFTileCopy(w *streamwriter.Writer, src source.Source, container, l0Desc string, omeSynthetic bool, plan omeEditPlan) error { +func writeTIFFTileCopy(w *streamwriter.Writer, src source.Source, container, l0Desc string, omeSynthetic bool, plan omeEditPlan, slide *opentile.Slide) error { levels := src.Levels() // A verbatim-copied JPEG tile must be tagged with the photometric matching // its own framing (JFIF/Adobe-YCbCr → YCbCr(6); bare/Aperio → RGB(2)). @@ -818,7 +832,7 @@ func writeTIFFTileCopy(w *streamwriter.Writer, src source.Source, container, l0D if err := <-drainErr; err != nil { return fmt.Errorf("drain level %d: %w", lvl.Index(), err) } - if _, err := emitSVSThumbnailAtL0(src, w, lvl.Index(), container, omeSynthetic, plan); err != nil { + if _, err := emitSVSThumbnailAtL0(src, w, lvl.Index(), container, omeSynthetic, plan, slide); err != nil { return err } } @@ -949,7 +963,7 @@ func emitOneAssociated(src source.Source, w *streamwriter.Writer, a source.Assoc // container=="svs" && lvlIndex==0. Honors the plan via emitOneAssociated // (dropAll/remove emit nothing; replace emits plan.spec). Handles the upsert // (replace a thumbnail the source lacks). Returns whether an IFD was emitted. -func emitSVSThumbnailAtL0(src source.Source, w *streamwriter.Writer, lvlIndex int, container string, omeSynthetic bool, plan omeEditPlan) (bool, error) { +func emitSVSThumbnailAtL0(src source.Source, w *streamwriter.Writer, lvlIndex int, container string, omeSynthetic bool, plan omeEditPlan, slide *opentile.Slide) (bool, error) { if container != "svs" || lvlIndex != 0 || plan.dropAll { return false, nil } @@ -965,9 +979,32 @@ func emitSVSThumbnailAtL0(src source.Source, w *streamwriter.Writer, lvlIndex in } return true, nil } + // Synthesize: SVS classifies the SECOND IFD positionally as the thumbnail + // (opentile by page index; ImageScope/Aperio strictly so). A source without a + // thumbnail (IFE, BIF, …) would otherwise leave the first reduced pyramid + // level at IFD 1, where ImageScope mis-reads it as the thumbnail and DROPS it + // from the pyramid. Genuine Aperio SVS always carries a thumbnail at IFD 1, so + // render one from L0 to match (longest edge thumbLongSide, baseline JPEG). + if slide != nil { + l0 := slide.Pyramid(0).Levels[0] + rect := opentile.Region{Origin: opentile.Point{X: 0, Y: 0}, Size: l0.Size} + jpegBytes, tw, th, terr := streamCropThumbnail(slide, rect, l0.Size.W, l0.Size.H, synthThumbnailQuality) + if terr != nil { + return false, fmt.Errorf("synthesize SVS thumbnail: %w", terr) + } + if err := addCropThumbnailStripped(w, jpegBytes, tw, th); err != nil { + return false, fmt.Errorf("write synthesized SVS thumbnail: %w", err) + } + return true, nil + } return false, nil } +// synthThumbnailQuality is the baseline-JPEG quality for a thumbnail synthesized +// for an SVS whose source carries none. The thumbnail is a small navigation +// preview, so a moderate quality keeps it tiny without visible artifacts. +const synthThumbnailQuality = 80 + func writeAssociatedImages(src source.Source, w *streamwriter.Writer, container string, omeSynthetic bool, plan omeEditPlan) error { if plan.dropAll { return nil diff --git a/cmd/wsitools/downsample.go b/cmd/wsitools/downsample.go index c6f8615..d3cf2e4 100644 --- a/cmd/wsitools/downsample.go +++ b/cmd/wsitools/downsample.go @@ -293,7 +293,7 @@ func buildEnginePyramid(ctx context.Context, slide *opentile.Slide, w *streamwri } sink := newStreamwriterSink(handles) - return runEngineRetile(ctx, slide, srcRegion, outL0, levels, &codecTileEncoder{enc: enc}, sink, workers) + return runEngineRetile(ctx, slide, srcRegion, outL0, levels, &codecTileEncoder{enc: enc, tileW: outTile, tileH: outTile}, sink, workers) } // buildPyramidFromRaster encodes an in-memory RGB888 L0 raster into a tiled diff --git a/cmd/wsitools/retile_pad_test.go b/cmd/wsitools/retile_pad_test.go new file mode 100644 index 0000000..2ccc358 --- /dev/null +++ b/cmd/wsitools/retile_pad_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +// TestPadRGBTileReplicate verifies a partial edge tile is padded up to the full +// tile size by replicating the last valid row/column (TIFF requires uniform +// full-size tiles; the retile engine hands partial edge tiles at content size). +func TestPadRGBTileReplicate(t *testing.T) { + // 2x2 content, distinct pixels, padded to 4x4. + // (0,0)=R (1,0)=G + // (0,1)=B (1,1)=W + src := []byte{ + 255, 0, 0, 0, 255, 0, // row 0: R G + 0, 0, 255, 255, 255, 255, // row 1: B W + } + dst := padRGBTileReplicate(src, 2, 2, 4, 4) + if len(dst) != 4*4*3 { + t.Fatalf("dst len = %d, want %d", len(dst), 4*4*3) + } + px := func(x, y int) [3]byte { + o := (y*4 + x) * 3 + return [3]byte{dst[o], dst[o+1], dst[o+2]} + } + // Content preserved. + if px(0, 0) != [3]byte{255, 0, 0} || px(1, 0) != [3]byte{0, 255, 0} { + t.Errorf("content row 0 wrong: %v %v", px(0, 0), px(1, 0)) + } + // Last column (x=1) replicated rightward into x=2,3 on row 0 (G). + if px(2, 0) != [3]byte{0, 255, 0} || px(3, 0) != [3]byte{0, 255, 0} { + t.Errorf("right-pad row 0 = %v %v, want G,G", px(2, 0), px(3, 0)) + } + // Last row (y=1) replicated downward into y=2,3; column 0 stays B. + if px(0, 2) != [3]byte{0, 0, 255} || px(0, 3) != [3]byte{0, 0, 255} { + t.Errorf("bottom-pad col 0 = %v %v, want B,B", px(0, 2), px(0, 3)) + } + // Bottom-right corner replicates the (1,1)=W pixel. + if px(3, 3) != [3]byte{255, 255, 255} { + t.Errorf("corner pad = %v, want W", px(3, 3)) + } +} + +// TestPadRGBTileReplicateNoOpWhenFull confirms a full-size tile is unchanged +// shape-wise (the encoder skips padding for full tiles, but the helper must be +// correct if called). +func TestPadRGBTileReplicateNoOpWhenFull(t *testing.T) { + src := make([]byte, 2*2*3) + for i := range src { + src[i] = byte(i) + } + dst := padRGBTileReplicate(src, 2, 2, 2, 2) + for i := range src { + if dst[i] != src[i] { + t.Fatalf("full-size pad altered byte %d: got %d want %d", i, dst[i], src[i]) + } + } +} diff --git a/cmd/wsitools/retile_sink.go b/cmd/wsitools/retile_sink.go index 4e98638..dd76ac4 100644 --- a/cmd/wsitools/retile_sink.go +++ b/cmd/wsitools/retile_sink.go @@ -11,14 +11,55 @@ import ( // (347) carries the shared tables from enc.LevelHeader(). One codecTileEncoder // is shared across the engine's worker pool — codec.Encoder.EncodeTile is // concurrency-safe (the existing transcode pipeline shares one the same way). +// +// tileW/tileH, when >0, are the full output tile dimensions. The retile engine +// hands edge/corner tiles (and whole levels smaller than one tile) at their +// truncated CONTENT size; TIFF requires every tile to be a uniform full +// TileWidth×TileLength (edge pixels beyond ImageWidth/Length are padding, +// ignored by readers), and OpenSlide/ImageScope (and the IFE 256px-tile format) +// enforce it — a sub-full-size JPEG tile reads as a "dimensional mismatch" and +// corrupts the slide. So partial tiles are edge-replicated up to the full size +// before encoding. DZI/SZI legitimately use partial edge tiles and go through +// internal/dzi's own encoders, not this type, so they are unaffected. type codecTileEncoder struct { - enc codec.Encoder + enc codec.Encoder + tileW, tileH int } func (e *codecTileEncoder) EncodeTile(rgb []byte, w, h int) ([]byte, error) { + if e.tileW > 0 && e.tileH > 0 && (w < e.tileW || h < e.tileH) { + rgb = padRGBTileReplicate(rgb, w, h, e.tileW, e.tileH) + w, h = e.tileW, e.tileH + } return e.enc.EncodeTile(rgb, w, h, nil) } +// padRGBTileReplicate copies a w×h tightly-packed RGB tile into a tw×th buffer, +// replicating the last valid column and row across the padding. Edge replication +// (rather than a constant fill) keeps the padded pixels close to the content so +// the JPEG MCUs straddling the boundary don't bleed an alien colour back into +// the visible edge. Requires w>0, h>0, tw>=w, th>=h. +func padRGBTileReplicate(src []byte, w, h, tw, th int) []byte { + dst := make([]byte, tw*th*3) + srcStride, dstStride := w*3, tw*3 + for y := 0; y < th; y++ { + sy := y + if sy >= h { + sy = h - 1 // replicate the last source row downward + } + srow := src[sy*srcStride : sy*srcStride+w*3] + drow := dst[y*dstStride : y*dstStride+tw*3] + copy(drow[:w*3], srow) + if w < tw { + last := srow[(w-1)*3 : w*3] // replicate the last source column rightward + for x := w; x < tw; x++ { + copy(drow[x*3:x*3+3], last) + } + } + } + return dst +} + // flooredLevelCount returns the number of octave levels (each half the previous, // ceil-halving) from native w×h down to and including the first level whose // smaller dimension is ≤ tile. Always ≥ 1. This floors the engine's octave diff --git a/tests/integration/svs_aperio_conformance_test.go b/tests/integration/svs_aperio_conformance_test.go new file mode 100644 index 0000000..99b31da --- /dev/null +++ b/tests/integration/svs_aperio_conformance_test.go @@ -0,0 +1,164 @@ +//go:build integration + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + opentile "github.com/wsilabs/opentile-go" + _ "github.com/wsilabs/opentile-go/formats/all" +) + +// jpegSOFDims parses the image dimensions from a JPEG's first SOF marker. +func jpegSOFDims(b []byte) (w, h int, ok bool) { + i := 2 // skip SOI + for i+9 < len(b) { + if b[i] != 0xFF { + i++ + continue + } + m := b[i+1] + if (m >= 0xC0 && m <= 0xC3) || (m >= 0xC5 && m <= 0xC7) || (m >= 0xC9 && m <= 0xCB) || (m >= 0xCD && m <= 0xCF) { + h = int(b[i+5])<<8 | int(b[i+6]) + w = int(b[i+7])<<8 | int(b[i+8]) + return w, h, true + } + if m == 0xD8 || m == 0xD9 || (m >= 0xD0 && m <= 0xD7) { + i += 2 + continue + } + seg := int(b[i+2])<<8 | int(b[i+3]) + i += 2 + seg + } + return 0, 0, false +} + +// TestSVSEdgeTilesAreFullSize guards the TIFF-conformance fix: the retile engine +// must pad partial edge/corner tiles up to the full declared TileWidth×TileLength +// (OpenSlide/ImageScope reject a sub-full-size JPEG tile as a "dimensional +// mismatch"). --factor routes through the engine, and a 2220×2967 / 240px source +// yields partial right/bottom edge tiles, so every L0 edge tile's JPEG must +// decode to exactly 240×240. +func TestSVSEdgeTilesAreFullSize(t *testing.T) { + src := cmuFixture(t) + bin := buildOnce(t) + out := filepath.Join(t.TempDir(), "out.svs") + + if o, err := runCLI(bin, "convert", "--to", "svs", "--codec", "jpeg", "--factor", "2", "-f", "-o", out, src); err != nil { + t.Fatalf("convert --factor 2 --to svs: %v\n%s", err, o) + } + + sl, err := opentile.OpenFile(out) + if err != nil { + t.Fatalf("open output: %v", err) + } + defer sl.Close() + l0 := sl.Levels()[0] + tw, th := l0.TileSize.W, l0.TileSize.H + cols := (l0.Size.W + tw - 1) / tw + rows := (l0.Size.H + th - 1) / th + if cols < 2 || rows < 2 { + t.Fatalf("need partial edges to test: grid %dx%d", cols, rows) + } + // Interior, right edge, bottom edge, corner — all must be full tile size. + for _, c := range [][2]int{{0, 0}, {cols - 1, 0}, {0, rows - 1}, {cols - 1, rows - 1}} { + b, err := l0.Tile(c[0], c[1]) + if err != nil { + t.Fatalf("read tile (%d,%d): %v", c[0], c[1], err) + } + w, h, ok := jpegSOFDims(b) + if !ok { + t.Fatalf("tile (%d,%d): no JPEG SOF", c[0], c[1]) + } + if w != tw || h != th { + t.Errorf("tile (%d,%d) JPEG = %dx%d, want full tile %dx%d (edge tiles must be padded)", c[0], c[1], w, h, tw, th) + } + } +} + +// TestSVSSynthesizesThumbnailAtIFD1 guards the Aperio-layout fix: a source +// without a thumbnail, converted to SVS, must get a synthesized thumbnail at +// IFD 1. Genuine Aperio SVS always carries the thumbnail as the second IFD; +// ImageScope classifies IFD 1 positionally as the thumbnail, so without one the +// first reduced pyramid level lands at IFD 1 and is dropped. Built from a +// multi-level fixture stripped of associated images (--no-associated), so no +// dedicated no-thumbnail fixture is needed. +func TestSVSSynthesizesThumbnailAtIFD1(t *testing.T) { + src := filepath.Join(testdir(t), "svs", "JP2K-33003-1.svs") + if _, err := os.Stat(src); err != nil { + t.Skipf("fixture missing: %v", err) + } + bin := buildOnce(t) + dir := t.TempDir() + + // 1. Multi-level source → tiff with NO associated images (no thumbnail). + noThumb := filepath.Join(dir, "nothumb.tiff") + if o, err := runCLI(bin, "convert", "--to", "tiff", "--no-associated", "-f", "-o", noThumb, src); err != nil { + t.Fatalf("make no-thumbnail tiff: %v\n%s", err, o) + } + if ifds, err := runCLI(bin, "dump-ifds", noThumb); err != nil { + t.Fatalf("dump-ifds intermediate: %v\n%s", err, ifds) + } else if strings.Contains(ifds, "thumbnail") { + t.Fatalf("intermediate tiff unexpectedly has a thumbnail:\n%s", ifds) + } + + // 2. Convert the no-thumbnail source to SVS → must synthesize IFD 1 thumbnail. + out := filepath.Join(dir, "out.svs") + if o, err := runCLI(bin, "convert", "--to", "svs", "-f", "-o", out, noThumb); err != nil { + t.Fatalf("convert no-thumbnail tiff → svs: %v\n%s", err, o) + } + + ifds, err := runCLI(bin, "dump-ifds", out) + if err != nil { + t.Fatalf("dump-ifds output: %v\n%s", err, ifds) + } + // Keep only the per-IFD layout lines ("IFD 0 pyramid L0 ..."), excluding the + // verbose detail lines ("IFD 0: WSIImageType=...", which carry a colon). + lines := strings.Split(strings.TrimSpace(ifds), "\n") + var ifdLines []string + for _, l := range lines { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, "IFD ") && !strings.Contains(t, ":") { + ifdLines = append(ifdLines, l) + } + } + if len(ifdLines) < 2 { + t.Fatalf("expected ≥2 IFDs, got:\n%s", ifds) + } + // IFD 1 must be the thumbnail. + if !strings.Contains(ifdLines[1], "thumbnail") { + t.Errorf("IFD 1 is not a thumbnail (Aperio requires it):\n%s", strings.Join(ifdLines, "\n")) + } + // All 3 source pyramid levels must survive (none consumed as the thumbnail). + pyr := 0 + for _, l := range ifdLines { + if strings.Contains(l, "pyramid") { + pyr++ + } + } + if pyr != 3 { + t.Errorf("pyramid level count = %d, want 3 (no level eaten):\n%s", pyr, strings.Join(ifdLines, "\n")) + } + + // opentile must also still read all 3 levels and see the thumbnail associated. + sl, err := opentile.OpenFile(out) + if err != nil { + t.Fatalf("open output: %v", err) + } + defer sl.Close() + if n := len(sl.Levels()); n != 3 { + t.Errorf("opentile level count = %d, want 3", n) + } + hasThumb := false + for _, a := range sl.AssociatedImages() { + if string(a.Type()) == "thumbnail" { + hasThumb = true + } + } + if !hasThumb { + t.Error("output has no thumbnail associated image") + } +}