svs

A Flutter library for displaying Aperio SVS (whole-slide image) files.

SVS is a pyramidal, tiled TIFF-based format used to store gigapixel whole-slide microscopy/pathology images. svs reads the pyramid directly and streams only the tiles the current viewport needs — the full image is never loaded into memory, however large the slide.

Features

  • Pan & zoom viewer (SvsImageView) with a minimap, zoom percentage, and a physical scale bar (µm/mm, derived from the slide's own microns-per-pixel metadata) — each independently toggleable (showMinimap/showZoomLevel/showScaleBar).
  • Level-of-detail tile streaming: only the visible region's tiles are fetched and decoded, at the resolution level that matches the current zoom — panning and zooming a multi-gigapixel slide stays smooth.
  • Background isolate decoding (native platforms): tile I/O and JPEG2000 decode run off the main isolate, so the UI thread stays responsive. On the web, which has no background isolates, decoding runs on the calling thread instead — see Platform support.
  • JPEG and JPEG2000 tiles, the two compressions Aperio actually ships (Compression 7 and 33005) — JPEG2000 via openjpeg_ffi.
  • Associated images and metadata: thumbnail/label/macro images, and parsed Aperio metadata (magnification, microns-per-pixel, and the rest of the pipe-delimited ImageDescription block).
  • Full metadata dump (SvsFile.readInfo): every TIFF tag on every pyramid level and associated image, decoded — not just the fields this package parses directly — for callers that want the whole file's own metadata, or a specific tag this package doesn't otherwise surface.
  • Region cropping (readSvsRegion): decode an arbitrary rectangle of any pyramid level to a single composited image, without loading the whole level.
  • Crop to a new pyramidal .svs (exportSvsRegionAsSvs, or exportSvsRegionAsSvsPreservingLevels to also mirror the source's own level count/downsample steps): re-encode a cropped region as a brand new, valid multi-level pyramidal .svs file — not a flat raster image — openable by this package (or any other tiled-TIFF/OpenSlide-aware tool) and pannable/zoomable like any other slide.
  • Brightness/contrast/shadow/highlight adjustment (SvsImageAdjustments): applied identically live (cheap to change every frame — a GPU color filter) and on export, so preview and output always match.
  • Annotations (SvsAnnotationController): draw points, rectangles, polylines, and polygons over the slide — anchored in level-0 pixel space, so they stay put across pan/zoom — with tap-to-select, hit-testing, and JSON persistence.
  • Measurement (measureAnnotation): live physical length/area labels on line, rectangle, and polygon annotations (including while drawing), computed from the slide's own microns-per-pixel metadata.
  • Export to common image formats (exportSvsRegion, exportAssociatedImage, exportSvsLevel): encode a crop, an associated image, or a whole pyramid level to PNG, JPEG, BMP, TIFF, or WebP bytes.
  • Memory-pressure aware tile cache, and active cancellation of in-flight tile requests once they scroll out of view.
  • Persistent disk tile cache (DiskTileCache, opt-in, native platforms only): decoded tiles survive across app restarts, so re-viewing the same region of a slide skips both the tile fetch and — for JPEG2000 slides — the wavelet decode.

Platform support

svs runs on every Flutter platform — iOS, Android, macOS, Windows, Linux, and (since 1.2.0) the web — with the same API (SvsFile, SvsImageView, region/pyramid export, annotations). JPEG2000 decoding is native on all of them via openjpeg_ffi, which compiles OpenJPEG to WebAssembly for the web build.

A few things are unavoidably different on the web, since it has no filesystem and no background isolates:

  • No filesystem path: SvsFile.open(path) isn't usable in a browser. Use SvsFile.openBytes(bytes) instead — feed it the slide's bytes directly (e.g. from an <input type=file>/package:file_picker pick, or a network fetch). SvsFile.path is null for a file opened this way. See example/ for a working file-picker-based web entry point.
  • No background-isolate decoding: tile fetch/JPEG2000 decode runs on the calling thread instead of a worker isolate — this falls back automatically, no code changes needed, but a JP2K-heavy slide may feel less smooth while panning/zooming than on native.
  • No DiskTileCache: there's no filesystem to persist tiles to across page reloads. The in-memory TileCache (always on) still avoids re-decoding a tile you're actively panning back and forth over.
  • No *ToFile export helpers: exportSvsRegionToFile, exportAssociatedImageToFile, exportSvsLevelToFile, exportSvsRegionAsSvsToFile, and exportSvsRegionAsSvsPreservingLevelsToFile all write to a filesystem path and so aren't available on the web. Use their byte-returning siblings (exportSvsRegion, exportAssociatedImage, exportSvsLevel, exportSvsRegionAsSvs, exportSvsRegionAsSvsPreservingLevels) and trigger a browser download yourself with the resulting bytes.

Getting started

dependencies:
  svs: ^1.2.0

Usage

import 'package:svs/svs.dart';
import 'package:flutter/widgets.dart';

final svsFile = await SvsFile.open('/path/to/slide.svs');

// Anywhere in a widget tree:
SvsImageView(svsFile: svsFile);

// When done:
await svsFile.close();

On the web (no filesystem path to open), use SvsFile.openBytes instead — see Platform support:

final bytes = await pickedSlideBytes(); // e.g. from package:file_picker
final svsFile = await SvsFile.openBytes(bytes);

SvsImageView handles pan/zoom gestures, tile streaming, and the minimap/ HUD on its own — no further wiring needed. Each overlay can be turned off independently:

SvsImageView(
  svsFile: svsFile,
  showMinimap: false, // skips decoding the thumbnail entirely, not just hiding it
  showZoomLevel: false,
  showScaleBar: false,
);

Persistent tile cache

Native platforms only — see Platform support. By default, decoded tiles are cached in memory only — closing and re-opening the same slide decodes everything again from scratch. Pass a DiskTileCache to keep decoded tiles on disk between sessions, scoped to a directory unique to that slide (mixing tiles from different slides in one directory isn't supported — their level/tile-x/tile-y keys can collide). svs itself has no opinion on where that directory lives — pick one with your own app's path_provider dependency (or any other means of locating a writable directory):

import 'package:path_provider/path_provider.dart';

final cacheDir = Directory(
  '${(await getApplicationCacheDirectory()).path}/svs_tiles/${svsFile.path.hashCode}',
);
final diskCache = await DiskTileCache.open(cacheDir); // 500 MB budget by default

SvsImageView(svsFile: svsFile, diskCache: diskCache);

Bounded by decoded-pixel byte budget and evicted LRU, same policy as the in-memory cache — pass maxBytes to DiskTileCache.open to change it. Most valuable for JPEG2000 slides, whose wavelet decode is comparatively expensive to redo; for JPEG slides the win is mainly skipping repeated file I/O.

Cropping a region

To pull out an arbitrary rectangle — e.g. exporting a region of interest, or generating a fixed-size tile at a chosen resolution — use readSvsRegion. Coordinates are in the given pyramid level's own pixel space (level 0 is full resolution), and the rectangle may hang off the level's edges; the out-of-bounds part comes back transparent:

final region = await readSvsRegion(
  svsFile,
  level: 0,
  x: 1000,
  y: 2000,
  width: 512,
  height: 512,
);
// region is a dart:ui Image — draw it, or convert to bytes:
final bytes = await region.toByteData(format: ui.ImageByteFormat.png);
region.dispose();

readSvsRegion must be called on the main isolate (like any other dart:ui decode) and stitches together only the tiles the rectangle actually overlaps.

Reading a file's full metadata

SvsFile.readInfo dumps every TIFF tag of every pyramid level and associated image, decoded regardless of type — beyond the Aperio fields SvsFile.metadata already parses directly:

final info = await svsFile.readInfo();
print(info.isBigTiff); // true/false
print(info.levels[0].tags[256]); // ImageWidth, raw tag ID
print(info.levels[0].namedTags['ImageWidth']); // same, by name

For just a few specific tags instead of everything, use readTags on a level or associated image directly:

final tags = await svsFile.levels[0].readTags([256, 257]); // ImageWidth, ImageLength

Converting to other image formats

encodeSvsImage turns any decoded image (from readSvsRegion or decodeAssociatedImage) into PNG, JPEG, BMP, TIFF, or WebP bytes. The exportSvs* wrappers combine decoding and encoding into one call and dispose the intermediate image for you:

// A cropped region, as JPEG:
final jpegBytes = await exportSvsRegion(
  svsFile,
  level: 0,
  x: 1000, y: 2000, width: 512, height: 512,
  format: SvsImageFormat.jpeg,
  quality: 90, // 1-100, JPEG only — every other format is lossless
);
await File('region.jpg').writeAsBytes(jpegBytes);

// The slide's label image, as PNG:
final label = svsFile.associatedImages
    .firstWhere((a) => a.kind == AssociatedImageKind.label);
final pngBytes = await exportAssociatedImage(label, format: SvsImageFormat.png);

// An entire (coarse) pyramid level, as TIFF:
final levelBytes = await exportSvsLevel(
  svsFile,
  level: svsFile.levels.length - 1, // the smallest/coarsest level
  format: SvsImageFormat.tiff,
);

Each of those has a ...ToFile counterpart (exportSvsRegionToFile, exportAssociatedImageToFile, exportSvsLevelToFile) that writes straight to a path and skips the manual writeAsBytes step:

await exportSvsRegionToFile(
  svsFile,
  path: 'region.jpg',
  level: 0,
  x: 1000, y: 2000, width: 512, height: 512,
  format: SvsImageFormat.jpeg,
);

exportSvsLevel refuses (throws ArgumentError) to export a level over maxPixels (64,000,000 px by default, roughly an 8000x8000 image) without an explicit opt-in — level 0 of a real slide can be 100,000+ px per side, and compositing/re-encoding one whole-hog can mean gigabytes of RAM and a multi-minute encode. Crop with exportSvsRegion or target a coarser level instead unless you really need the full-resolution export.

Brightness/contrast/shadow/highlight adjustment

SvsImageAdjustments applies identically to a live SvsImageView and to every export function — the same values always produce the same result in both places:

const adjustments = SvsImageAdjustments(
  brightness: 0.1,
  contrast: 0.2,
  shadows: 0.3, // lifts dark regions
  highlights: -0.2, // brightens light regions further (protects less)
);

SvsImageView(svsFile: svsFile, adjustments: adjustments); // live preview

final bytes = await exportSvsRegion(
  svsFile,
  level: 0, x: 1000, y: 2000, width: 512, height: 512,
  format: SvsImageFormat.png,
  adjustments: adjustments, // same look in the exported file
);

Every parameter is nominally -1..1, with 0 meaning no change (SvsImageAdjustments.none, the default everywhere).

Cropping to a new pyramidal .svs file

exportSvsRegionAsSvs crops a region like exportSvsRegion, but re-encodes it as a brand new, valid multi-level pyramidal .svs file instead of a flat raster image — reopenable with SvsFile.open and pannable/zoomable like any other slide:

final croppedSvsBytes = await exportSvsRegionAsSvs(
  svsFile,
  level: 0,
  x: 1000, y: 2000, width: 4096, height: 4096,
  tileSize: 256, // matches real Aperio files
  quality: 90,
);
await File('cropped_region.svs').writeAsBytes(croppedSvsBytes);

// Or straight to a file:
await exportSvsRegionAsSvsToFile(
  svsFile,
  path: 'cropped_region.svs',
  level: 0,
  x: 1000, y: 2000, width: 4096, height: 4096,
);

Levels are generated by halving the previous level's dimensions (box-filter downsampled) until one fits in a single tile — the same shape a real Aperio pyramid takes.

compression picks the pyramid's tile encoding — SvsExportCompression.jpeg (the default; quality applies) or .jpeg2000 (mathematically lossless by default, or lossy at a chosen jp2kCompressionRatio; typically a smaller file than JPEG at comparable visual quality, at the cost of slower encoding):

await exportSvsRegionAsSvsToFile(
  svsFile,
  path: 'cropped_region.svs',
  level: 0,
  x: 1000, y: 2000, width: 4096, height: 4096,
  compression: SvsExportCompression.jpeg2000,
  jp2kCompressionRatio: 0, // 0 = lossless (default); e.g. 20 = ~20:1 smaller
);

Note: jp2kCompressionRatio above 0 is lossy — the decoded pixels won't exactly match the source anymore, in exchange for a smaller file. quality below 100 is lossy the same way for SvsExportCompression.jpeg (JPEG has no lossless mode at all). For pathology slides, where diagnostic detail matters, prefer the lossless defaults (jp2kCompressionRatio: 0, or quality: 100) unless file size is a hard constraint.

matchSourceCompression: true picks compression/quality/ jp2kCompressionRatio for you instead, so the crop stays as close to the source file as this format allows rather than following this function's own fixed defaults:

await exportSvsRegionAsSvsToFile(
  svsFile,
  path: 'cropped_region.svs',
  level: 0,
  x: 1000, y: 2000, width: 4096, height: 4096,
  matchSourceCompression: true,
);

Without it, a small crop re-encoded at quality: 90 (or lossless JP2K) can end up larger than the corresponding region of the source file, if the source itself was actually encoded leaner — real slides are commonly scanned at Q=70-80, or a lossy JP2K ratio, not this function's own defaults. matchSourceCompression reads the source's own JPEG quality straight out of its ImageDescription (or, for JPEG2000, estimates an equivalent ratio by sampling the source's actual on-disk tile sizes, since Aperio doesn't record that ratio), and uses that instead — while still only changing what's necessary for a crop (dimensions, positional metadata); everything else (label/macro images, other ImageDescription fields, etc.) is carried over the same as always. Falls back to quality/jp2kCompressionRatio above when there's nothing to match (e.g. no Q= on the source level).

matchSourceCompression also changes tileSize's default: leave tileSize unset (rather than its usual 256) and it resolves to the source level's own tile edge length instead, so a source scanned on a non-256 tile grid (e.g. 240) keeps that same grid in the crop. Pass tileSize explicitly to override this too — it always wins over both defaults.

Note: none of this makes the crop's pyramid structure match the source's — the number of levels and their downsample steps are still generated by halving the crop's own dimensions (see below), independent of how the source file's real pyramid is organized (which can use a different, and not necessarily 2x, step between levels). Use exportSvsRegionAsSvsPreservingLevels below instead if that matters too.

The source file's own label/macro images and other ImageDescription metadata (Filename, Date, ScanScope ID, etc.) are carried into the exported file by default — includeLabelAndMacroImages/includeSourceMetadata (both default true) opt either out, e.g. before sharing a crop outside the context that made the original slide's label or scanner details meaningful.

Preserving the source's own pyramid structure

exportSvsRegionAsSvs always builds its output pyramid by halving the crop 2x per level, regardless of how the source's own pyramid is actually structured. Real slides often aren't 2x-stepped (e.g. downsample 1x, 4x, 16x), so that output pyramid's level count and downsample steps generally won't match the source's.

exportSvsRegionAsSvsPreservingLevels (and its ...ToFile counterpart) crop the same way, but build each output level by cropping directly from the matching source level — level, level + 1, ... up to the source's coarsest — instead of decoding just level and downsampling it repeatedly. The output ends up with the same level count and the same (scaled-down) downsample steps as that slice of the source's own pyramid:

final outBytes = await exportSvsRegionAsSvsPreservingLevels(
  svsFile,
  level: 0,
  x: 1000, y: 2000, width: 4096, height: 4096,
  matchSourceCompression: true, // combine freely with everything above
);

Every other parameter (tileSize, compression/quality/ jp2kCompressionRatio, matchSourceCompression, adjustments, includeLabelAndMacroImages, includeSourceMetadata, maxPixels, onProgress) means the same thing as on exportSvsRegionAsSvs, resolved once from level and applied uniformly to every generated level. The tradeoff: an output level's size directly reflects the source's own level at that resolution, so it isn't guaranteed to be exactly half the previous level or to land on a clean tileSize boundary at its edges — it's exactly as clean (or ungainly) as the source's real pyramid is.

Annotations

SvsAnnotationController owns the annotations drawn over an SvsImageView and the interactive state of drawing a new one. Pass the same controller to the view; it renders the annotations and routes pointer gestures to build new shapes while drawMode isn't SvsAnnotationDrawMode.none:

final annotations = SvsAnnotationController(drawColor: Colors.red);

SvsImageView(
  svsFile: svsFile,
  annotationController: annotations,
  onAnnotationTap: (a) => print('tapped ${a?.id}'),
);

// Start drawing a rectangle — a press-drag-release on the view now draws
// one instead of panning. Switch back to `.none` to resume pan/zoom.
annotations.drawMode = SvsAnnotationDrawMode.rectangle;

// Point mode: each tap commits a point immediately.
annotations.drawMode = SvsAnnotationDrawMode.point;

// Polygon/polyline mode: each tap adds a vertex; call finishPath() (e.g.
// from a "Done" button) once there are enough.
annotations.drawMode = SvsAnnotationDrawMode.polygon;
// ...taps happen via the view...
annotations.finishPath();

SvsAnnotationController.annotations is a live List<SvsAnnotation>; add, remove, update, and clear all notify listeners (including the view). Tapping the view while drawMode is none hit-tests existing annotations, auto-selects the one hit (or clears selection on a miss), and calls onAnnotationTap.

Every SvsAnnotation's points are in level-0 pixel coordinates, so they stay valid across pan and zoom. Persist a set with toJsonList() / loadFromJsonList() (JSON-safe maps — round-trip through jsonEncode/ jsonDecode yourself):

final jsonString = jsonEncode(annotations.toJsonList());
// ...later...
annotations.loadFromJsonList(jsonDecode(jsonString) as List);

Measurement

Whenever the slide has microns-per-pixel metadata (SvsFile.metadata.mppX/ mppY), SvsImageView shows a live length/area label on every polyline, rectangle, and polygon annotation — including the one currently being drawn, so dragging out a rectangle or placing polygon vertices doubles as a ruler. Set showMeasurements: false to turn the labels off.

The same computation is available directly via measureAnnotation, for measuring annotations outside the view (e.g. in a report):

final m = measureAnnotation(
  annotation,
  mppX: svsFile.metadata.mppX,
  mppY: svsFile.metadata.mppY,
);
print(m.lengthMicrons); // null if unmeasurable (e.g. a point, or no mpp)
print(m.areaMicronsSquared); // null for point/polyline shapes

See example/ for a minimal runnable app, or svs_example for a full-featured demo (file picker, associated-image previews, metadata inspector) built on top of this package.

Additional information

File issues or feature requests at the issue tracker. Contributions are welcome via pull request.

If this package saves you time, consider supporting its development:

Support me on Ko-fi

License

Apache License 2.0. See LICENSE for details.

Libraries

svs
A Flutter library for displaying Aperio SVS (whole-slide image) files.