Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V3 : Fix GIF, PNG, and WEBP Edge Case Handling #2882

Open
wants to merge 24 commits into
base: release/3.1.x
Choose a base branch
from

Conversation

JimBobSquarePants
Copy link
Member

@JimBobSquarePants JimBobSquarePants commented Feb 7, 2025

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

Fixes #2866
Fixes #2862

There's quite a lot going on here:

  • Both GIF and WEBP decoders were not handling frame disposal properly.
  • GIF Decoder background color handling was incorrect.
  • GIF Encoder incorrectly used global palette for local root frame.
  • WEBP Decoder did not clear some buffers on load where it should.
  • PNG Encoder palette animations did not work properly
  • Fixed pixel sampling in Wu and Octree quantizers
  • Improved accuracy for matching first 512 colors in EuclidianPixelMap.

I'll have to manually rework this for V4 as the code base has migrated significantly there.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 5 out of 16 changed files in this pull request and generated no comments.

Files not reviewed (11)
  • tests/Images/External/ReferenceOutput/GifDecoderTests/Issue1962_Rgba32_issue1962_tiniest_gif_1st.png: Language not supported
  • tests/Images/External/ReferenceOutput/GifDecoderTests/Issue2012BadMinCode_Rgba32_issue2012_drona1.png: Language not supported
  • tests/Images/Input/Gif/issues/issue_2866.gif: Language not supported
  • tests/Images/Input/Webp/issues/Issue2866.webp: Language not supported
  • src/ImageSharp/Formats/Gif/GifEncoderCore.cs: Evaluated as low risk
  • tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs: Evaluated as low risk
  • src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs: Evaluated as low risk
  • src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs: Evaluated as low risk
  • tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs: Evaluated as low risk
  • tests/ImageSharp.Tests/TestImages.cs: Evaluated as low risk
  • src/ImageSharp/Formats/Webp/WebpBlendMethod.cs: Evaluated as low risk
Comments suppressed due to low confidence (2)

tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs:389

  • [nitpick] The test method name GifEncoder_CanDecode_Issue2866 is misleading since it is testing encoding, not decoding. It should be renamed to GifEncoder_CanEncode_Issue2866.
public void GifEncoder_CanDecode_Issue2866<TPixel>(TestImageProvider<TPixel> provider)

src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs:89

  • The comment should reflect the correct type for backgroundColor, which is TPixel instead of Color.
/// <param name="backgroundColor">The default background color of the canvas in.</param>
@JimBobSquarePants JimBobSquarePants changed the title V3 : Fix GIF and WEBP Edge Case Handling V3 : Fix GIF, PNG, and WEBP Edge Case Handling Feb 25, 2025
@antonfirsov
Copy link
Member

This is quite a huge change, but I'm planning to take a high-level look in the coming days.

@JimBobSquarePants
Copy link
Member Author

This is quite a huge change, but I'm planning to take a high-level look in the coming days.

Thanks! I have a V4 version also that I’m going to push, lots of similarities but also some additional changes.

Copy link
Member

@antonfirsov antonfirsov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the codec logic this is touching, might be good if @brianpopow can also take a look.

Some general concerns:

  • Would be nice to have as high functional test coverage as possible for the functionality gap the PR is closing (with validation). The PR description lists both encoder and decoder issues, but only encoder tests are being added. If I get it right there are cases not covered by Error block in image result after saving after loading some files #2866 and
    Quality loss when quantizing an image that already has (fewer than) 256 colors #2862, can we get those tested as well?
  • [Edited] I'm skeptical about the Color Distance Cache changes. I assume the goal is an improvement in quitizer quality, but I think it comes with significant performance cost, so we should do a pro-contra analysis on it. Are there some main vs PR example pictures that demonstrate the improvement? Is it possible to get main vs PR benchmarks to see the performance impact?


[Theory]
[WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)]
public void GifEncoder_CanDecode_Issue2866<TPixel>(TestImageProvider<TPixel> provider)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a decoder issue/test actually? Can we do validation here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually both. There were issues in the encoder and decoder.

where TPixel : unmanaged, IPixel<TPixel>
{
if (TestEnvironment.RunsOnCI && !TestEnvironment.IsWindows)
if (TestEnvironment.RunsOnCI)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of skipping entirely, can we instead run this with RemoteExecutor and/or make them non-parallel?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a massive file with lots of frames ones decoded. I just wanted to ease the pressure.

Copy link
Member

@antonfirsov antonfirsov Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this validates a corner-case secanario, IMO it's better to pay the price to have the CI-coverage. We have some tests disabled on 32bit or on Windows when RunsOnCI is true, but AFAIK, we don't have any tests that would be entirely opted out on all CI pipelines; it would set a bad precedent.

@@ -32,7 +32,7 @@ protected BitReaderBase(Stream inputStream, int imageDataSize, MemoryAllocator m
/// <param name="memoryAllocator">Used for allocating memory during reading data from the stream.</param>
protected static IMemoryOwner<byte> ReadImageDataFromStream(Stream input, int bytesToRead, MemoryAllocator memoryAllocator)
{
IMemoryOwner<byte> data = memoryAllocator.Allocate<byte>(bytesToRead);
IMemoryOwner<byte> data = memoryAllocator.Allocate<byte>(bytesToRead, AllocationOptions.Clean);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to clean the buffer given that we assume we will read bytesToRead bytes anyways? And coming to the assumption: instead of ignoring the input.Read() result, shouldn't we fail the read and abort processing the stream if it's <bytesToRead ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just don't think it's a good idea to do any uncleaned buffer reading in the decoders now we've had CVEs due to it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It always depends on the particular buffer usage. This is a a raw chunk buffer that is to be transformed as part of the decoding process, and the raw contents won't show up in image buffers. In any case, ignoring the read result seems to be more worrying and implementing proper handling of the read result would render Clean unnecessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've rereviewed and in this case there are lots of places expecting the data span to be the requested length. It's much easier for me to clean the array than refactor.

Comment on lines 70 to 73
this.CacheY = memoryAllocator.Allocate<byte>((16 * this.CacheYStride) + extraY, AllocationOptions.Clean);
int cacheUvSize = (16 * this.CacheUvStride) + extraUv;
this.CacheU = memoryAllocator.Allocate<byte>(cacheUvSize);
this.CacheV = memoryAllocator.Allocate<byte>(cacheUvSize);
this.TmpYBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpUBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpVBuffer = memoryAllocator.Allocate<byte>((int)width);
this.Pixels = memoryAllocator.Allocate<byte>((int)(width * height * 4));
this.CacheU = memoryAllocator.Allocate<byte>(cacheUvSize, AllocationOptions.Clean);
this.CacheV = memoryAllocator.Allocate<byte>(cacheUvSize, AllocationOptions.Clean);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of celaning these, shouldn't we consider removing the #if DEBUG below, and let them being filled with 205?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can get away without cleaning everything but the Pixels buffer

/// <param name="key">The key to add.</param>
/// <param name="value">The value to add.</param>
/// <returns><see langword="true"/> if the key was added; otherwise, <see langword="false"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does such a complex method have to be aggressively inlined? We can trust the JIT much more these days about inlining decisions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

}

[MethodImpl(InliningOptions.ShortMethod)]
public readonly void Add(Rgba32 rgba, byte index)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I haven't profiled ImageSharp for a long time, I still think InliningOptions is a helpful tool to support profiling work. If we want to challange that assumption I would prefer to do it in a separate discussion/PR rather than with arbitrary changes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted

[MethodImpl(InliningOptions.ShortMethod)]
public readonly void Add(Rgba32 rgba, byte index)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly void Add(Rgba32 color, short index)
Copy link
Member

@antonfirsov antonfirsov Mar 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All callsites look like this.cache.Add(rgba, (byte)index). I know the fallback table was a short buffer even before the change, but now I'm confused why is it needed. Naturally I would guess it's for faster memory access, or am I wrong? If it's for perf, did we ever benchmark it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why I changed this. Will review.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted

/// <param name="value">The value associated with the key, if found.</param>
/// <returns><see langword="true"/> if the key is found; otherwise, <see langword="false"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetValue(uint key, out short value)
Copy link
Member

@antonfirsov antonfirsov Mar 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect this code to be significantly slower than the GetPalettIeIndex+memory lookup on the fallback path. A main vs PR benchmark would show the performance impact.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not bad actually since we're using the custom buckets (A standard dictionary would be slower). It's a bit slower (will vary image to image depending on color distribution) than the coarse cache on its own but we need that accuracy.

Copy link
Member

@antonfirsov antonfirsov Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit slower

I expect the perf regression to be significant. If I'm understanding the cache logic correctly, the code will be hit by default per pixel.

I still think we should at least be aware of the exact performance impact and make sure we are making a conscious decision. I can implement/run benchmarks later this week if that helps.

we need that accuracy

Are we sure all users prefer accuracy over performance by default? What about users who want to opt into faster perf rather than quality?

Copy link
Member Author

@JimBobSquarePants JimBobSquarePants Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antonfirsov I haven't pushed the code here yet as I'm still not sure what the default mode should be, and it requires updating reference images but....

I have defined a new enum on QuantizerOptions that allows switching between coarse, hybrid, and exact color distance matching. Here's the benchmarks for the three approaches.

Hybrid isn't that much slower than the coarse cache on its own.

.NET SDK=9.0.200
  [Host]     : .NET 7.0.20 (7.0.2024.26716), X64 RyuJIT
  Job-WTTMLE : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT

Runtime=.NET 6.0  Arguments=/p:DebugType=portable  IterationCount=3
LaunchCount=1  WarmupCount=3

Coarse

Method TestImage Mean Error StdDev Ratio RatioSD Gen 0 Gen 1 Gen 2 Allocated
'System.Drawing Gif' Bmp/Car.bmp 8.837 ms 2.073 ms 0.1136 ms 1.00 0.00 15.6250 15.6250 15.6250 198 KB
'ImageSharp Gif' Bmp/Car.bmp 10.363 ms 1.161 ms 0.0636 ms 1.17 0.01 93.7500 93.7500 93.7500 224 KB
'System.Drawing Gif' Png/rgb-48bpp.png 35.747 ms 3.901 ms 0.2138 ms 1.00 0.00 71.4286 71.4286 71.4286 393 KB
'ImageSharp Gif' Png/rgb-48bpp.png 43.825 ms 37.513 ms 2.0562 ms 1.23 0.05 83.3333 83.3333 83.3333 287 KB

Hybrid

Method TestImage Mean Error StdDev Ratio RatioSD Gen 0 Gen 1 Gen 2 Allocated
'System.Drawing Gif' Bmp/Car.bmp 8.638 ms 1.524 ms 0.0835 ms 1.00 0.00 15.6250 15.6250 15.6250 198 KB
'ImageSharp Gif' Bmp/Car.bmp 14.259 ms 5.146 ms 0.2821 ms 1.65 0.02 46.8750 46.8750 46.8750 207 KB
'System.Drawing Gif' Png/rgb-48bpp.png 35.540 ms 4.489 ms 0.2461 ms 1.00 0.00 71.4286 71.4286 71.4286 393 KB
'ImageSharp Gif' Png/rgb-48bpp.png 50.919 ms 7.914 ms 0.4338 ms 1.43 0.02 100.0000 100.0000 100.0000 306 KB

Exact (No Cache)

Method TestImage Mean Error StdDev Ratio RatioSD Gen 0 Gen 1 Gen 2 Allocated
'System.Drawing Gif' Bmp/Car.bmp 9.025 ms 6.737 ms 0.3693 ms 1.00 0.00 15.6250 15.6250 15.6250 198 KB
'ImageSharp Gif' Bmp/Car.bmp 132.910 ms 50.492 ms 2.7677 ms 14.74 0.47 250.0000 250.0000 250.0000 335 KB
'System.Drawing Gif' Png/rgb-48bpp.png 35.667 ms 1.864 ms 0.1022 ms 1.00 0.00 66.6667 66.6667 66.6667 393 KB
'ImageSharp Gif' Png/rgb-48bpp.png 360.896 ms 138.605 ms 7.5974 ms 10.12 0.19 - - - 1,258 KB

Copy link
Member Author

@JimBobSquarePants JimBobSquarePants Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antonfirsov I've pushed my changes so you can see what I've done. It's still defaulting to hybrid at the moment so the updated tests pass.

Doing it in a way that provided optimial codegen by avoiding branching required using previewd features (static abstract interface) but the usage is tightly constrined internally and is non-viral.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds promising, I want to take a look in the next couple of days and also benchmark it against some heavier gifs.

…elMap{TPixel}.cs

Co-authored-by: Anton Firszov <antonfir@gmail.com>
@brianpopow
Copy link
Collaborator

@JimBobSquarePants I think it would be good to add a few more test case to verify everything works as expected. Here is a github repository with some gif images generated with imagemagick: gif-test-suite, which I think could be useful testcases.

I can help with adding tests, I have some time in the next few days.

@JimBobSquarePants
Copy link
Member Author

That would be awesome if you could thanks!

@brianpopow
Copy link
Collaborator

brianpopow commented Mar 3, 2025

With some of test images I have added with f80aa76, the output of ImageSharp does not match what imagemagick would produce, which may indicate an error.
For example, the image:
animated_transparent_frame_restoreprev_loop
animated_transparent_frame_restoreprev_loop.gif

The output from ImageSharp looks like this:

output_0

output_1

output_2

output_3

I am not sure yet why. I will try to figure out what's wrong tomorrow.
Also notice: It could be not related to changes introduced with this PR.

To extract the images with imagemagick, the following command can be used:

 magick convert -coalesce animated_transparent_frame_restoreprev_loop.gif[0-4] out.png

@JimBobSquarePants
Copy link
Member Author

@brianpopow That looks like something to do with palette reading though the last frame suggests an additional bug.

@brianpopow
Copy link
Collaborator

@brianpopow That looks like something to do with palette reading though the last frame suggests an additional bug.

@JimBobSquarePants It seems that the issue is a regression from version 2.1.9 to 3.0.0. It is an issue with handling the disposal method RestoreToPrevious. When I change it back to how it was in 2.1.9, the images look again like expected in the ActualOutput folder.

The weird thing is that when I save the frames like this:

var img = Image.Load<Rgba32>(imagePath);
for (int i = 0; i < img.Frames.Count; i++)
{
    using Image frameImage = img.Frames.CloneFrame(i);

    frameImage.Save($"output_{i}.png");
}

The images still look black and white. I am not sure what I do wrong here with saving the frames.

@JimBobSquarePants
Copy link
Member Author

JimBobSquarePants commented Mar 6, 2025

@brianpopow That looks like something to do with palette reading though the last frame suggests an additional bug.

@JimBobSquarePants It seems that the issue is a regression from version 2.1.9 to 3.0.0. It is an issue with handling the disposal method RestoreToPrevious. When I change it back to how it was in 2.1.9, the images look again like expected in the ActualOutput folder.

The weird thing is that when I save the frames like this:

var img = Image.Load<Rgba32>(imagePath);
for (int i = 0; i < img.Frames.Count; i++)
{
    using Image frameImage = img.Frames.CloneFrame(i);

    frameImage.Save($"output_{i}.png");
}

The images still look black and white. I am not sure what I do wrong here with saving the frames.

I can fix restore to previous behavior easily enough but I'm seeing some odd behavior in ImageMagick for this image and others. It doesn't seem to follow the gif89a specification at all regarding GCE transparency and is setting entries at a given index with an alpha of zero regardless of whether there is a transparency flag. @dlemstra Any idea why this is?

@JimBobSquarePants
Copy link
Member Author

@brianpopow I figured out why the output is wrong when cloning. The current code attempts to encode the image using the palette data and bit depth extracted from the individual frames however the new frame contains all the data from the combined frame not just te delta. We shouldn't attempt to preserve the palette when converting between formats. I'll push a fix for this today to this branch.

@brianpopow
Copy link
Collaborator

@brianpopow I figured out why the output is wrong when cloning. The current code attempts to encode the image using the palette data and bit depth extracted from the individual frames however the new frame contains all the data from the combined frame not just te delta. We shouldn't attempt to preserve the palette when converting between formats. I'll push a fix for this today to this branch.

@JimBobSquarePants: I am glad you found the issue, I had trouble making sense out of this behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants