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:
parent
e19f3120ca
commit
51dd062407
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue