stable-datasets is a research-oriented dataset library for images, time series/audio, and video. It is built around a simple idea: dataset onboarding should be easy, storage should be explicit, and the object you load should already be usable in PyTorch-style training code.
The library ships dataset builders under stable_datasets/images/, stable_datasets/timeseries/, and stable_datasets/video/. On first load, a builder downloads raw assets, writes a processed cache, and returns a map-style StableDataset or StableDatasetDict. After that, repeated loads hit the processed cache directly.
This library is aimed at research workflows where you want:
- one place to keep dataset provenance, download logic, and schema definitions
- one loading interface across modalities
- cache-backed datasets that can be reopened quickly
- explicit storage choices instead of hidden format decisions
- a path to specialized layouts when one modality needs them
The central design goal is that datasets and modalities should have intelligent store-time and access-time defaults, while still leaving room for specialized layouts when workloads call for them. Video is the motivating example.
pip install -e .
pip install -e ".[dev,docs]"By default, downloads and processed caches live under ~/.stable_datasets/. You can override the root with:
export STABLE_DATASETS_CACHE_DIR=/data/stable_datasetsor pass download_dir= / processed_cache_dir= per dataset.
The examples/ directory contains small, runnable examples:
- examples/images.py: image classification with
CIFAR10 - examples/timeseries.py: audio/time-series loading with
AudioMNIST - examples/video.py: video loading with
SSv2,VideoRef, andset_video_decode
from stable_datasets.images import CIFAR10
ds = CIFAR10(split="train")
sample = ds[0]
print(sample.keys()) # dict_keys(["image", "label"])
print(type(sample["image"])) # PIL.Image.Image
print(sample["label"]) # integer class id
ds_torch = ds.with_format("torch")
torch_sample = ds_torch[0]
print(torch_sample["image"].shape) # C x H x Wfrom stable_datasets.timeseries import AudioMNIST
ds = AudioMNIST(split="train")
sample = ds[0]
print(sample.keys()) # series, label, speaker_id, ...
print(len(sample["series"])) # channels
ds_np = ds.with_format("numpy")
np_sample = ds_np[0]
print(np_sample["series"].shape)from stable_datasets import VideoDecodeConfig
from stable_datasets.video import SSv2
ds = SSv2(split="train", storage_format="lance")
sample = ds[0]
video_ref = sample["video"]
print(video_ref.path)
print(video_ref.extension)
decoded = ds.set_video_decode(
VideoDecodeConfig(
num_frames=16,
sampling="uniform",
decoder="torchcodec",
output="torch",
layout="TCHW",
)
)
decoded_sample = decoded[0]
print(decoded_sample["video"].shape)The important semantic point is that default video rows return a VideoRef, which is a storage-normalized handle. Decoding is a retrieval-time policy and not baked into the stored schema.
The library has four main layers:
-
BaseDatasetBuilderEach dataset defines provenance, schema, and example generation. Builders are responsible for sourcing raw data and yielding Python examples. -
Cache writers The cache layer turns builder output into a processed on-disk representation. Most datasets use the generic row-per-example writers in
stable_datasets/cache.py. -
Storage backends Backends reopen processed caches and provide row access. The main layouts today are:
arrow-shardslance-rowslance-video-frames
-
StableDatasetThe dataset object presents a uniform map-style API, handles formatting, and optionally applies read-time video decoding.
The practical flow is:
Builder -> cache writer -> backend -> formatter -> StableDataset
stable_datasets/schema.pyPublic schema surface plus dataset metadata/config types.stable_datasets/features/Feature implementations such asImage,Video,Array3D,ClassLabel, andValue.stable_datasets/backends/Physical storage layouts and read-side logic.stable_datasets/cache.pyCache writers, metadata, and cache opening.stable_datasets/dataset.pyStableDataset,StableDatasetDict, and read-time video decode integration.
The distinction between schema.py and features/ is deliberate:
features/owns modality behavior This is where encode/decode/format logic for concrete feature types lives.schema.pyowns dataset description This is where dataset metadata types,Features, versioning, and the stable import surface live.
That split matters because adding a new modality should mostly mean adding a new feature implementation, not threading special cases through unrelated files.
Video is the modality where storage and retrieval semantics diverge the most, so the library treats it explicitly.
Video currently supports three storage modes:
Video(storage="path")Stages the source video into cache-owned assets and stores a structured cell describing the cached file.Video(storage="bytes")Stores the raw video bytes inline in the cache, again with a structured metadata cell.Video(storage="frames")Uses a specialized Lance frame layout for segment-oriented access.
At a practical level:
- choose
pathwhen you want the cache to own the video files but still preserve a normal compressed-video workflow - choose
byteswhen you want the cache to be self-contained and not depend on separate staged files - choose
frameswhen your workload is really “sample lots of short frame windows quickly”.
For path and bytes, reading a row returns a VideoRef. A VideoRef is intentionally storage-only:
- it gives you
.pathand.bytes - it carries metadata like extension and media type
- it does not own decoder construction or frame sampling
Storage decides what is cached; retrieval decides how to decode it.
So the default access pattern for path and bytes is:
sample = ds[0]
ref = sample["video"]
# low-level access
video_path = ref.path
video_bytes = ref.bytesFrom there, users either decode explicitly with their library of choice, or ask the dataset to decode at read time with set_video_decode(...).
If you want decoded tensors, use set_video_decode(...) with VideoDecodeConfig:
decoded = ds.set_video_decode(
VideoDecodeConfig(num_frames=16, sampling="random")
)
frames = decoded[0]["video"]This keeps decode policy out of the persisted schema and lets users swap decoders or supply custom decode functions without rebuilding caches.
Custom hooks are also supported:
decode_fnfor per-sample custom decodedecode_fn_batchedfor worker-local batched decode insideStableDataset.__getitems__
Video(storage="frames") is the specialized path with a different logical dataset layout.
The writer:
- fully decodes each source video
- optionally resizes frames
- re-encodes each frame as WebP
- stores one Lance row per frame
The backend then exposes frame windows as samples, not original videos as samples. In other words:
- dataset length becomes “number of valid frame windows”
sample["video"]is already a frame stack- rows also carry
start_frame,frame_indices, and related segment metadata
So frames mode points users toward a different access pattern:
sample = ds[0]
frames = sample["video"]
start = sample["start_frame"]
indices = sample["frame_indices"]This layout is aimed at workloads like video SSL or action recognition where repeated short-window random access is more important than preserving the original compressed video as the primary read unit. However, Video(storage="frames") has a larger upfront preparation cost than path or bytes, because the cache writer decodes each source video, optionally resizes frames, and re-encodes them as WebP. The payoff is much faster random access to short temporal windows at training time.
To add a new dataset:
- choose the right modality package under
stable_datasets/ - subclass
BaseDatasetBuilder - define
VERSIONandSOURCE - implement
_info() - implement
_generate_examples(...) - add tests that cover metadata and at least one smoke path
The main contract is that _info() and _generate_examples(...) must agree exactly on field names and types.
pytest -qFor targeted work, run the relevant subset under stable_datasets/tests/.