Virtual cropping¶
sleap-io can expose a virtual, on-read crop of a video — a cropped view whose
frames are produced by decoding the source and slicing in memory, without copying or
re-encoding any pixels on disk. It is the lazy, non-destructive counterpart of the
materializing Transforms pipeline: a virtually-cropped frame is
byte-identical to what baking a Transform(crop=...) would write.
Quick start¶
>>> import sleap_io as sio
>>>
>>> full = sio.load_video("tests/data/videos/centered_pair_low_quality.mp4")
>>> print(full.shape)
(1100, 384, 384, 1)
>>>
>>> # A cropped view. crop = (x1, y1, x2, y2), with x2/y2 EXCLUSIVE.
>>> view = full.crop((64, 64, 192, 192))
>>> print(view.shape) # cropped view
(1100, 128, 128, 1)
>>> print(view[0].shape) # a single cropped frame
(128, 128, 1)
>>> print(view._crop_tuple()) # (x1, y1, x2, y2) -- view.crop is not the rect
(64, 64, 192, 192)
>>> print(view.source_video is full) # provenance to the uncropped original
True
Video.from_crop opens a file and crops it in one call:
>>> import sleap_io as sio
>>> view = sio.Video.from_crop(
... "tests/data/videos/centered_pair_low_quality.mp4", crop=(64, 64, 192, 192)
... )
>>> print(view.shape)
(1100, 128, 128, 1)
The returned object is a normal Video: shape, len(), grayscale,
NumPy-style indexing, and matching all report the cropped view.
The crop convention¶
A crop is (x1, y1, x2, y2) in source pixel coordinates, with x2/y2
exclusive — exactly the convention used by Transform and
crop_frame. The cropped size is (y2 - y1, x2 - x1).
Coordinates may be negative or extend past the source — out-of-bounds regions are
padded with fill (default 0), never clamped, so the output shape is always
exactly (y2 - y1, x2 - x1). This makes fixed-size, centroid-following windows easy:
>>> import sleap_io as sio
>>> full = sio.load_video("tests/data/videos/centered_pair_low_quality.mp4")
>>> cx, cy = 192, 192
>>> # Fixed 128x128 window centered on a point (may run off the edge -> padded).
>>> view = full.crop(center=(cx, cy), size=(128, 128), fill=0)
>>> print(view.shape) # (n_frames, 128, 128, channels)
(1100, 128, 128, 1)
Video.crop accepts one region spec — an explicit crop rect, a bbox=(x1,y1,x2,y2),
an roi (anything exposing shapely-style .bounds, expanded by margin), or a
center/size pair:
full.crop((x1, y1, x2, y2)) # explicit rect
full.crop(bbox=(x1, y1, x2, y2)) # same, named
full.crop(roi=my_roi, margin=8) # axis-aligned bounds of an ROI + margin
full.crop(center=(cx, cy), size=(w, h)) # fixed-size window
Coordinates¶
A crop is a pure integer translation by (x1, y1), so mapping landmark coordinates
between source and cropped frames is exact and NaN-preserving:
>>> import numpy as np
>>> import sleap_io as sio
>>> full = sio.load_video("tests/data/videos/centered_pair_low_quality.mp4")
>>> view = full.crop((64, 64, 192, 192))
>>> pts_source = np.array([[100.0, 120.0]])
>>> pts_crop = view.to_crop_coords(pts_source) # subtract (x1, y1)
>>> print(pts_crop)
[[36. 56.]]
>>> pts_source = view.to_source_coords(pts_crop) # add (x1, y1)
>>> print(pts_source)
[[100. 120.]]
On an uncropped video these are identity passthroughs, so the same call works
regardless of whether a video happens to be cropped. The underlying functions live in
sleap_io.transform.points as crop_points / uncrop_points.
Coordinates are never rewritten on disk
Virtual cropping never mutates stored instance.points. These helpers are
read-time conveniences for presenting/ingesting coordinates in cropped-frame space.
Mosaics: many crops, one decode¶
Multiple differently-cropped views of one physical file can share a single decoder, so the source frame is decoded once per read rather than once per tile:
>>> import sleap_io as sio
>>> full = sio.load_video("tests/data/videos/centered_pair_low_quality.mp4")
>>> tiles = [
... full.crop((x, y, x + 128, y + 128)) # share_decode=True (default)
... for y in range(0, full.shape[1] - 128, 128)
... for x in range(0, full.shape[2] - 128, 128)
... ]
>>> labels = sio.Labels(videos=tiles)
>>> print(len(labels.videos))
4
Each tile reuses full's backend as its inner reader. The tiles do not own that
shared decoder, so closing one tile does not tear down its siblings; the owning source
Video manages the decoder's lifetime. (Decoder sharing is intentionally not preserved
across pickle/deepcopy/open() — each reconstruction rebuilds its own reader.)
Two crops of the same file with different crops are kept distinct through merge, append, and matching; two crops with the same rect dedup to one view.
Saving & loading (SLP round-trip)¶
Crops round-trip through .slp without breaking older readers:
sio.save_file(labels, "mosaic.slp")
labels2 = sio.load_file("mosaic.slp")
labels2.videos[0]._crop_tuple() # (0, 0, 128, 128) -- preserved
labels2.videos[0].shape # (1000, 128, 128, 3)
labels2.videos[0].source_video.shape # (1000, 1080, 1920, 3)
len(labels2.videos) # all tiles preserved (not collapsed)
- The crop rects are stored in a dedicated top-level
/video_cropsdataset, written only when a crop is present; thevideos_jsonentry describes the uncropped source. - An older reader that does not understand
/video_cropssimply loads the uncropped source video — a graceful, lossy degrade, never an error. - Files with no crops are byte-identical to before this feature existed (no
/video_crops, no format-version bump).
Applying (baking) a crop to disk¶
A virtual crop can be materialized to a real video file — the cropped pixels become physical and the crop is no longer a read-time view. This is coordinate-neutral: a virtual crop already presents cropped-frame coordinates, so baking the pixels leaves all point coordinates unchanged.
Video.apply_crop bakes one cropped video and returns a new Video for the baked file,
preserving provenance (source_video is the uncropped original):
view = full.crop((320, 200, 576, 456))
baked = view.apply_crop("crop.mp4")
baked.shape # (1000, 256, 256, 3) — cropped, now physical
baked.source_video.shape # (1000, 1080, 1920, 3) — uncropped original
baked._crop_tuple() # None — the crop is materialized, not virtual
Labels.apply_crops bakes every virtually-cropped video in a Labels and rewires all
references (labeled frames, ROIs, suggestions) to the baked files; uncropped videos are
untouched and coordinates are unchanged:
From the command line, sio apply-crops materializes every virtual crop in an SLP,
writing baked videos to a directory next to the output and updating the references:
apply_crop vs sio transform --crop
apply_crop materializes an existing virtual crop (no coordinate change).
sio transform --crop applies a new crop and adjusts coordinates — that is the
materializing transform_video / transform_labels path:
Encoder padding
The H.264 encoder pads frame dimensions up to a multiple of 16 (bottom/right only, preserving the top-left content and coordinate alignment). A baked video whose cropped width/height are not multiples of 16 is padded on those edges.
Performance expectations¶
The crop is applied after a full-frame decode for every backend except raw, sub-frame-chunked HDF5, where it can push the region read down to the storage layer:
| Backend | Strategy | I/O effect |
|---|---|---|
MediaVideo (mp4/H.264/…) |
decode full frame, slice | No decode/I/O savings — inter-frame codecs must decode the whole frame; the slice is a free in-memory view. Saves resident array size only. |
HDF5Video raw rank-4, sub-frame chunked |
hyperslab region read (ds[i, y1:y2, x1:x2, :]) |
Real I/O reduction — only the overlapping chunks are read/decompressed. The one case where a crop saves disk work. |
HDF5Video raw rank-4, per-frame chunked |
region read (whole chunk still fetched) | Modest — skips chunk reassembly, not I/O. |
HDF5Video embedded PNG/JPEG (.pkg.slp) |
decode full image, slice | No savings — the whole image must be decoded before any spatial selection. |
ImageVideo, TiffVideo, SeqVideo |
decode full frame, slice | No savings with the current decoders. |
Pushdown for raw HDF5 is automatic and gated on the dataset's actual chunking; it falls back to a full decode plus slice (byte-identical) whenever it would not help.
Non-goals¶
Virtual cropping is a pure translate-and-clip view. It deliberately does not do:
- Rotation, scale, pad, or flip on read — those remain the domain of the
materializing
Transformpipeline. - Decode-cost savings for compressed video — only sub-frame-chunked raw HDF5 sees real I/O savings; everywhere else the crop is a free post-decode view.
- Lossless export through non-SLP writers (NWB, COCO, JABS, Ultralytics) — those
formats have no crop concept; exporting a cropped
Labelsthrough them is acceptably lossy (the cropped frame and its coordinates are emitted as-is). - Rewriting on-disk point coordinates — the source labels are never mutated.
See also¶
- Transforms: the materializing crop/scale/rotate/pad/flip pipeline.
- Video: the
Videofacade and its backends.