svs 1.3.0
svs: ^1.3.0 copied to clipboard
Streams and renders Aperio SVS whole-slide pathology images: pan/zoom, LOD tile streaming, JPEG & JPEG2000, no full-image load.
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
(
Compression7 and 33005) — JPEG2000 viaopenjpeg_ffi. - Associated images and metadata: thumbnail/label/macro images, and
parsed Aperio metadata (magnification, microns-per-pixel, and the rest of
the pipe-delimited
ImageDescriptionblock). - 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, orexportSvsRegionAsSvsPreservingLevelsto also mirror the source's own level count/downsample steps): re-encode a cropped region as a brand new, valid multi-level pyramidal.svsfile — not a flat raster image — openable by this package (or any other tiled-TIFF/OpenSlide-aware tool) and pannable/zoomable like any other slide. - Rebuild a slide's own pyramid level count (
rebuildSvsPyramid, orrebuildSvsPyramidToFile/rebuildSvsPyramidInPlaceto write straight to disk instead of returning bytes): re-encode a whole existing slide with more levels (auto-computed for a smooth, evenly-2x-stepped zoom, since real slides often aren't evenly stepped) or fewer (an explicit, smallerlevelCount) — either to a new file next to the original, or overwriting it in place. Aneffortsetting trades throughput for how much room the rebuild leaves the UI thread to stay responsive while it runs. - 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. UseSvsFile.openBytes(bytes)instead — feed it the slide's bytes directly (e.g. from an<input type=file>/package:file_pickerpick, or a network fetch).SvsFile.pathisnullfor a file opened this way. Seeexample/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-memoryTileCache(always on) still avoids re-decoding a tile you're actively panning back and forth over. - No
*ToFileexport helpers:exportSvsRegionToFile,exportAssociatedImageToFile,exportSvsLevelToFile,exportSvsRegionAsSvsToFile,exportSvsRegionAsSvsPreservingLevelsToFile,rebuildSvsPyramidToFile, andrebuildSvsPyramidInPlaceall write to a filesystem path (the last two of those don't even fit withinSvsFile .openBytes's pathless model, since there's no source file to overwrite) and so aren't available on the web. Use their byte-returning siblings (exportSvsRegion,exportAssociatedImage,exportSvsLevel,exportSvsRegionAsSvs,exportSvsRegionAsSvsPreservingLevels,rebuildSvsPyramid) 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:
jp2kCompressionRatioabove0is lossy — the decoded pixels won't exactly match the source anymore, in exchange for a smaller file.qualitybelow 100 is lossy the same way forSvsExportCompression.jpeg(JPEG has no lossless mode at all). For pathology slides, where diagnostic detail matters, prefer the lossless defaults (jp2kCompressionRatio: 0, orquality: 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
exportSvsRegionAsSvsPreservingLevelsbelow 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.
Rebuilding a slide's pyramid level count #
rebuildSvsPyramid applies the same re-encoding exportSvsRegionAsSvs does
to a crop, but to a whole existing slide, as a first-class "change this
slide's own level count" operation:
final rebuiltBytes = await rebuildSvsPyramid(svsFile);
await File('rebuilt.svs').writeAsBytes(rebuiltBytes);
// Or straight to a new file next to the original:
await rebuildSvsPyramidToFile(
svsFile,
path: '${svsFile.path}.rebuilt.svs',
);
By default (levelCount: null) it regenerates the maximal, smoothest
2x-halved cascade down to one tile. For a source whose own pyramid has few or
unevenly-spaced levels — real Aperio slides are often not 2x-stepped, e.g.
downsample 1x, 4x, 16x, which can make zooming feel like it "pops" between
levels instead of smoothly resolving — this increases the level count and
evens out the downsample steps in between. Pass an explicit, smaller
levelCount to go the other way and decrease the level count instead (the
file gets smaller and faster to rebuild; the coarsest level just won't
necessarily fit in one tile anymore). A levelCount at or above the natural
count is a no-op, since there's nothing meaningful to generate beyond it:
await rebuildSvsPyramidToFile(
svsFile,
path: '${svsFile.path}.fewer_levels.svs',
levelCount: 4, // fewer/coarser-capped levels than the natural cascade
);
tileSize, compression/quality/jp2kCompressionRatio,
adjustments, includeLabelAndMacroImages, includeSourceMetadata, and
onProgress all mean the same thing as on exportSvsRegionAsSvs.
matchSourceCompression defaults to true here (the opposite of
exportSvsRegionAsSvs's crop-oriented default) — rebuilding a whole slide's
pyramid should stay visually/size-equivalent to the source unless you say
otherwise.
Modifying the original file, or a new one next to it
rebuildSvsPyramidToFile (above) always writes a brand new file at whatever
path you give it — the "new file next to the original" option, leaving the
source untouched. rebuildSvsPyramidInPlace is the "modify the original
file" option instead: it overwrites svsFile's own source file safely (via a
temp file streamed alongside it, only swapped in once the rebuild fully
succeeds), and returns a freshly-reopened SvsFile on the result:
// svsFile must have been opened with SvsFile.open (a real path) —
// SvsFile.openBytes has no file to overwrite.
svsFile = await rebuildSvsPyramidInPlace(svsFile, levelCount: 6);
svsFile is closed as part of this call once the rebuild itself succeeds —
don't keep using the instance you passed in; the returned SvsFile replaces
it. If anything goes wrong (mid-rebuild, or during the final swap), the
original file is left completely untouched and the temp file is cleaned up.
This function, like rebuildSvsPyramidToFile, is native platforms only
(where a real filesystem exists) — on the web, use the byte-returning
rebuildSvsPyramid together with a browser download instead.
Controlling RAM/CPU usage while rebuilding
Every function in this section (and exportSvsRegionAsSvs/
exportSvsRegionAsSvsPreservingLevels) takes an effort
(SvsPyramidRebuildEffort) parameter that trades throughput for how much
room the operation leaves the UI thread — relevant because this all runs on
the main isolate (tile decoding needs dart:ui), so a long rebuild can
otherwise compete with rendering frames:
await rebuildSvsPyramidInPlace(
svsFile,
effort: SvsPyramidRebuildEffort.low, // smoothest UI, slowest rebuild
);
.balanced (the default) matches this package's historical behavior; .low
actively cedes more time between row-bands for the smoothest experience on a
foreground screen; .high yields less often for the fastest throughput. None
of the three settings change peak memory, which is already bounded by
design — a couple of tile-row bands per level in flight at once, never the
whole image — regardless of effort. For genuine RAM control, prefer the
disk-streamed rebuildSvsPyramidToFile/rebuildSvsPyramidInPlace over the
in-memory rebuildSvsPyramid, which needs as many bytes of RAM as the whole
rebuilt file.
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:
License #
Apache License 2.0. See LICENSE for details.
