API Reference¶
This document covers the public API exported from src/index.ts. All examples assume:
import {
loadSlp,
saveSlp,
saveSlpToBytes,
loadVideo,
Video,
Mp4BoxVideoBackend,
Labels,
LabeledFrame,
Instance,
PredictedInstance,
Skeleton,
Track,
LabelsSet,
SuggestionFrame,
ROI,
UserROI,
PredictedROI,
SegmentationMask,
UserSegmentationMask,
PredictedSegmentationMask,
BoundingBox,
UserBoundingBox,
PredictedBoundingBox,
Centroid,
UserCentroid,
PredictedCentroid,
getCentroidSkeleton,
CENTROID_SKELETON,
LabelImage,
UserLabelImage,
PredictedLabelImage,
normalizeLabelIds,
readTrackMateCsv,
loadTrackMate,
isTrackMateFile,
loadUltralytics,
saveUltralytics,
readLabels as readUltralytics,
writeLabels as writeUltralytics,
readLabelsSet as readUltralyticsSet,
parseLabelFile,
detectLineFormat,
loadJabs,
JABS_DEFAULT_SKELETON,
makeJabsDefaultSkeleton,
predictionToInstance,
staticObjectToRoi,
makeSimpleSkeleton,
Identity,
Instance3D,
PredictedInstance3D,
Camera,
CameraGroup,
RecordingSession,
toDict,
fromDict,
toNumpy,
fromNumpy,
roisToGeoJSON,
roisFromGeoJSON,
writeGeoJSON,
readGeoJSON,
} from "@talmolab/sleap-io.js";
Core I/O¶
loadSlp(source, options)¶
Read .slp from a path, URL, File, FileSystemFileHandle, or byte buffer.
const labels = await loadSlp("/data/session.slp", {
openVideos: false,
h5: { stream: "auto", filenameHint: "session.slp" },
});
source: string path/URL,File,FileSystemFileHandle,Uint8Array, orArrayBuffer.options.openVideos(defaulttrue): setfalseto skip opening video backends.options.h5.stream:"auto" | "range" | "download"(browser URL streaming).options.h5.filenameHint: helps name temporary files.options.lazy(defaultfalse): use lazy loading for on-demand frame materialization.
saveSlp(labels, filename, options)¶
Write .slp (Node/Electron).
await saveSlp(labels, "/tmp/roundtrip.slp", {
embed: false,
restoreOriginalVideos: true,
});
options.embed: which frame images to embed, as Python sleap-io'sembed:true/"all": every labeled frame and every suggestion ("all+suggestions"is an alias)"user": user-labeled frames (user instances, negative frames, user centroids/boxes/masks/ROIs)"suggestions"/"user+suggestions"- an explicit list of
[video, frameIdx]pairs: exactly those frames "source": no images; restore source video paths before writingfalse(default): no embedding (already-embedded images are still kept)options.embedAllVideos(defaulttrue, as Python'sembed_all_videos): when embedding, a video with no frames to embed becomes an empty embedded video referencing the package (keeping its shape and source video), so the package is portable.falseleaves such videos as external references.options.onProgress(current, total, message): called as frames are read ("Embedding frames") and written ("Writing frames"). Returnfalseto cancel: the save throwsExportCancelled.options.restoreOriginalVideos(defaulttrue): keep original video paths.
saveSlpToBytes(labels, options)¶
Serialize Labels to SLP format and return the bytes. Works in both Node.js and browser environments.
const bytes: Uint8Array = await saveSlpToBytes(labels, { embed: false });
labels:Labelsobject to serialize.options: the sameembed,embedAllVideos,onProgressandrestoreOriginalVideosoptions assaveSlp.- Returns
Promise<Uint8Array>.
loadVideo(source, options)¶
Open a Video with an appropriate backend.
// From file path (Node.js) or URL (browser)
const video = await loadVideo("/data/movie.mp4", { openBackend: true });
const frame0 = await video.getFrame(0);
video.close();
// From File object (browser)
const video = await loadVideo(fileInput.files[0]);
source:string(path or URL) orFile(browser).options.dataset: dataset path for embedded HDF5 sources.options.openBackend(defaulttrue): setfalsefor deferred open.options.backend: explicit backend type ("mp4box","mediabunny","media","hdf5").
Video Backends¶
Video¶
Video wraps a backend and exposes shape, fps, getFrame, getFrameTimes, and close.
const video = await loadVideo("/data/movie.mp4");
const timestamps = await video.getFrameTimes(); // number[] | null
// shape and fps have setters
video.shape = [100, 480, 640, 3];
video.fps = 30;
shapeandfpshave both getters and setters.getFrame(index, signal?)accepts an optionalAbortSignalto cancel in-flight decodes.getFrameTimes()returnsnullif the backend does not implement it.
Virtual crops¶
Video.crop returns a virtual, on-read cropped view (no pixels are copied or
re-encoded). See Virtual Cropping for the full guide.
const view = full.crop([320, 200, 576, 456]); // [x1, y1, x2, y2], x2/y2 exclusive
view.shape; // [frames, 256, 256, channels]
view.cropRect; // [320, 200, 576, 456]
view.isCropped; // true
view.cropFill; // 0
view.sourceVideo === full; // true
// Region specs (exactly one): explicit rect, bbox, roi, or center+size.
full.crop(null, { bbox: [x1, y1, x2, y2] });
full.crop(null, { roi: myRoi, margin: 8 });
full.crop(null, { center: [cx, cy], size: [128, 128], fill: 0 });
// Coordinate helpers (identity passthrough on an uncropped video):
const local = view.toCropCoords(sourcePts); // subtract (x1, y1)
const back = view.toSourceCoords(local); // add (x1, y1)
Video.crop(crop?, opts?)— one region spec acrosscrop/opts.bbox/opts.roi/ (opts.center+opts.size); plusopts.margin,opts.fill(out-of-bounds pad value, default0), andopts.shareDecode(defaulttrue, reuse the source decoder for mosaics).Video.fromCrop(video, crop?, opts?)— static wrapper aroundvideo.crop(...)(videomust be aVideo; path strings are unsupported in the JS port).- Getters:
isCropped,cropRect([x1,y1,x2,y2] | null),cropFill. toCropCoords(pts)/toSourceCoords(pts)— NaN-preserving copies; accept[x,y]pairs or a flat interleaved buffer.- Standalone primitives:
cropPoints,uncropPoints,cropFrame, and theCropVideoBackendclass are all exported (browser-safe).
Mp4BoxVideoBackend¶
Browser-only backend for .mp4 with WebCodecs + mp4box. Supports range requests when the server honors Range and falls back to full download.
// From URL
const backend = new Mp4BoxVideoBackend("/data/movie.mp4");
// From File or Blob (browser)
const backend = new Mp4BoxVideoBackend(file);
const video = new Video({ filename: "/data/movie.mp4", backend });
const t = await video.getFrameTimes();
Constructor: new Mp4BoxVideoBackend(source: string | File | Blob, options?)
Notes:
- Requires WebCodecs (VideoDecoder, EncodedVideoChunk).
- Uses a Range: bytes=0-0 probe to detect range support (no longer requires HEAD).
- Provides precise frame timestamps via getFrameTimes().
- getFrame() uses an async queue internally to prevent race conditions, and accepts an optional AbortSignal.
Hdf5VideoBackend¶
Used for .slp, .h5, or .hdf5 sources (embedded frames).
- Selected automatically by
loadVideo()for HDF5 inputs. - Decodes embedded PNG/JPEG frames in browsers; raw frames require
shape+channelOrder.
MediaVideoBackend¶
Browser fallback when WebCodecs/mp4box is unavailable.
- Uses
HTMLVideoElement+ canvas. - No
getFrameTimes()support.
Labels & Model Classes¶
Key types:
Labels,LabeledFrame,Instance,PredictedInstanceSkeleton,TrackVideo,SuggestionFrame,LabelsSetCamera,CameraGroup,RecordingSession,InstanceGroup(camera/3D utilities)Identity(ground-truth animal identity across sessions)Instance3D,PredictedInstance3D(3D keypoint data)LazyDataStore,LazyFrameList(lazy loading)ROI,UserROI,PredictedROI(spatial annotations)SegmentationMask,UserSegmentationMask,PredictedSegmentationMask(binary masks)BoundingBox,UserBoundingBox,PredictedBoundingBox(detection/tracking)Centroid,UserCentroid,PredictedCentroid(point detections/tracking)LabelImage,UserLabelImage,PredictedLabelImage(dense instance segmentation)Pointhas fields{ xy, visible, complete, score? }wherescoreis an optional confidence value.LabeledFrame.isNegative(boolean, defaultfalse): marks negative-annotated frames.SuggestionFrame.group(string, default"default"): the suggestion group name.
const skeleton = new Skeleton(["nose", "tail"]);
const inst = Instance.fromArray([[10, 20], [30, 40]], skeleton);
const video = new Video({ filename: "/data/movie.mp4" });
const frame = new LabeledFrame({ video, frameIdx: 0, instances: [inst] });
const labels = new Labels({ labeledFrames: [frame], skeletons: [skeleton], videos: [video] });
// Add a video with deduplication
labels.addVideo(video); // no-op if already present
Frame and track index lookups¶
Labels builds lazy Map indices for O(1) frame and track lookups, replacing linear scans through labeledFrames. The frame-aware find() / getCentroids() / getBboxes() / getMasks() / getLabelImages() / getRois() methods use these indices internally when both video and frameIdx are passed.
// O(1) frame lookup
const lf = labels.getFrame(video, 42); // LabeledFrame | null
// O(1) track annotation lookup, sorted by frameIdx
const annotations = labels.getTrackAnnotations(video, track);
// Force index rebuild after external mutations
labels.reindex();
Indices are built lazily on first access and auto-invalidated when frames are added or videos replaced. See the Lazy Loading section below for the caveat that applies when the Labels is lazy.
Deep copy and video remapping¶
Labels.copy() produces a fully independent copy of a Labels object with consistent internal references — frame videos point to the copy's video objects, instance skeletons point to the copy's skeletons, etc. Both eager and lazy Labels are supported.
Labels.replaceVideos() rewrites video references across every collection in one call: labeledFrames, suggestions, sessions, staticRois, temporalRois, masks, labelImages, and centroids.
// Deep copy (eager or lazy — matches the original)
const copy = labels.copy();
// Suppress backend auto-opening on the new copy (faster, safer for bulk ops)
const detached = labels.copy({ openVideos: false });
// Replace video references — specify both old and new
labels.replaceVideos({ oldVideos: [oldVid], newVideos: [newVid] });
// Or infer old from current
labels.replaceVideos({ newVideos: [newVid] });
// Or pass a Map
labels.replaceVideos({ videoMap: new Map([[oldVid, newVid]]) });
LazyDataStore.copy() duplicates the underlying HDF5 column arrays so that two Labels wrapping the same source file can diverge safely.
Frame merging¶
Merge annotations from one LabeledFrame into another with strategy-aware handling. LabeledFrame.mergeAnnotations(other, strategy?, threshold?) supports six strategies, applied across all annotation modalities (centroids, bboxes, masks, label images, ROIs). To populate a single LabeledFrame in the first place, see Adding annotations to frames below.
| Strategy | Behavior |
|---|---|
"keep_original" |
Keep self only, discard everything from other |
"keep_new" |
Replace self's annotations with (copies of) other's |
"keep_both" (default) |
Deduplicate by object identity, union both sets (items from other are shallow-copied before insertion) |
"replace_predictions" |
Keep user annotations from self; drop self's predicted and take all predicted from other |
"auto" |
Spatially match by centroid distance; user beats predicted; add unmatched |
"update_tracks" |
Spatially match and cascade track assignments only |
The auto and update_tracks strategies compare centroid distances against threshold (default 5.0 px). Empty masks and ROIs whose centroid is null are skipped by the matcher.
Under "auto", mask merging honors the fromPredicted provenance link before spatial matching: if a user mask in either frame links to a predicted mask in the other (by object identity), that pair is matched regardless of distance, and only falls back to centroid proximity when no link exists. Other annotation modalities have no fromPredicted link, so they are unaffected.
The strategy argument is typed as MergeStrategy, re-exported from the package root for type-safe call sites:
import type { MergeStrategy } from "@talmolab/sleap-io.js";
const strategy: MergeStrategy = "auto";
lfA.mergeAnnotations(lfB, strategy, 3.0);
// Default behavior (keep_both): dedupe by identity, union everything
lfA.mergeAnnotations(lfB);
// Spatial auto merge with a 3 px threshold
lfA.mergeAnnotations(lfB, "auto", 3.0);
// Keep only predicted updates from other, leave user annotations alone
lfA.mergeAnnotations(lfB, "replace_predictions");
Lazy Loading¶
Load SLP files with on-demand frame materialization for better performance on large datasets.
// Load lazily - only metadata is parsed initially
const labels = await loadSlp("large_dataset.slp", { lazy: true });
console.log(labels.isLazy); // true
// Access individual frames on demand
const frame = labels.labeledFrames.at(0);
// Materialize all frames when needed
labels.materialize();
console.log(labels.isLazy); // false
Key classes:
LazyDataStore: Holds raw HDF5 column data; supportsmaterializeFrame(idx),materializeAll(),copy(), andtoNumpy()fast path.LazyFrameList: Array-like container withat(idx),length,toArray(),[Symbol.iterator](), andmaterializedCount.
Note:
labels.getFrame()andlabels.getTrackAnnotations()throw on lazyLabels. These O(1) lookups rely on fully materialized frame/track indices and would otherwise silently return stale or partial results. You have two options, both of which trigger full materialization under the hood:
- Call
labels.materialize()explicitly, then use the O(1) lookups.- Call
labels.find({ video, frameIdx }), which materializes internally on first call and then uses the same O(1) index. It returnsLabeledFrame[]— take the first element.Both paths do the same amount of work on the first call; there's no cheap "lazy-preserving" variant of frame lookup by
(video, frameIdx).
// Option 1: materialize first, then O(1) lookup
labels.materialize();
const lf = labels.getFrame(video, 42);
// Option 2: find() — equivalent, materializes on first call
const [frame] = labels.find({ video, frameIdx: 42 }); // LabeledFrame | undefined
ROI & Segmentation Masks¶
Spatial annotations stored alongside pose data (SLP format 1.5+).
ROI¶
Region of interest with GeoJSON-like geometry. ROI is abstract; use UserROI or PredictedROI for construction, or use the static factory methods which return UserROI.
// Create from polygon vertices
const roi = ROI.fromPolygon([[0,0], [100,0], [100,100], [0,100]], {
category: "region",
});
// Create from multi-polygon
const roi = ROI.fromMultiPolygon([
[[[0,0], [10,0], [10,10], [0,10], [0,0]]],
[[[20,20], [30,20], [30,30], [20,30], [20,20]]],
]);
// Predicted ROI with confidence score
const pred = new PredictedROI({
geometry: { type: "Polygon", coordinates: [[[0,0],[10,0],[10,10],[0,10],[0,0]]] },
score: 0.95,
});
// Properties
roi.bounds; // { minX, minY, maxX, maxY }
roi.area; // polygon area
roi.centroidXy; // [x, y]
roi.isBbox; // true if axis-aligned rectangle
roi.isPredicted; // false for UserROI, true for PredictedROI
// Explode multi-geometries into individual ROIs (preserves subclass)
const parts = roi.explode(); // ROI[]
// Convert to GeoJSON Feature
const feature = roi.toGeoJSON();
// Convert to mask
const mask = roi.toMask(480, 640);
Note:
ROI.fromBbox()andROI.fromXyxy()are deprecated. UseBoundingBox.fromXywh()orBoundingBox.fromXyxy()instead.Note:
ROI.videois retained for static ROIs (arena boundaries, chamber outlines stored onlabels.staticRois);ROI.frameIdxwas removed in v0.3.0. Frame-bound ROIs derive their frame from the parentLabeledFrame— attach them withlf.append(roi).
SegmentationMask¶
RLE-encoded binary mask. SegmentationMask is abstract; use UserSegmentationMask or PredictedSegmentationMask for direct construction. The fromArray() factory returns UserSegmentationMask.
// Create from 2D boolean array
const mask = SegmentationMask.fromArray(boolArray, 480, 640, {
category: "segmentation",
});
// Create from array with stride (downsampled prediction)
const mask = SegmentationMask.fromArray(data, 120, 160, {
stride: 4, // sets scale to [0.25, 0.25]
});
// Predicted mask with score and optional score map
const pred = new PredictedSegmentationMask({
rleCounts, height: 480, width: 640,
score: 0.92,
scoreMap: new Float32Array(480 * 640), // optional per-pixel confidence
});
// Properties
mask.data; // Uint8Array (decoded)
mask.area; // pixel count
mask.bbox; // { x, y, width, height }
mask.isPredicted; // false for User, true for Predicted
mask.scale; // [number, number] spatial scale (default [1, 1])
mask.offset; // [number, number] spatial offset (default [0, 0])
mask.hasSpatialTransform; // true if scale != [1,1] or offset != [0,0]
mask.imageExtent; // { height, width } in image coordinates
// Resample to target size (removes spatial transform)
const fullRes = mask.resampled(480, 640);
// Trace the actual mask boundary as closed polygon rings in image space
// (honors scale/offset). Disjoint blobs and holes each get their own ring;
// an empty mask returns []. Pure data — works in the browser.
const rings = mask.contours(); // number[][][]
// Convert to a polygon ROI tracing the real outline: a Polygon (one blob,
// holes nested as interior rings) or MultiPolygon (disjoint blobs). A
// predicted mask yields a PredictedROI carrying its score.
const roi = mask.toPolygon();
// Convert to BoundingBox object (with metadata)
const bb = mask.toBbox();
toPolygon()traces the boundary (as of v0.5.0) rather than returning the bounding-box rectangle. For the axis-aligned box usemask.bboxormask.toBbox(). The traced outline is exact and axis-aligned ("staircase"); consumers wanting smooth curves can post-process the rings (e.g. Chaikin subdivision — the demo does this).get data()memoizes the decoded raster, so repeateddata/area/bbox/contours()reads decode the RLE once (treat the returned buffer as read-only).
Adopting predictions (human-in-the-loop). PredictedSegmentationMask.toUser()
mirrors Instance.fromPredicted: it returns a new UserSegmentationMask that
copies the RLE raster and carries over metadata (name, category, source,
track, trackingScore, instance, scale, offset) while dropping
prediction-only fields (score, scoreMap). It sets fromPredicted on the
returned mask as a provenance link.
const userMask = pred.toUser(); // userMask.fromPredicted === pred
const detached = pred.toUser(false); // detached.fromPredicted === null
Note: like
Instance.fromPredicted, the mask'sfromPredictedlink IS persisted to the SLP format. It is stored as an index into the saved mask list and re-linked on read, so it survives a save/load round-trip as long as the source prediction is also saved (in the same or another frame). If the source prediction is not saved, the link loads asnull. Recording at least one link bumps the SLPformat_idto 2.4; the column is always written and reads are presence-gated, so files written before this column existed load the link asnull.
LabeledFrame.unusedPredictedMasks returns the predicted masks in a frame that
no user mask has adopted — the mask analogue of LabeledFrame.unusedPredictions.
A prediction is adopted when a user mask in the same frame links to it via
fromPredicted (checked first, by identity) or, lacking a link, spatially
overlaps it (bbox-centroid within 5 px). The remaining unclaimed predictions are
returned, e.g. for surfacing unreviewed detections in a proofreading UI.
Rendering masks in the browser¶
drawMasks and drawLabelImage are browser-safe raster compositors (pure
ImageData blending — no skia-canvas, no Node built-ins). They are exported
from the browser entry, so a UI can composite masks onto a <canvas>
client-side without the Node renderImage path:
import { drawMasks } from "@talmolab/sleap-io.js"; // browser build
const ctx = canvas.getContext("2d");
ctx.drawImage(videoFrame, 0, 0);
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
drawMasks(img, frame.masks, {
colors: frame.masks.map(maskColorRgb), // e.g. by track identity
alpha: 0.45,
});
ctx.putImageData(img, 0, 0);
// Crisp vector outlines on top, from the data model:
for (const mask of frame.masks) {
for (const ring of mask.contours()) {
ctx.beginPath();
ctx.moveTo(ring[0][0], ring[0][1]);
for (const [x, y] of ring) ctx.lineTo(x, y);
ctx.closePath();
ctx.stroke();
}
}
The vector overlays (drawBboxes, drawRois, drawCentroids, applyOverlay)
and the full renderImage/renderVideo pipeline draw through skia-canvas and
remain Node-only (main entry). See demo/ for a complete browser example that
fills masks with drawMasks and outlines them with smoothed contours().
renderImage/renderVideo auto-draw a frame's centroids (filled circle
markers, colored by track via the pose palette, scoped to the rendered video),
so a centroid-only frame renders without an explicit overlay. drawCentroids
is also exported for standalone use:
import { drawCentroids } from "@talmolab/sleap-io.js"; // Node (skia-canvas)
drawCentroids(imageData, frame.centroids, {
colors: frame.centroids.map(centroidColorRgb), // e.g. by track identity
markerSize: 5,
alpha: 1,
});
BoundingBox¶
Axis-aligned or rotated bounding box for detection/tracking workflows. BoundingBox is abstract; use UserBoundingBox or PredictedBoundingBox for direct construction.
Bounding boxes use corner coordinates (x1, y1, x2, y2) as their primary storage. Center-based properties (xCenter, yCenter, width, height) are available as computed getters.
Construct a user-annotated bbox and attach it to a frame:
const bbox = new UserBoundingBox({
x1: 0, y1: 20, x2: 100, y2: 100,
category: "animal",
});
// The parent LabeledFrame provides video + frameIdx context
let lf = labels.getFrame(labels.videos[0], 3);
if (!lf) {
lf = new LabeledFrame({ video: labels.videos[0], frameIdx: 3 });
labels.append(lf);
}
lf.append(bbox);
Construct a predicted bbox with confidence score:
const predBbox = new PredictedBoundingBox({
x1: 0, y1: 20, x2: 100, y2: 100,
score: 0.95,
});
Factory methods (return UserBoundingBox):
const fromCorners = BoundingBox.fromXyxy(10, 20, 110, 100); // corner coords
const fromTopLeft = BoundingBox.fromXywh(10, 20, 100, 80); // top-left + size
Properties and conversions:
// Stored properties
bbox.x1; bbox.y1; bbox.x2; bbox.y2; // corner coordinates
bbox.angle; // rotation in radians (default 0)
// Computed properties
bbox.xCenter; // (x1 + x2) / 2
bbox.yCenter; // (y1 + y2) / 2
bbox.width; // abs(x2 - x1)
bbox.height; // abs(y2 - y1)
bbox.xyxy; // [x1, y1, x2, y2] (axis-aligned bounding box)
bbox.xywh; // { x, y, width, height }
bbox.corners; // number[][] (4 corner points, respects rotation)
bbox.bounds; // { minX, minY, maxX, maxY }
bbox.area; // width * height
bbox.centroidXy; // [x, y]
bbox.isPredicted; // true for PredictedBoundingBox
bbox.isRotated; // true if angle != 0
// Conversion
const roi = bbox.toRoi(); // Polygon ROI
const mask = bbox.toMask(480, 640); // SegmentationMask
Centroid¶
Point representing the center of an object. Centroid is abstract; use UserCentroid or PredictedCentroid.
// Create user centroid (attach to a frame to provide video + frameIdx context)
const c = new UserCentroid({ x: 100, y: 200, track });
let lf = labels.getFrame(video, 0);
if (!lf) {
lf = new LabeledFrame({ video, frameIdx: 0 });
labels.append(lf);
}
lf.append(c);
// Create predicted centroid with confidence
const pc = new PredictedCentroid({
x: 50, y: 60, z: 3.5,
score: 0.95,
trackingScore: 0.8,
track,
name: "spot1", source: "trackmate",
});
let lf1 = labels.getFrame(video, 1);
if (!lf1) {
lf1 = new LabeledFrame({ video, frameIdx: 1 });
labels.append(lf1);
}
lf1.append(pc);
// Properties
c.xy; // [x, y]
c.yx; // [y, x] (row, col)
c.xyz; // [x, y, z | null]
c.isPredicted; // false for User, true for Predicted
// Convert to single-node Instance
const inst = pc.toInstance(); // PredictedInstance with centroid skeleton
// Create from Instance (center of mass, bbox center, or anchor node)
const centroid = Centroid.fromInstance(instance);
const centroid = Centroid.fromInstance(instance, { method: "bboxCenter" });
const centroid = Centroid.fromInstance(instance, { method: "anchor", node: "head" });
// Instance convenience method (requires centroid.ts import)
const centroid = instance.toCentroid();
const centroid = instance.toCentroid("anchor", "head");
// Instance centroid shortcut (no Centroid object, just coordinates)
instance.centroidXy; // [x, y] | null (mean of visible points)
Centroid Skeleton¶
// Shared single-node skeleton for centroid conversions
const skel = getCentroidSkeleton(); // Skeleton(["centroid"])
CENTROID_SKELETON === getCentroidSkeleton(); // true (singleton)
Labels Annotation Access¶
Annotations (Centroid, BoundingBox, SegmentationMask, LabelImage, frame-bound ROI) live on individual LabeledFrame instances, not on Labels. The Labels getters return flattened read-only views across all frames; mutation always goes through the owning frame.
// Read-only property getters (flat views across all frames)
labels.rois; // ROI[]
labels.masks; // SegmentationMask[]
labels.bboxes; // BoundingBox[]
labels.centroids; // Centroid[]
labels.labelImages; // LabelImage[]
labels.identities; // Identity[]
labels.staticRois; // Static (video-level) ROIs, e.g. arena boundaries
labels.temporalRois; // Frame-bound ROIs (flat view across frames)
// Filtered queries (all support `predicted` filter; frame-aware filters use O(1) indices)
labels.getRois({ video, frameIdx: 0, category: "arena", predicted: false });
labels.getMasks({ video, category: "segmentation", predicted: true });
labels.getBboxes({ video, frameIdx: 0, predicted: true });
labels.getCentroids({ video, frameIdx: 0, track, predicted: true });
labels.getLabelImages({ video, frameIdx: 0, track, category: "cell" });
// Note: getRois({ video }) returns only frame-bound ROIs for that video.
// For static ROIs, use labels.staticRois directly.
// Add a video (deduplicates automatically)
labels.addVideo(video);
Adding annotations to frames¶
Use LabeledFrame.append(annotation) to add any annotation type. It routes by runtime type to the appropriate per-frame list (lf.centroids, .bboxes, .masks, .labelImages, .rois) and handles Instance and PredictedInstance as well. If you know the target list at the call site, you can also push directly.
Note:
LabelsandLabeledFrameeach have anappend()method.labels.append(lf)adds aLabeledFrameto aLabels(and also registers its video and tracks).lf.append(annotation)adds an annotation to a singleLabeledFrame. They're different operations at different levels of the hierarchy.
import {
Labels,
LabeledFrame,
UserCentroid,
UserBoundingBox,
UserSegmentationMask,
} from "@talmolab/sleap-io.js";
// Build a frame with mixed annotations
const lf = new LabeledFrame({ video, frameIdx: 5 });
lf.append(new UserCentroid({ x: 100, y: 200, track }));
lf.append(new UserBoundingBox({ x1: 50, y1: 50, x2: 150, y2: 150 }));
lf.append(UserSegmentationMask.fromArray(maskData, 480, 640));
// Or push directly if you know the type
lf.centroids.push(new UserCentroid({ x: 10, y: 20 }));
// Pass frames as labeledFrames when constructing Labels
const labels = new Labels({ labeledFrames: [lf], videos: [video], skeletons: [skeleton] });
// To mutate an existing Labels, look up the target frame first
let existing = labels.getFrame(video, 5);
if (!existing) {
existing = new LabeledFrame({ video, frameIdx: 5 });
labels.append(existing);
}
existing.append(anotherBbox);
Static ROIs (video-level, e.g. arena boundaries) live on labels.staticRois. Use labels.addStaticRoi(roi) to add one — the method also registers the ROI's track on labels.tracks if present:
labels.addStaticRoi(arenaRoi);
SLP format versions¶
The SLP format version (format_id) is set automatically based on content:
| Version | Trigger |
|---|---|
| 1.5 | ROIs or masks present |
| 1.6 | ROIs with instance associations |
| 1.7 | Bounding boxes |
| 1.8 | Label images |
| 1.9 | Identities or predicted annotations |
| 2.0 | BBox corner-based format |
| 2.1 | Spatial metadata (scale/offset) |
| 2.2 | Chunked label image storage |
| 2.3 | Virtual video crops present |
| 2.4 | Persisted mask fromPredicted provenance link |
Virtual Crops (Format 2.3+)¶
A virtually-cropped video stores its crop rect in a dedicated
top-level /video_crops dataset (a single JSON string), written only when at
least one video is cropped. Its videos_json entry describes the uncropped
source, and its source_video is the uncropped original — so a reader that does
not understand /video_crops simply loads the full-frame source (a graceful, lossy
degrade, never an error).
[
{ "video": 2, "crop": [128, 96, 384, 352], "fill": 0 },
{ "video": 5, "crop": [0, 0, 256, 256], "fill": 0 }
]
video— integer index into thevideos_jsonvideo list.crop—[x1, y1, x2, y2]in source pixel coordinates,x2/y2exclusive.fill— out-of-bounds fill value (regions outside the source are padded, not clamped).
The presence of any crop bumps format_id to 2.3. Files with no crops are
byte-identical to earlier versions (no /video_crops dataset, no version bump).
Label Images (Dense Segmentation)¶
Dense integer label images for instance segmentation workflows (Cellpose, StarDist, SAM, Mask R-CNN output). Each pixel stores an object ID (0 = background, positive integers = objects) with per-object metadata (track, category, name).
LabelImage is abstract; use UserLabelImage or PredictedLabelImage, or use the static factory methods which return UserLabelImage.
Creating Label Images¶
// From a 2D integer array. Tracks are NOT created by default — pass them
// explicitly, or set `createTracks: true` to auto-create one per label ID.
const li = LabelImage.fromArray(
new Int32Array([0, 0, 1, 1, 0, 2, 2, 0, 0]),
3, 3, // height, width
{ tracks: [trackA, trackB] },
);
// Pure segmentation (no tracking): omit tracks (objects carry track = null).
const segLi = LabelImage.fromArray(maskData, 480, 640);
// Opt in to auto-created tracks named by label ID:
const trackedLi = LabelImage.fromArray(maskData, 480, 640, { createTracks: true });
// From segmentation masks (composites multiple binary masks)
const li2 = LabelImage.fromMasks(masks);
// From per-object binary masks (SAM / Mask R-CNN output)
const li3 = LabelImage.fromBinaryMasks(
[mask1_2d, mask2_2d, mask3_2d],
{
tracks: [trackA, trackB, trackC],
categories: ["cell", "cell", "debris"],
scores: [0.95, 0.88, 0.72], // creates PredictedLabelImage if provided
},
);
// Batch creation from a stack of 2D arrays (one per frame)
const labelImages = LabelImage.fromStack({
data: [frame0_2d, frame1_2d, frame2_2d],
tracks: new Map([[1, trackA], [2, trackB]]),
createTracks: true, // auto-create Track objects from label IDs
});
// Attach each label image to its frame
labelImages.forEach((li, i) => {
let lf = labels.getFrame(video, i);
if (!lf) {
lf = new LabeledFrame({ video, frameIdx: i });
labels.append(lf);
}
lf.append(li);
});
Frame and video context for label images comes from the parent LabeledFrame. Factory methods return plain UserLabelImage objects; use lf.append(li) to attach them to a frame.
Properties and Methods¶
// Data access
li.data; // Int32Array (flat, row-major)
li.height; // number
li.width; // number
li.objects; // Map<number, LabelImageObjectInfo>
li.nObjects; // number of objects
li.labelIds; // sorted unique non-zero label IDs in data
li.tracks; // Track[] from object metadata
li.categories; // Set<string> from object metadata
// Spatial transforms (for downsampled predictions)
li.scale; // [number, number] (default [1, 1])
li.offset; // [number, number] (default [0, 0])
li.hasSpatialTransform; // boolean
li.imageExtent; // { height, width } in image coordinates
// Mask extraction
const mask = li.getObjectMask(1); // Uint8Array for label ID 1
const mask = li.getTrackMask(trackA); // Uint8Array for all objects with trackA
const mask = li.getCategoryMask("cell"); // Uint8Array for all "cell" objects
// Iteration over objects
for (const [track, category, binaryMask] of li.items()) {
console.log(track?.name, category, binaryMask.length);
}
// Conversion
const masks = li.toMasks(); // SegmentationMask[]
const bboxes = li.toBboxes(); // BoundingBox[]
const fullRes = li.resampled(480, 640); // new LabelImage at full resolution
// Predicted label image
li.isPredicted; // false for UserLabelImage, true for PredictedLabelImage
// PredictedLabelImage also has: score, scoreMap, scoreMapScale, scoreMapOffset
LabelImageObjectInfo¶
Per-object metadata stored in LabelImage.objects:
interface LabelImageObjectInfo {
track: Track | null;
category: string;
name: string;
instance: Instance | null;
score?: number | null; // per-object confidence (PredictedLabelImage)
trackingScore?: number | null; // per-link tracking cost
}
normalizeLabelIds()¶
Normalize label IDs across multiple frames so each Track or category gets a globally consistent ID. Mutates label images in place.
// Normalize by track (default) — each Track gets a unique, consistent ID
const trackMap: Map<Track, number> = normalizeLabelIds(labelImages);
// Normalize by category — same category string gets the same ID
const catMap: Map<string, number> = normalizeLabelIds(labelImages, { by: "category" });
Identity & 3D Data Structures¶
Identity¶
Ground-truth animal identity that persists across videos, sessions, and experiments. Unlike Track (which represents a temporal trajectory within a single video), Identity represents the actual animal.
const id = new Identity({ name: "mouse_A", color: "#ff0000" });
id.name; // "mouse_A"
id.color; // "#ff0000"
id.metadata; // Record<string, unknown>
Instance3D and PredictedInstance3D¶
3D keypoint data from multi-camera triangulation.
// User-annotated 3D instance
const inst3d = new Instance3D({
points: [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
skeleton,
score: 0.95,
});
inst3d.nVisible; // number of non-NaN keypoints
inst3d.isEmpty; // true if all keypoints are NaN/null
// Predicted 3D instance (with per-keypoint scores)
const pred3d = new PredictedInstance3D({
points: [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
skeleton,
score: 0.95,
pointScores: [0.99, 0.87],
});
3D Fields on Existing Types¶
// Labels.identities — all ground-truth identities
labels.identities; // Identity[]
// Camera.size — image dimensions
camera.size; // [width, height] | undefined
// InstanceGroup — links identity and 3D data to multi-camera groups
group.identity; // Identity | undefined
group.instance3d; // Instance3D | undefined
group.points; // delegates to instance3d.points if available
Lossless, lazy-native session grouping¶
Cross-camera grouping (FrameGroup / InstanceGroup) round-trips losslessly
through read → object model → write without materializing frames, on all
three read paths (eager, lazy, streaming). Instead of resolving the grouping
against a materialized frame table at read time, the model stores the as-read
index refs (camcorder_to_lf_and_inst_idx_map and labeled_frame_by_camera)
and resolves them lazily:
// Typed access — same public API as before, now lazy under the hood.
const fg = labels.sessions[0].frameGroups.get(0)!;
fg.instanceGroups[0].instanceByCamera; // Map<Camera, Instance> (resolved on demand)
fg.labeledFrameByCamera; // Map<Camera, LabeledFrame> (resolved on demand)
Under the lazy reader, resolving one cross-camera ref materializes only that one
frame (via Labels.frameAt(i)); an untouched session serializes straight from
the stored refs with zero frame materialization. Mutating a group (accessing
its map, then editing/reordering instances) re-derives indices from the live
objects on write, so edits are reflected correctly.
On write, the sessions_json shape matches Python sleap-io's session_to_dict
byte-for-byte: calibration is keyed cam_0, cam_1, … (with each camera's
name kept as a field inside its dict), while camcorder_to_video_idx_map,
camcorder_to_lf_and_inst_idx_map, and labeled_frame_by_camera are keyed by the
bare integer camera index ("0", "1", …). These are two different key spaces on
purpose — both Python and sleap-io.js resolve cameras by their order in
calibration / positional index, so the calibration key string is cosmetic. This
is a convergence to Python's existing format, not a new on-disk feature, so it
does not bump format_id (that namespace is shared with Python, which already
defines 2.5+). Reading stays tolerant of legacy name-keyed calibration, so loading
an older file and re-saving upgrades the sessions to the canonical shape in place.
Raw sessions_json access (rawJson / rawSessionsJson) — deprecated¶
Deprecated / transitional, opt-in only. The typed session model above is now a faithful projection of
sessions_json, so consumers should read typed objects rather than this raw dict.rawJsonis a temporary bridge that will be removed once LUCID migrates. It isundefinedby default — pass{ rawSessions: true }to a read entrypoint to capture it. Capturing it deep-clones the whole session payload (doubling session memory inLabels.copy()), which is why it is off by default.
// OPT-IN: capture the verbatim parsed sessions_json dict per session.
const labels = await readSlp(path, { rawSessions: true });
labels.sessions[0].rawJson;
// -> { calibration, camcorder_to_video_idx_map,
// camcorder_to_lf_and_inst_idx_map, frame_group_dicts, metadata, ... }
// Index-aligned convenience getter (derived from sessions, no stored field)
labels.rawSessionsJson; // Array<Record<string, unknown> | undefined>
labels.rawSessionsJson[0]; // === labels.sessions[0].rawJson
// Default (no option): rawJson is undefined, no clone, no copy() bloat.
const lean = await readSlp(path);
lean.sessions[0].rawJson; // undefined
rawSessions is accepted by readSlp, readSlpLazy, and readSlpStreaming.
When captured, rawJson exposes every key, including ones sleap-io.js does not
itself model.
Caveats (when captured):
- Read-time snapshot.
rawJsonis captured at read time and is NOT updated when you mutate the object model afterwards. - Never re-written to disk. Writes are rebuilt from the object model (the
single source of truth);
rawJsonis a pure in-memory read surface over the already-existingsessions_jsondataset. It is not stored inprovenanceor the/metadatablob, so saving does not bloat those. - Index-aligned.
rawSessionsJson[i]corresponds tosessions[i]; entries areundefinedfor sessions constructed in-memory or read withoutrawSessions.
Metadata round-trip (Option 1)¶
Opaque metadata dicts round-trip losslessly through read → object model →
write at every level — RecordingSession.metadata (including nested
metadata.lucid), FrameGroup.metadata, InstanceGroup.metadata, and
CameraGroup.metadata. Arbitrary unknown keys survive by JSON-equality.
Note:
CameraGroup.metadatapreviously read back as{}(silently dropped on read); it is now populated fromcalibration.metadata.
Object-model write limitations (Option 3)¶
The write path (serializeSession) rebuilds sessions_json from a fixed field
set, so it is lossless for everything sleap-io.js structurally models —
per-camera intrinsics/extrinsics (name, rotation, translation, matrix,
distortions, size), cam_N-keyed calibration with name kept as a field,
integer-index-keyed camcorder_to_video_idx_map /
camcorder_to_lf_and_inst_idx_map / labeled_frame_by_camera, and all metadata
dicts. camcorder_to_lf_and_inst_idx_map
now survives losslessly — it is derived from live objects when the group was
mutated, and otherwise falls back to the as-read index refs (so it is preserved
even in lazy mode). The following remain recoverable only via rawSessions:
- Any unmodeled top-level key NOT under a
metadatasub-dict (at session / per-camera / frame-group / instance-group level) is dropped. - Frame groups with zero instance groups are skipped entirely on write.
- Frame groups are keyed by
frame_idx; groups sharing (or missing) aframe_idxcollapse to one entry on read (a pre-existing fidelity gap).
Legacy note: older sleap-io.js builds keyed
calibrationby cameranamewhen a camera had a distinct name, diverging from Python'scam_Nconvention. Reading tolerates both (cameras resolve by order); re-saving canonicalizes tocam_N.
SLEAP Analysis CSV export¶
Export Labels to the SLEAP Analysis CSV — one row per instance per frame with
columns track, frame_idx, instance.score, {node}.score, {node}.x, {node}.y, …
(node columns sorted alphabetically; missing/invisible points and user-instance
scores are empty cells). labelsToCsv is browser-safe (returns the string);
saveLabelsCsv writes to disk (Node).
import { labelsToCsv, saveLabelsCsv } from "@talmolab/sleap-io.js";
const csv = labelsToCsv(labels); // string (browser + Node)
await saveLabelsCsv(labels, "out.csv"); // Node file write
// Span the whole video: emit NaN rows for unlabeled frames, padded to the video
// length when known (mirrors the Analysis-HDF5 export; sleap-io PR #480).
await saveLabelsCsv(labels, "out.csv", { includeEmpty: true });
// Other options: video (Video | index filter), includeScore (default true),
// startFrame / endFrame (inclusive / exclusive).
GeoJSON I/O¶
Convert ROIs to/from GeoJSON format.
// Convert ROIs to GeoJSON FeatureCollection
const geojson = roisToGeoJSON(labels.rois);
// Serialize to JSON string
const json = writeGeoJSON(labels.rois);
// Parse GeoJSON back to ROIs
const rois = roisFromGeoJSON(geojson);
const rois = readGeoJSON(jsonString);
TrackMate CSV I/O¶
Import TrackMate (ImageJ/Fiji) CSV tracking exports as PredictedCentroid objects. Node.js only.
import { readTrackMateCsv, loadTrackMate, isTrackMateFile } from "@talmolab/sleap-io.js";
// Check if a file is a TrackMate spots CSV
isTrackMateFile("data_spots.csv"); // true/false
// Load spots CSV (auto-detects sibling _edges.csv and .tif video)
const labels = readTrackMateCsv("data_spots.csv");
labels.centroids; // PredictedCentroid[]
labels.tracks; // Track[] (from TRACK_ID column)
// With explicit options
const labels = readTrackMateCsv("data_spots.csv", {
edgesPath: "data_edges.csv", // optional, auto-detected from _spots suffix
video: "data.tif", // optional, auto-detected from _spots suffix
});
// Public API alias
const labels = loadTrackMate("data_spots.csv");
TrackMate CSV format:
- *_spots.csv — spot detections with POSITION_X, POSITION_Y, POSITION_Z, FRAME, QUALITY, TRACK_ID
- *_edges.csv — frame-to-frame linkages; LINK_COST is stored as trackingScore on target centroids
Ultralytics YOLO I/O¶
Read and write Ultralytics YOLO datasets — pose, detection, and segmentation. A dataset is a directory of data.yaml + per-split images/ and labels/. The format of each label line is auto-detected by value count. Node.js only (directory-based I/O).
import { loadUltralytics, saveUltralytics } from "@talmolab/sleap-io.js";
// Load a split (auto-detects pose/detection/segmentation per line)
const labels = loadUltralytics("path/to/yolo_dataset", { split: "train" });
labels.labeledFrames; // pose instances (5 + 3k value lines)
labels.bboxes; // UserBoundingBox / PredictedBoundingBox (5 / 6 value lines)
labels.rois; // UserROI from segmentation polygons
// Pointing at data.yaml directly also works
loadUltralytics("path/to/yolo_dataset/data.yaml");
// Write a dataset (default task "pose"; also "detect" / "segment")
await saveUltralytics(labels, "out_dir", { splitRatios: { train: 0.8, val: 0.2 } });
await saveUltralytics(labels, "out_dir", { task: "detect" }); // writes labels.bboxes
await saveUltralytics(labels, "out_dir", { task: "segment" }); // writes labels.rois
Lower-level helpers are also exported: parseLabelFile(path, skeleton, [h, w], { classNames }) returns { instances, rois, bboxes }; detectLineFormat(parts) returns "detection" | "detection_conf" | "segmentation" | "pose"; plus writeLabelFile, writeBboxLabelFile, writeRoiLabelFile, readLabelsSet, parseDataYaml, createDataYaml, and createSkeletonFromConfig.
Label-line formats (coordinates normalized to [0, 1]):
- Detection — class_id x_center y_center width height [confidence] → UserBoundingBox (5 values) or PredictedBoundingBox (6 values).
- Segmentation — class_id x1 y1 x2 y2 … xn yn → UserROI polygon (multi-polygons are exploded on write; interior holes are dropped with a warning).
- Pose — class_id x_center y_center width height x1 y1 v1 … xn yn vn → Instance (visibility 0 = invisible, 1/2 = visible).
Image I/O note: image dimensions are read from the file header (PNG/JPEG/GIF/BMP/TIFF) rather than by decoding pixels. On write, frames backed by an on-disk image file are copied verbatim; frames yielding raw
ImageDatapixels are encoded to PNG; otherwise the frame is skipped with a warning. TheimageFormat/imageQualityoptions apply only to the raw-pixel PNG path.
JABS Pose I/O¶
Read JABS (Jackson Lab Animal Behavior System) pose files. JABS files are HDF5 on disk, so this reader is Node.js only. Matches Python sleap-io v0.7.0 (PR #371): instances are PredictedInstance objects with per-point confidence, and static objects are UserROIs rather than synthetic skeletons.
import { loadJabs } from "@talmolab/sleap-io.js";
// Read a JABS pose file (pose versions 2–6). Async (HDF5 via h5wasm).
const labels = await loadJabs("recording_pose_est_v5.h5");
labels.labeledFrames; // frames of PredictedInstance poses
labels.tracks; // one Track per JABS identity (v2 → a single track "1")
labels.staticRois; // static objects as UserROI (also in labels.rois)
labels.videos[0]; // Video named after the pose file (…_pose_est_vN.h5 → ….avi)
// Optional: override the skeleton (must have one node per keypoint column)
const labels2 = await loadJabs("recording_pose_est_v2.h5", { skeleton: myMouseSkeleton });
Static objects become UserROIs with source: "jabs" and category: "arena" for corners (or "anchor" otherwise); a single-point object (e.g. lixit) is a Point geometry, multi-point (e.g. corners) is a MultiPoint. The default JABS_DEFAULT_SKELETON is the 12-node "Mouse" skeleton; makeJabsDefaultSkeleton(), predictionToInstance(), staticObjectToRoi(), and makeSimpleSkeleton() are also exported.
JABS coordinates are stored
(y, x)and flipped to(x, y)on read. Static-object coordinates are kept as-stored. The writer (saveJabs) is not yet implemented — the common path is a one-time JABS → SLP conversion.
Skeleton Codecs¶
JSON (jsonpickle format)¶
import { readSkeletonJson, isSkeletonJson } from "@talmolab/sleap-io.js";
const skeleton = readSkeletonJson(jsonString);
Training Config¶
import { readTrainingConfigSkeletons, isTrainingConfig } from "@talmolab/sleap-io.js";
const skeletons = readTrainingConfigSkeletons(jsonString);
Codecs & Numpy Helpers¶
Dictionary codec¶
const dict = toDict(labels);
const restored = fromDict(dict);
Numpy codec¶
const array = toNumpy(labels, { returnConfidence: true });
const rebuilt = fromNumpy(array, { video, skeleton, returnConfidence: true });
Also available:
- Labels.numpy({ video, returnConfidence })
- Labels.fromNumpy(data, { video, skeleton, trackNames, firstFrame })
Streaming Options¶
SLP streaming is handled by HDF5 open helpers:
const labels = await loadSlp("https://example.com/session.slp", {
openVideos: false,
h5: { stream: "range", filenameHint: "session.slp" },
});
stream: "auto"uses range streaming when available.stream: "download"forces full file download.- Node only supports string paths or byte buffers for SLP inputs.
- Browser supports URL,
File,FileSystemFileHandle, or byte buffers.
Error Handling Notes¶
Common runtime errors to anticipate:
- SLP parsing:
- Missing
/metadatagroup in invalid.slp. - Unsupported source type in browser or Node.
- Failed fetch for remote SLP (bad URL/status).
-
h5wasmFS unavailable in unsupported environments. -
MP4/WebCodecs:
- WebCodecs not supported or non-browser environment.
- No video tracks or unsupported codec.
-
Failed fetch or no video source available.
-
Media backend:
- Browser-only usage.
- Video load or seek failures.