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
This commit is contained in:
Kumataro 2025-03-19 20:24:08 +09:00 committed by GitHub
parent 8207549638
commit 3e43d0cfca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 172 additions and 36 deletions

View File

@ -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<int> durations;
//! Vector of frames, where each Mat represents a single frame.
CV_PROP_RW std::vector<Mat> frames;

View File

@ -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<void*>(static_cast<const void*>(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<in
return false;
}
loopCount = animation.loop_count;
// confirm the params
for (size_t i = 0; i < params.size(); i += 2) {
switch (params[i]) {
case IMWRITE_GIF_LOOP:
loopCount = std::min(std::max(params[i + 1], 0), 65535); // loop count is in 2 bytes
CV_LOG_WARNING(NULL, "IMWRITE_GIF_LOOP is not functional since 4.12.0. Replaced by cv::Animation::loop_count.");
break;
case IMWRITE_GIF_SPEED:
frameDelay = 100 - std::min(std::max(params[i + 1] - 1, 0), 99); // from 10ms to 1000ms
CV_LOG_WARNING(NULL, "IMWRITE_GIF_SPEED is not functional since 4.12.0. Replaced by cv::Animation::durations.");
break;
case IMWRITE_GIF_DITHER:
dithering = std::min(std::max(params[i + 1], -1), 3);
@ -648,15 +652,28 @@ bool GifEncoder::writeanimation(const Animation& animation, const std::vector<in
} else {
img_vec_ = animation.frames;
}
bool result = writeHeader(img_vec_);
bool result = writeHeader(img_vec_, animation.loop_count);
if (!result) {
strm.close();
return false;
}
for (size_t i = 0; i < img_vec_.size(); i++) {
frameDelay = cvRound(animation.durations[i] / 10);
result = writeFrame(img_vec_[i]);
// Animation duration is in 1ms unit.
const int frameDelay = animation.durations[i];
CV_CheckGE(frameDelay, 0, "It must be positive value");
// GIF file stores duration in 10ms unit.
const int frameDelay10ms = cvRound(frameDelay / 10);
CV_LOG_IF_WARNING(NULL, (frameDelay10ms == 0),
cv::format("frameDelay(%d) is rounded to 0ms, its behaviour is user application depended.", frameDelay));
CV_CheckLE(frameDelay10ms, 65535, "It requires to be stored in WORD");
result = writeFrame(img_vec_[i], frameDelay10ms);
if (!result) {
strm.close();
return false;
}
}
strm.putByte(0x3B); // trailer
@ -668,10 +685,11 @@ ImageEncoder GifEncoder::newEncoder() const {
return makePtr<GifEncoder>();
}
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<int>(GIF_DISPOSE_RESTORE_PREVIOUS << GIF_DISPOSE_METHOD_SHIFT) |
static_cast<int>(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<Mat>& img_vec) {
bool GifEncoder::writeHeader(const std::vector<Mat>& 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<Mat>& 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
// 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.putWord(_loopCount);
strm.putByte(0x00); // end of the extension
}
return true;
}

View File

@ -157,7 +157,6 @@ private:
int globalColorTableSize;
int localColorTableSize;
uchar opMode;
uchar criticalTransparency;
uchar transparentColor;
Vec3b transparentRGB;
@ -173,8 +172,6 @@ private:
std::vector<uchar> localColorTable;
// params
int loopCount;
int frameDelay;
int colorNum;
int bitDepth;
int dithering;
@ -182,8 +179,8 @@ private:
bool fast;
bool writeFrames(const std::vector<Mat>& img_vec, const std::vector<int>& params);
bool writeHeader(const std::vector<Mat>& img_vec);
bool writeFrame(const Mat& img);
bool writeHeader(const std::vector<Mat>& img_vec, const int loopCount);
bool writeFrame(const Mat& img, const int frameDelay);
bool pixel2code(const Mat& img);
void getColorTable(const std::vector<Mat>& img_vec, bool isGlobal);
static int ditheringKernel(const Mat &img, Mat &img_, int depth, uchar transparency);

View File

@ -414,6 +414,110 @@ TEST(Imgcodecs_Gif, decode_disposal_method)
}
}
// See https://github.com/opencv/opencv/issues/26970
typedef testing::TestWithParam<int> 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<cv::Mat> 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<uint8_t> buf(tmp.begin(), tmp.end());
std::vector<uint8_t> 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<int> Imgcodecs_Gif_duration;
TEST_P(Imgcodecs_Gif_duration, imwriteanimation)
{
const string gif_filename = cv::tempfile(".gif");
cv::Animation anim;
int duration = GetParam();
vector<cv::Mat> 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