From 3e43d0cfca9753bcc4983f610b75d70c3f25f0cd Mon Sep 17 00:00:00 2001 From: Kumataro Date: Wed, 19 Mar 2025 20:24:08 +0900 Subject: [PATCH] Merge pull request #26971 from Kumataro:fix26970 imgcodecs: gif: support animated gif without loop #26971 Close #26970 ### 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 --- .../imgcodecs/include/opencv2/imgcodecs.hpp | 14 ++- modules/imgcodecs/src/grfmt_gif.cpp | 83 +++++++++----- modules/imgcodecs/src/grfmt_gif.hpp | 7 +- modules/imgcodecs/test/test_gif.cpp | 104 ++++++++++++++++++ 4 files changed, 172 insertions(+), 36 deletions(-) diff --git a/modules/imgcodecs/include/opencv2/imgcodecs.hpp b/modules/imgcodecs/include/opencv2/imgcodecs.hpp index 5bf850b69a..b78b641121 100644 --- a/modules/imgcodecs/include/opencv2/imgcodecs.hpp +++ b/modules/imgcodecs/include/opencv2/imgcodecs.hpp @@ -118,8 +118,8 @@ enum ImwriteFlags { IMWRITE_JPEGXL_EFFORT = 641,//!< For JPEG XL, encoder effort/speed level without affecting decoding speed; it is between 1 (fastest) and 10 (slowest). Default is 7. IMWRITE_JPEGXL_DISTANCE = 642,//!< For JPEG XL, distance level for lossy compression: target max butteraugli distance, lower = higher quality, 0 = lossless; range: 0 .. 25. Default is 1. IMWRITE_JPEGXL_DECODING_SPEED = 643,//!< For JPEG XL, decoding speed tier for the provided options; minimum is 0 (slowest to decode, best quality/density), and maximum is 4 (fastest to decode, at the cost of some quality/density). Default is 0. - IMWRITE_GIF_LOOP = 1024,//!< For GIF, it can be a loop flag from 0 to 65535. Default is 0 - loop forever. - IMWRITE_GIF_SPEED = 1025,//!< For GIF, it is between 1 (slowest) and 100 (fastest). Default is 96. + IMWRITE_GIF_LOOP = 1024, //!< Not functional since 4.12.0. Replaced by cv::Animation::loop_count. + IMWRITE_GIF_SPEED = 1025, //!< Not functional since 4.12.0. Replaced by cv::Animation::durations. IMWRITE_GIF_QUALITY = 1026, //!< For GIF, it can be a quality from 1 to 8. Default is 2. See cv::ImwriteGifCompressionFlags. IMWRITE_GIF_DITHER = 1027, //!< For GIF, it can be a quality from -1(most dither) to 3(no dither). Default is 0. IMWRITE_GIF_TRANSPARENCY = 1028, //!< For GIF, the alpha channel lower than this will be set to transparent. Default is 1. @@ -260,10 +260,20 @@ It provides support for looping, background color settings, frame timing, and fr struct CV_EXPORTS_W_SIMPLE Animation { //! Number of times the animation should loop. 0 means infinite looping. + /*! @note At some file format, when N is set, whether it is displayed N or N+1 times depends on the implementation of the user application. This loop times behaviour has not been documented clearly. + * - (GIF) See https://issues.chromium.org/issues/40459899 + * And animated GIF with loop is extended with the Netscape Application Block(NAB), which it not a part of GIF89a specification. See https://en.wikipedia.org/wiki/GIF#Animated_GIF . + * - (WebP) See https://issues.chromium.org/issues/41276895 + */ CV_PROP_RW int loop_count; //! Background color of the animation in BGRA format. CV_PROP_RW Scalar bgcolor; //! Duration for each frame in milliseconds. + /*! @note (GIF) Due to file format limitation + * - Durations must be multiples of 10 milliseconds. Any provided value will be rounded down to the nearest 10ms (e.g., 88ms → 80ms). + * - 0ms(or smaller than expected in user application) duration may cause undefined behavior, e.g. it is handled with default duration. + * - Over 65535 * 10 milliseconds duration is not supported. + */ CV_PROP_RW std::vector durations; //! Vector of frames, where each Mat represents a single frame. CV_PROP_RW std::vector frames; diff --git a/modules/imgcodecs/src/grfmt_gif.cpp b/modules/imgcodecs/src/grfmt_gif.cpp index d33b600722..3a70dcc568 100644 --- a/modules/imgcodecs/src/grfmt_gif.cpp +++ b/modules/imgcodecs/src/grfmt_gif.cpp @@ -47,8 +47,8 @@ bool GifDecoder::readHeader() { return false; } - String signature(6, ' '); - m_strm.getBytes((uchar*)signature.data(), 6); + std::string signature(6, ' '); + m_strm.getBytes((uchar*)signature.c_str(), 6); CV_Assert(signature == R"(GIF87a)" || signature == R"(GIF89a)"); // #1: read logical screen descriptor @@ -428,6 +428,7 @@ void GifDecoder::close() { bool GifDecoder::getFrameCount_() { m_frame_count = 0; + m_animation.loop_count = 1; auto type = (uchar)m_strm.getByte(); while (type != 0x3B) { if (!(type ^ 0x21)) { @@ -436,11 +437,18 @@ bool GifDecoder::getFrameCount_() { // Application Extension need to be handled for the loop count if (extension == 0xFF) { int len = m_strm.getByte(); + bool isFoundNetscape = false; while (len) { - // TODO: In strictly, Application Identifier and Authentication Code should be checked. - if (len == 3) { - if (m_strm.getByte() == 0x01) { - m_animation.loop_count = m_strm.getWord(); + if (len == 11) { + std::string app_auth_code(len, ' '); + m_strm.getBytes(const_cast(static_cast(app_auth_code.c_str())), len); + isFoundNetscape = (app_auth_code == R"(NETSCAPE2.0)"); + } else if (len == 3) { + if (isFoundNetscape && (m_strm.getByte() == 0x01)) { + int loop_count = m_strm.getWord(); + // If loop_count == 0, it means loop forever. + // Otherwise, the loop is displayed extra one time than it is written in the data. + m_animation.loop_count = (loop_count == 0) ? 0 : loop_count + 1; } else { // this branch should not be reached in normal cases m_strm.skip(2); @@ -505,8 +513,8 @@ bool GifDecoder::getFrameCount_() { } bool GifDecoder::skipHeader() { - String signature(6, ' '); - m_strm.getBytes((uchar *) signature.data(), 6); + std::string signature(6, ' '); + m_strm.getBytes((uchar *) signature.c_str(), 6); // skip height and width m_strm.skip(4); char flags = (char) m_strm.getByte(); @@ -538,9 +546,7 @@ GifEncoder::GifEncoder() { // default value of the params fast = true; - loopCount = 0; // infinite loops by default criticalTransparency = 1; // critical transparency, default 1, range from 0 to 255, 0 means no transparency - frameDelay = 5; // 20fps by default, 10ms per unit bitDepth = 8; // the number of bits per pixel, default 8, currently it is a constant number lzwMinCodeSize = 8; // the minimum code size, default 8, this changes as the color number changes colorNum = 256; // the number of colors in the color table, default 256 @@ -566,16 +572,14 @@ bool GifEncoder::writeanimation(const Animation& animation, const std::vector(); } -bool GifEncoder::writeFrame(const Mat &img) { +bool GifEncoder::writeFrame(const Mat &img, const int frameDelay10ms) { if (img.empty()) { return false; } + height = m_height, width = m_width; // graphic control extension @@ -681,7 +699,7 @@ bool GifEncoder::writeFrame(const Mat &img) { const int gcePackedFields = static_cast(GIF_DISPOSE_RESTORE_PREVIOUS << GIF_DISPOSE_METHOD_SHIFT) | static_cast(criticalTransparency ? GIF_TRANSPARENT_INDEX_GIVEN : GIF_TRANSPARENT_INDEX_NOT_GIVEN); strm.putByte(gcePackedFields); - strm.putWord(frameDelay); + strm.putWord(frameDelay10ms); strm.putByte(transparentColor); strm.putByte(0x00); // end of the extension @@ -796,7 +814,7 @@ bool GifEncoder::lzwEncode() { return true; } -bool GifEncoder::writeHeader(const std::vector& img_vec) { +bool GifEncoder::writeHeader(const std::vector& img_vec, const int loopCount) { strm.putBytes(fmtGifHeader, (int)strlen(fmtGifHeader)); if (img_vec[0].empty()) { @@ -821,16 +839,23 @@ bool GifEncoder::writeHeader(const std::vector& img_vec) { strm.putBytes(globalColorTable.data(), globalColorTableSize * 3); } + if ( loopCount != 1 ) // If no-loop, Netscape Application Block is unnecessary. + { + // loopCount 0 means loop forever. + // Otherwise, most browsers(Edge, Chrome, Firefox...) will loop with extra 1 time. + // GIF data should be written with loop count decreased by 1. + const int _loopCount = ( loopCount == 0 ) ? loopCount : loopCount - 1; - // add application extension to set the loop count - strm.putByte(0x21); // GIF extension code - strm.putByte(0xFF); // application extension table - strm.putByte(0x0B); // length of application block, in decimal is 11 - strm.putBytes(R"(NETSCAPE2.0)", 11); // application authentication code - strm.putByte(0x03); // length of application block, in decimal is 3 - strm.putByte(0x01); // identifier - strm.putWord(loopCount); - strm.putByte(0x00); // end of the extension + // add Netscape Application Block to set the loop count in application extension. + strm.putByte(0x21); // GIF extension code + strm.putByte(0xFF); // application extension table + strm.putByte(0x0B); // length of application block, in decimal is 11 + strm.putBytes(R"(NETSCAPE2.0)", 11); // application authentication code + strm.putByte(0x03); // length of application block, in decimal is 3 + strm.putByte(0x01); // identifier + strm.putWord(_loopCount); + strm.putByte(0x00); // end of the extension + } return true; } diff --git a/modules/imgcodecs/src/grfmt_gif.hpp b/modules/imgcodecs/src/grfmt_gif.hpp index 106cc52186..af1f794ca9 100644 --- a/modules/imgcodecs/src/grfmt_gif.hpp +++ b/modules/imgcodecs/src/grfmt_gif.hpp @@ -157,7 +157,6 @@ private: int globalColorTableSize; int localColorTableSize; - uchar opMode; uchar criticalTransparency; uchar transparentColor; Vec3b transparentRGB; @@ -173,8 +172,6 @@ private: std::vector localColorTable; // params - int loopCount; - int frameDelay; int colorNum; int bitDepth; int dithering; @@ -182,8 +179,8 @@ private: bool fast; bool writeFrames(const std::vector& img_vec, const std::vector& params); - bool writeHeader(const std::vector& img_vec); - bool writeFrame(const Mat& img); + bool writeHeader(const std::vector& img_vec, const int loopCount); + bool writeFrame(const Mat& img, const int frameDelay); bool pixel2code(const Mat& img); void getColorTable(const std::vector& img_vec, bool isGlobal); static int ditheringKernel(const Mat &img, Mat &img_, int depth, uchar transparency); diff --git a/modules/imgcodecs/test/test_gif.cpp b/modules/imgcodecs/test/test_gif.cpp index a7c5ce8264..1ceef24637 100644 --- a/modules/imgcodecs/test/test_gif.cpp +++ b/modules/imgcodecs/test/test_gif.cpp @@ -414,6 +414,110 @@ TEST(Imgcodecs_Gif, decode_disposal_method) } } +// See https://github.com/opencv/opencv/issues/26970 +typedef testing::TestWithParam Imgcodecs_Gif_loop_count; +TEST_P(Imgcodecs_Gif_loop_count, imwriteanimation) +{ + const string gif_filename = cv::tempfile(".gif"); + + int loopCount = GetParam(); + cv::Animation anim(loopCount); + + vector src; + for(int n = 1; n <= 5 ; n ++ ) + { + cv::Mat frame(64, 64, CV_8UC3, cv::Scalar::all(0)); + cv::putText(frame, cv::format("%d", n), cv::Point(0,64), cv::FONT_HERSHEY_PLAIN, 4.0, cv::Scalar::all(255)); + anim.frames.push_back(frame); + anim.durations.push_back(1000 /* ms */); + } + + bool ret = false; +#if 0 + // To output gif image for test. + EXPECT_NO_THROW(ret = imwriteanimation(cv::format("gif_loop-%d.gif", loopCount), anim)); + EXPECT_TRUE(ret); +#endif + EXPECT_NO_THROW(ret = imwriteanimation(gif_filename, anim)); + EXPECT_TRUE(ret); + + // Read raw GIF data. + std::ifstream ifs(gif_filename); + std::stringstream ss; + ss << ifs.rdbuf(); + string tmp = ss.str(); + std::vector buf(tmp.begin(), tmp.end()); + + std::vector netscape = {0x21, 0xFF, 0x0B, 'N','E','T','S','C','A','P','E','2','.','0'}; + auto pos = std::search(buf.begin(), buf.end(), netscape.begin(), netscape.end()); + if(loopCount == 1) { + EXPECT_EQ(pos, buf.end()) << "Netscape Application Block should not be included if Animation.loop_count == 1"; + } else { + EXPECT_NE(pos, buf.end()) << "Netscape Application Block should be included if Animation.loop_count != 1"; + } + + remove(gif_filename.c_str()); +} + +INSTANTIATE_TEST_CASE_P(/*nothing*/, + Imgcodecs_Gif_loop_count, + testing::Values( + -1, + 0, // Default, loop-forever + 1, + 2, + 65534, + 65535, // Maximum Limit + 65536 + ) +); + +typedef testing::TestWithParam Imgcodecs_Gif_duration; +TEST_P(Imgcodecs_Gif_duration, imwriteanimation) +{ + const string gif_filename = cv::tempfile(".gif"); + + cv::Animation anim; + + int duration = GetParam(); + vector src; + for(int n = 1; n <= 5 ; n ++ ) + { + cv::Mat frame(64, 64, CV_8UC3, cv::Scalar::all(0)); + cv::putText(frame, cv::format("%d", n), cv::Point(0,64), cv::FONT_HERSHEY_PLAIN, 4.0, cv::Scalar::all(255)); + anim.frames.push_back(frame); + anim.durations.push_back(duration /* ms */); + } + + bool ret = false; +#if 0 + // To output gif image for test. + EXPECT_NO_THROW(ret = imwriteanimation(cv::format("gif_duration-%d.gif", duration), anim)); + EXPECT_EQ(ret, ( (0 <= duration) && (duration <= 655350) ) ); +#endif + EXPECT_NO_THROW(ret = imwriteanimation(gif_filename, anim)); + EXPECT_EQ(ret, ( (0 <= duration) && (duration <= 655350) ) ); + + remove(gif_filename.c_str()); +} + +INSTANTIATE_TEST_CASE_P(/*nothing*/, + Imgcodecs_Gif_duration, + testing::Values( + -1, // Unsupported + 0, // Undefined Behaviour + 1, + 9, + 10, + 50, + 100, // 10 FPS + 1000, // 1 FPS + 655340, + 655350, // Maximum Limit + 655360 // Unsupported + ) +); + }//opencv_test }//namespace