From 30178be8008087d43946f97a46395b4f639c5012 Mon Sep 17 00:00:00 2001 From: Alex <117711477+0lekW@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:58:58 +1200 Subject: [PATCH] Merge pull request #28833 from 0lekW:avfoundation-fractional-fps-seek-fix Avfoundation fractional fps seek fix #28833 Accompanied by PR on [opencv_extra](https://github.com/opencv/opencv_extra/pull/1345) Fixes https://github.com/opencv/opencv/issues/28831 AVFoundation backend seeking slowly drifts for any video which has a non-integer frame rate. Narrowing of a float `mAssetTrack.nominalFrameRate` to int32_t means fractional rates are treated as integer when seeking by frame number. Solution implemented in this branch is to use the tracks native rational frame duration when seeking (and available). I've also included an AVF only test case which uses a new test file in opencv_extra. This test just compares the video caps expected ms vs actual for a known rate file (23.976). Could consider extending this test to more then just AVF, as from what I can see there are no fractional rate tests / files. ### Pull Request Readiness Checklist See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request - [x] I agree to contribute to the project under Apache 2 License. - [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV - [x] The PR is proposed to the proper branch - [x] There is a reference to the original bug report and related work - [x] There is accuracy test, performance test and test data in opencv_extra repository, if applicable Patch to opencv_extra has the same branch name. - [x] The feature is well documented and sample code can be built with the project CMake --- modules/videoio/src/cap_avfoundation.mm | 16 +++++++-- modules/videoio/src/cap_avfoundation_mac.mm | 14 ++++++-- modules/videoio/test/test_video_io.cpp | 39 +++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/modules/videoio/src/cap_avfoundation.mm b/modules/videoio/src/cap_avfoundation.mm index 4f74ddfaab..aa7ee57142 100644 --- a/modules/videoio/src/cap_avfoundation.mm +++ b/modules/videoio/src/cap_avfoundation.mm @@ -1104,9 +1104,19 @@ bool CvCaptureFile::setProperty(int property_id, double value) { t.value = value * t.timescale / 1000; retval = setupReadingAt(t); break; - case cv::CAP_PROP_POS_FRAMES: - retval = mAssetTrack.nominalFrameRate > 0 ? setupReadingAt(CMTimeMake(value, mAssetTrack.nominalFrameRate)) : false; + case cv::CAP_PROP_POS_FRAMES: { + // Build the seek CMTime from the track's native rational frame + // duration so non-integer rates (e.g. 23.976 = 24000/1001) are exact. + CMTime frameDuration = mAssetTrack.minFrameDuration; + if (frameDuration.timescale > 0 && frameDuration.value > 0) { + retval = setupReadingAt(CMTimeMultiply(frameDuration, (int32_t)value)); + } else if (mAssetTrack.nominalFrameRate > 0) { + retval = setupReadingAt(CMTimeMake(value, mAssetTrack.nominalFrameRate)); + } else { + retval = false; + } break; + } case cv::CAP_PROP_POS_AVI_RATIO: t = mAsset.duration; t.value = round(t.value * value); @@ -1349,4 +1359,4 @@ void CvVideoWriter_AVFoundation::write(cv::InputArray image) { } } -#pragma clang diagnostic pop \ No newline at end of file +#pragma clang diagnostic pop diff --git a/modules/videoio/src/cap_avfoundation_mac.mm b/modules/videoio/src/cap_avfoundation_mac.mm index 5ba1c01c60..3bcd0928de 100644 --- a/modules/videoio/src/cap_avfoundation_mac.mm +++ b/modules/videoio/src/cap_avfoundation_mac.mm @@ -1033,9 +1033,19 @@ bool CvCaptureFile::setProperty_(int property_id, double value) { t.value = value * t.timescale / 1000; retval = setupReadingAt(t); break; - case cv::CAP_PROP_POS_FRAMES: - retval = mAssetTrack.nominalFrameRate > 0 ? setupReadingAt(CMTimeMake(value, mAssetTrack.nominalFrameRate)) : false; + case cv::CAP_PROP_POS_FRAMES: { + // Build the seek CMTime from the track's native rational frame + // duration so non-integer rates (e.g. 23.976 = 24000/1001) are exact. + CMTime frameDuration = mAssetTrack.minFrameDuration; + if (frameDuration.timescale > 0 && frameDuration.value > 0) { + retval = setupReadingAt(CMTimeMultiply(frameDuration, (int32_t)value)); + } else if (mAssetTrack.nominalFrameRate > 0) { + retval = setupReadingAt(CMTimeMake(value, mAssetTrack.nominalFrameRate)); + } else { + retval = false; + } break; + } case cv::CAP_PROP_POS_AVI_RATIO: t = mAsset.duration; t.value = round(t.value * value); diff --git a/modules/videoio/test/test_video_io.cpp b/modules/videoio/test/test_video_io.cpp index 9c1b5cb209..784b89d600 100644 --- a/modules/videoio/test/test_video_io.cpp +++ b/modules/videoio/test/test_video_io.cpp @@ -1238,4 +1238,43 @@ inline static std::string stream_capture_ffmpeg_name_printer(const testing::Test INSTANTIATE_TEST_CASE_P(videoio, stream_capture_ffmpeg, testing::Values("h264", "h265", "mjpg.avi"), stream_capture_ffmpeg_name_printer); +// Seek by frame index must be accurate on non-integer frame rates. +typedef testing::TestWithParam PreciseSeekingTest; + +TEST_P(PreciseSeekingTest, seek_nonInteger_fps_frame_accurate) +{ + VideoCaptureAPIs apiPref = GetParam(); + if (!videoio_registry::hasBackend(apiPref)) + throw SkipTestException(cv::String("Backend is not available/disabled: ") + cv::videoio_registry::getBackendName(apiPref)); + + const std::string file = findDataFile("video/sample_23976fps.mp4"); + + VideoCapture cap; + ASSERT_TRUE(cap.open(file, apiPref)) + << "AVFoundation could not open " << file; + + const double fps_num = 24000.0; + const double fps_den = 1001.0; + const double frame_period_ms = fps_den * 1000.0 / fps_num; // ~41.7083 + const double tol_ms = frame_period_ms * 0.5; + + const int targets[] = { 0, 1, 24, 50, 99 }; + for (int N : targets) + { + ASSERT_TRUE(cap.set(CAP_PROP_POS_FRAMES, N)) + << "seek to frame " << N << " failed"; + Mat img; + ASSERT_TRUE(cap.read(img)) + << "read after seek to frame " << N << " failed"; + const double got_ms = cap.get(CAP_PROP_POS_MSEC); + const double expect_ms = N * frame_period_ms; + EXPECT_NEAR(got_ms, expect_ms, tol_ms) + << "non-integer fps seek drifted at frame N=" << N; + } +} + +VideoCaptureAPIs seekable_backeinds[] = {CAP_FFMPEG, CAP_MSMF, CAP_AVFOUNDATION}; + +INSTANTIATE_TEST_CASE_P(videoio, PreciseSeekingTest, testing::ValuesIn(seekable_backeinds), safe_capture_name_printer); + } // namespace