fix: VideoInfo.fps returns float instead of truncated int (#2210)

* VideoInfo.fps returns float instead of int

Truncating the raw CAP_PROP_FPS value with int() causes timing drift for
non-integer frame rates (23.976, 29.97, 59.94). Over a long video this
accumulates into noticeable sync errors — e.g. 23 vs 23.976 drifts ~1s
per minute of footage.

Changes:
- VideoInfo.fps type annotation: int -> float
- from_video_path: int(video.get(CAP_PROP_FPS)) -> float(...)
- ByteTrack.frame_rate type annotation: int -> float (already converts
  to int internally via max_time_lost = int(frame_rate / 30.0 * buffer))
- Tests: assert fps is float, add float_fps_video_path fixture at 23.976

* fix: update examples to cast float fps to int where required
* fix: wrap long docstring line in FPSBasedTimer (ruff E501)
* test: remove unused float_fps_video_path fixture

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
abritton2002 2026-04-13 14:05:19 -04:00 committed by GitHub
parent e19f3120ca
commit 51dd062407
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 47 additions and 16 deletions

View File

@ -1,5 +1,9 @@
# Changelog
### 0.28.0 <small>Unreleased</small>
- Fixed [#2210](https://github.com/roboflow/supervision/pull/2210): [`sv.VideoInfo.fps`](https://supervision.roboflow.com/latest/utils/video/#supervision.utils.video.VideoInfo) now returns a `float` instead of a truncated `int`. Previously, frame rates like 23.976, 29.97, and 59.94 were silently truncated, causing frame-timing drift that accumulates over long videos. The type of `VideoInfo.fps` has changed from `int` to `float`; callers that pass `fps` to APIs requiring an integer (such as `deque(maxlen=...)` or `TraceAnnotator(trace_length=...)`) should wrap the value with `int()`.
### 0.27.0 <small>Nov 16, 2025</small>
- Added [#2008](https://github.com/roboflow/supervision/pull/2008): [`sv.filter_segments_by_distance`](https://supervision.roboflow.com/0.27.0/detection/utils/masks/#supervision.detection.utils.masks.filter_segments_by_distance) to keep the largest connected component and nearby components within an absolute or relative distance threshold. Useful for cleaning segmentation predictions from models such as SAM, SAM2, YOLO segmentation, and RF-DETR segmentation.

View File

@ -84,7 +84,7 @@ def main(
)
trace_annotator = sv.TraceAnnotator(
thickness=thickness,
trace_length=video_info.fps * 2,
trace_length=int(video_info.fps * 2),
position=sv.Position.BOTTOM_CENTER,
)
@ -93,7 +93,7 @@ def main(
polygon_zone = sv.PolygonZone(polygon=SOURCE)
view_transformer = ViewTransformer(source=SOURCE, target=TARGET)
coordinates = defaultdict(lambda: deque(maxlen=video_info.fps))
coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps)))
with sv.VideoSink(target_video_path, video_info) as sink:
for frame in frame_generator:

View File

@ -70,7 +70,7 @@ def main(
)
trace_annotator = sv.TraceAnnotator(
thickness=thickness,
trace_length=video_info.fps * 2,
trace_length=int(video_info.fps * 2),
position=sv.Position.BOTTOM_CENTER,
)
@ -79,7 +79,7 @@ def main(
polygon_zone = sv.PolygonZone(polygon=SOURCE)
view_transformer = ViewTransformer(source=SOURCE, target=TARGET)
coordinates = defaultdict(lambda: deque(maxlen=video_info.fps))
coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps)))
with sv.VideoSink(target_video_path, video_info) as sink:
for frame in frame_generator:

View File

@ -71,7 +71,7 @@ def main(
)
trace_annotator = sv.TraceAnnotator(
thickness=thickness,
trace_length=video_info.fps * 2,
trace_length=int(video_info.fps * 2),
position=sv.Position.BOTTOM_CENTER,
)
@ -80,7 +80,7 @@ def main(
polygon_zone = sv.PolygonZone(polygon=SOURCE)
view_transformer = ViewTransformer(source=SOURCE, target=TARGET)
coordinates = defaultdict(lambda: deque(maxlen=video_info.fps))
coordinates = defaultdict(lambda: deque(maxlen=int(video_info.fps)))
with sv.VideoSink(target_video_path, video_info) as sink:
for frame in frame_generator:

View File

@ -11,17 +11,18 @@ class FPSBasedTimer:
per second (FPS).
Attributes:
fps (int): The frame rate of the video stream, used to calculate time durations.
fps (float): The frame rate of the video stream, used to calculate
time durations.
frame_id (int): The current frame number in the sequence.
tracker_id2frame_id (Dict[int, int]): Maps each tracker's ID to the frame number
at which it was first detected.
"""
def __init__(self, fps: int = 30) -> None:
def __init__(self, fps: float = 30) -> None:
"""Initializes the FPSBasedTimer with the specified frames per second rate.
Args:
fps (int): The frame rate of the video stream. Defaults to 30.
fps (float): The frame rate of the video stream. Defaults to 30.
"""
self.fps = fps
self.frame_id = 0

View File

@ -33,7 +33,8 @@ class ByteTrack:
minimum_matching_threshold: Threshold for matching tracks with detections.
Decreasing minimum_matching_threshold improves accuracy but risks fragmentation.
Increasing it improves completeness but risks false positives and drift.
frame_rate: The frame rate of the video.
frame_rate: The frame rate of the video. Accepts float values (e.g. 23.976,
29.97) for accurate lost-track-buffer calculation.
minimum_consecutive_frames: Number of consecutive frames that an object must
be tracked before it is considered a 'valid' track.
Increasing minimum_consecutive_frames prevents the creation of accidental tracks from
@ -45,7 +46,7 @@ class ByteTrack:
track_activation_threshold: float = 0.25,
lost_track_buffer: int = 30,
minimum_matching_threshold: float = 0.8,
frame_rate: int = 30,
frame_rate: float = 30,
minimum_consecutive_frames: int = 1,
):
self.track_activation_threshold = track_activation_threshold

View File

@ -27,7 +27,8 @@ class VideoInfo:
Attributes:
width: width of the video in pixels
height: height of the video in pixels
fps: frames per second of the video
fps: frames per second of the video as a float. Common values include
23.976, 24.0, 25.0, 29.97, 30.0, 59.94, and 60.0.
total_frames: total number of frames in the video,
default is None
@ -38,7 +39,7 @@ class VideoInfo:
video_info = sv.VideoInfo.from_video_path(video_path="<SOURCE_VIDEO_FILE>")
video_info
# VideoInfo(width=3840, height=2160, fps=25, total_frames=538)
# VideoInfo(width=3840, height=2160, fps=25.0, total_frames=538)
video_info.resolution_wh
# (3840, 2160)
@ -47,7 +48,7 @@ class VideoInfo:
width: int
height: int
fps: int
fps: float
total_frames: int | None = None
@classmethod
@ -58,7 +59,7 @@ class VideoInfo:
width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = int(video.get(cv2.CAP_PROP_FPS))
fps = float(video.get(cv2.CAP_PROP_FPS))
total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
video.release()
return VideoInfo(width, height, fps, total_frames)

View File

@ -149,11 +149,35 @@ def test_video_info(dummy_video_path):
video_info = VideoInfo.from_video_path(dummy_video_path)
assert video_info.width == 640
assert video_info.height == 480
assert video_info.fps == 25
assert video_info.fps == pytest.approx(25.0)
assert isinstance(video_info.fps, float)
assert video_info.total_frames == 10
assert video_info.resolution_wh == (640, 480)
def test_video_info_float_fps(dummy_video_path, monkeypatch):
"""
Verify that VideoInfo preserves non-integer FPS values as floats.
Scenario: Retrieving metadata from a video while OpenCV reports 23.976 fps.
Expected: fps is returned as the original float value, not truncated to an
integer. This prevents frame-timing drift in long videos.
"""
original_get = cv2.VideoCapture.get
def mocked_get(self, prop_id):
if prop_id == cv2.CAP_PROP_FPS:
return 23.976
return original_get(self, prop_id)
monkeypatch.setattr(cv2.VideoCapture, "get", mocked_get)
video_info = VideoInfo.from_video_path(dummy_video_path)
assert isinstance(video_info.fps, float)
assert video_info.fps == pytest.approx(23.976)
assert video_info.fps != int(video_info.fps)
def test_get_video_frames_generator(dummy_video_path):
"""
Verify that get_video_frames_generator yields frames with correct shapes.