Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion cmd/wsitools/associated_rebuild_ometiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion cmd/wsitools/associated_rebuild_tiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/wsitools/convert_factor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/wsitools/convert_ife.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 5 additions & 5 deletions cmd/wsitools/convert_stitched.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
})
Expand Down Expand Up @@ -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++ {
Expand All @@ -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,
})
Expand Down
51 changes: 44 additions & 7 deletions cmd/wsitools/convert_tiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)).
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/wsitools/downsample.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions cmd/wsitools/retile_pad_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
43 changes: 42 additions & 1 deletion cmd/wsitools/retile_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading