Skip to content

V4 : Fix GIF, PNG, and WEBP Edge Case Handling - #2894

Merged
JimBobSquarePants merged 21 commits into
mainfrom
js/fix-2866-v4
May 14, 2025
Merged

V4 : Fix GIF, PNG, and WEBP Edge Case Handling#2894
JimBobSquarePants merged 21 commits into
mainfrom
js/fix-2866-v4

Conversation

@JimBobSquarePants

@JimBobSquarePantsJimBobSquarePants commented Feb 26, 2025

Copy link
Copy Markdown
Member

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

#2882 but for V4.

Fixes#2866
Fixes #2862

Changes shared with V3:

  • 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.

V4 Specific changes

  • Rewrite the OctreeQuantizer to support 32bit colors and added memory pooling to massively reduce memory.
  • Added Alpha thresholding to quantizers.
  • Added transparent color handling to quantizers.
  • Replace Coarse caching with a dedicated type that uses 1/8th of the memory.
  • Removes any decoded color tables should processing occur to allow generation of new tables on encode.

/// and lookups while consuming roughly 240 KB for the node pool.
/// </summary>
private sealed class Octree
internal sealed class Octree : IDisposable

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

@rickbrew@saucecontrol This is the new "Octree" I've been chatting to you about.

/// typically very short; in the worst-case, the number of iterations is bounded by 256.
/// This guarantees highly efficient and predictable performance for small, fixed-size color palettes.
/// </remarks>
internal sealed unsafe class ExactCache : IDisposable

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

@rickbrew@saucecontrol Here's the new Exact and Coarse caches I built. They work really well!

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comments suppressed due to low confidence (2)

src/ImageSharp/Formats/Png/PngEncoderCore.cs:1482

  • Ensure that 'backgroundColor' is reliably assigned before any use, as the [MemberNotNull] attribute enforces non-nullability and a missing assignment could lead to runtime violations.
[MemberNotNull(nameof(backgroundColor))]

src/ImageSharp/Formats/Png/PngEncoderCore.cs:1557

  • Verify that 'paletteQuantizer' is defined as a nullable value type; if it is or becomes a reference type, using 'HasValue' may introduce nullability issues in future changes.
if (paletteQuantizer.HasValue)

@rickbrew

rickbrew commented Mar 2, 2025

Copy link
Copy Markdown

As per our discussions on Discord recently, it's probably worth figuring out some additional special treatment for transparent pixels in the Octree Hexadecatree, and in the distance metric. Maybe this would be a follow-up PR at some point. I'm adding this here so there's an easy-ish place to find this info, not because I think this PR needs to incorporate this.

The gist of what I've figured out so far is that when A=0, the distance to any non-A=0 pixel should be the alpha value of that other pixel and should disregard the color channels. So Distance(Rgba(0,0,0,0), Rgba(0,0,0,255)) is 255, but Distance(Rgba(32,64,128,0), Rgba(128,64,32,255))is also 255. A transparent color is equal to any other transparent color (and this is exactly how it works in premultiplied alpha).

This is important when building the octree so that you don't allocate more than 1 palette slot for fully transparent.

This is also very important during the error diffusion process. If the source color was, say, Rgba(16,16,16,16) but the closest color in the palette was Rgba(0,0,0,0) then the only actual error is that the alpha channel isn't 0. The values of the color channels don't even matter at that point.

We haven't quite figured out the right formula for when working with alpha values other than 0 or 255. My quantization implementation only handles a single A=0 slot in the palette so I haven't had a chance to explore this properly yet. @saucecontrol has a good theory, that the error should be deltaC*(1-abs(deltaA)) but I'm not yet sure it's complete because it doesn't give me good results in some cases (and of course it's possible that the problem is in my code, not necessarily in that formula).

@antonfirsov

antonfirsov commented Mar 27, 2025

Copy link
Copy Markdown
Member

I ran some #2882 vs #2894 benchmarks. This PR regresses EncodeGif_CoarsePaletteEncoder for leo.gif by a factor of 3x. For some reason it doesn't happen with cheers.gif or the default encoder. I didn't debug why, but IMO this is worth to investigate.

System

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476)
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 20 logical and 10 physical cores

EncodeGif_CoarsePaletteEncoder

On #2882 (V3)

MethodTestImageMeanErrorStdDev
'ImageSharp Gif'Gif/cheers.gif561,493.93 us11,153.117 us17,031.987 us
'ImageSharp Gif'Gif/leo.gif37,311.68 us451.528 us422.360 us

On this PR (V4)

MethodTestImageMeanErrorStdDev
'ImageSharp Gif'Gif/cheers.gif579,361.13 us8,316.918 us7,779.651 us
'ImageSharp Gif'Gif/leo.gif116,420.41 us1,395.623 us1,305.467 us

EncodeGif_DefaultEncoder

On #2882 (V3)

MethodTestImageMeanErrorStdDev
'ImageSharp Gif'Gif/cheers.gif258,390.1 us5,132.65 us5,040.94 us
'ImageSharp Gif'Gif/leo.gif21,148.4 us384.27 us340.64 us

On this PR (V4)

MethodTestImageMeanErrorStdDev
'ImageSharp Gif'Gif/cheers.gif233,308.61 us4,548.075 us4,031.750 us
'ImageSharp Gif'Gif/leo.gif20,780.87 us411.151 us403.806 us

Comment on lines +183 to +185
int bucketIndex = GetBucketIndex(color.R, color.G, color.B);
byte quantAlpha = QuantizeAlpha(color.A);
return this.buckets[bucketIndex].TryGetValue(quantAlpha, out paletteIndex);

@antonfirsovantonfirsovMar 27, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would expect this code to be significantly slower than the simple indirect lookup in V3. It's a mistery to me why does the regression only show up in 1 of the 4 benchmark cases. Maybe it's not a hot path in the other ones?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Yeah I’m experimenting with a new coarse cache that uses a couple of hundred KB rather than 4MB.

I’m not sure why there’s such a dramatic speed loss though for that test. Will need to profile.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would personally prefer to attempt to optimize for speed here even if it costs more memory. See #2882 (comment).

@JimBobSquarePants

Copy link
Copy Markdown
MemberAuthor

@antonfirsov I've been thinking a lot recently about how we attempt to preserve the original palette when encodings image and I'm not sure whether doing so it worth the complexity in the encoder code. The GIF encoder for example is far, far more complicated than I'd like due to this behavior.

I wonder if we ignore the palette from the metadata and keep if for information purpose only. If someone wants to use it, they can pass it to the encoder via the PaletteQuantizer. We can rely on our default encoders to do most of the work.

With the new OctreeQuantizer and fixes to the WuQuantizer sampling strategy were guarantee very good results.

@antonfirsov

Copy link
Copy Markdown
Member

If someone wants to use it, they can pass it to the encoder via the PaletteQuantizer.

Do we have any guess how often do users prefer this over the encoder recreating the palette? Would it be possible to create a low ceremony API for this in our encoder infra?

@antonfirsov

Copy link
Copy Markdown
Member

Maybe a heretic idea, but would it make sense to make this PR an equivalent for the V3 one and do further quantization improvements in a separate one? This would (1) quickly close on the issue of decoder bugfixes (2) simplify reviews and reduce the chance of making mistakes.

@JimBobSquarePants

JimBobSquarePants commented Mar 28, 2025

Copy link
Copy Markdown
MemberAuthor

Do we have any guess how often do users prefer this over the encoder recreating the palette? Would it be possible to create a low ceremony API for this in our encoder infra?

Honestly, I don't think people even notice. I just want to make things easier to maintain while giving them the power to do specific actions.

Maybe a heretic idea, but would it make sense to make this PR an equivalent for the V3 one and do further quantization improvements in a separate one? This would (1) quickly close on the issue of decoder bugfixes (2) simplify reviews and reduce the chance of making mistakes.

In that case. I think we can review this now. I have further optimizations planned but they can wait.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull Request Overview

This pull request addresses a number of edge-case issues related to GIF, PNG, and WEBP encoding/decoding in V4 by updating color table handling, quantization logic and enhancing transparent pixel processing. Key changes include:

  • Exposing interface members explicitly via public modifiers in several interfaces.
  • Adjustments in GIF metadata and frame handling, including a dedicated fallback quantizer and revised transparent pixel behavior.
  • Updates to auxiliary utilities and AOT compiler tools to support the new quantizer and pixel map functionality.

Reviewed Changes

Copilot reviewed 237 out of 239 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
src/ImageSharp/Formats/IQuantizingImageEncoder.csAdded explicit public modifiers on interface members.
src/ImageSharp/Formats/IFormatMetadata.cs, IFormatFrameMetadata.cs, IAnimatedImageEncoder.csMade similar changes to expose interface members explicitly.
src/ImageSharp/Formats/Gif/*.csRevised GIF metadata and frame processing logic; removed redundant color table copying.
src/ImageSharp/Formats/GifEncoderCore.csIntroduced a fallback quantizer and updated frame quantization options; added a TODO regarding metadata checks.
src/ImageSharp/Formats/GifDecoderCore.csUpdated decoding to better handle background color index and frame disposal behaviors.
src/ImageSharp/Formats/* (Cur, Bmp)Removed the deprecated color table assignments and cleared metadata after image/frame processing.
src/ImageSharp/Formats/EncodingUtilities.csRenamed and modified functions to replace transparent pixels rather than clearing them.
src/ImageSharp/Advanced/AotCompilerTools.csAdded the AOT pre-seeding for pixel maps.
src/ImageSharp/Common/InlineArray.csAdded new inline fixed sized array types.
Files not reviewed (2)
  • Directory.Build.props: Language not supported
  • src/ImageSharp/Common/InlineArray.tt: Language not supported
Comments suppressed due to low confidence (3)

src/ImageSharp/Formats/IQuantizingImageEncoder.cs:16

  • [nitpick] Explicitly marking interface members as public is redundant since all interface members are public by default. Consider removing the explicit modifier to keep the code concise and consistent with standard interface definitions.
public IQuantizer? Quantizer { get; }

src/ImageSharp/Formats/Gif/GifEncoderCore.cs:198

  • [nitpick] There is a lingering TODO comment regarding checking metadata. It would be helpful to either address this issue or provide a more specific comment about the expected behavior to avoid confusion in production code.
// TODO: We should be checking the metadata here also I think?

src/ImageSharp/Formats/Gif/GifDecoderCore.cs:874

  • [nitpick] The background color index is assigned both to a local field and then later set in the GIF metadata. Consider consolidating these assignments into a single source of truth to avoid potential discrepancies between the global field and the metadata.
byte index = this.logicalScreenDescriptor.BackgroundColorIndex;

@JimBobSquarePants

Copy link
Copy Markdown
MemberAuthor

@antonfirsov I added Magick.NET to the GIF Encode benchmarks to guage performance there as SkiaSharp does not support GIF.

MethodTestImageMeanErrorStdDevRatioRatioSD
'System.Drawing Gif'Gif/cheers.gif394.81 us7.746 us8.920 us1.000.03
'ImageSharp Gif'Gif/cheers.gif271,412.34 us5,207.020 us5,347.226 us687.7920.05
'Magick.NET Gif'Gif/cheers.gif132,836.36 us545.717 us483.764 us336.627.50
'System.Drawing Gif'Gif/leo.gif63.29 us1.237 us1.926 us1.000.04
'ImageSharp Gif'Gif/leo.gif24,851.62 us97.855 us81.713 us393.0011.84
'Magick.NET Gif'Gif/leo.gif16,251.59 us108.177 us101.189 us257.007.85

It's important to note that neither library performs the same deduplication operations we do so accurate comparison is hard as we cannot isolate quantization.

@antonfirsov

antonfirsov commented Apr 3, 2025

Copy link
Copy Markdown
Member

In that case. I think we can review this now. I have further optimizations planned but they can wait.

What I meant is to draw the line at the exact equivalent of the V3 changes and split out any improvements on top on that to a separate PR. The current state regresses one of the benchmark cases significantly for a reason we don't understand. My primary "review feedback" against this state is that we should go figure out why is there a regression and attempt to reduce it. This may take long, so IMO it would be good to get the codec fixes merged first. Then the next PR would be only scoped on V4 quantization improvements.

@JimBobSquarePants

Copy link
Copy Markdown
MemberAuthor

In that case. I think we can review this now. I have further optimizations planned but they can wait.

What I meant is to draw the line at the exact equivalent of the V3 changes and split out any improvements on top on that to a separate PR. The current state regresses one of the benchmark cases significantly for a reason we don't understand. My primary "review feedback" against this state is that we should go figure out why is there a regression and attempt to reduce it. This may take long, so IMO it would be good to get the codec fixes merged first. Then the next PR would be only scoped on V4 quantization improvements.

Here's the interesting thing. I'm not seeing the same slowdown for the single test that you saw. I've ran the benchmarks several times now and I'm seeing a consistant slowdown with the new CoarseCache across all images when used in conjunction with the web safe palette quantizer.

MethodTestImageMeanErrorStdDevRatioRatioSD
'System.Drawing Gif'Gif/cheers.gif402.87 us8.043 us9.878 us1.000.03
'ImageSharp Gif'Gif/cheers.gif619,254.41 us5,247.679 us4,651.931 us1,537.9838.30
'Magick.NET Gif'Gif/cheers.gif131,894.89 us1,033.196 us915.902 us327.578.11
'System.Drawing Gif'Gif/leo.gif57.95 us1.150 us1.536 us1.000.04
'ImageSharp Gif'Gif/leo.gif131,399.18 us1,557.266 us1,300.388 us2,268.9963.14
'Magick.NET Gif'Gif/leo.gif16,085.41 us48.683 us40.653 us277.767.29
'System.Drawing Gif'Gif/trans.gif1,034.94 us6.914 us5.774 us1.000.01
'ImageSharp Gif'Gif/trans.gif4,188.09 us30.656 us27.175 us4.050.03
'Magick.NET Gif'Gif/trans.gif665.61 us4.025 us3.568 us0.640.00

Switching back to the old cache brings performance back in line.

MethodTestImageMeanErrorStdDevRatioRatioSD
'System.Drawing Gif'Gif/cheers.gif369.97 us7.106 us9.486 us1.000.04
'ImageSharp Gif'Gif/cheers.gif492,040.30 us7,378.671 us6,541.000 us1,330.7737.41
'Magick.NET Gif'Gif/cheers.gif132,217.81 us698.538 us653.413 us357.609.11
'System.Drawing Gif'Gif/leo.gif60.01 us1.190 us1.887 us1.000.04
'ImageSharp Gif'Gif/leo.gif34,840.45 us191.300 us178.942 us581.1417.85
'Magick.NET Gif'Gif/leo.gif16,220.66 us86.744 us76.896 us270.568.29
'System.Drawing Gif'Gif/trans.gif1,074.64 us19.856 us28.477 us1.000.04
'ImageSharp Gif'Gif/trans.gif4,304.03 us22.128 us19.616 us4.010.10
'Magick.NET Gif'Gif/trans.gif668.40 us2.163 us1.918 us0.620.02

When comparing using default options we can see that the performance is comparable.

Default New Cache

MethodTestImageMeanErrorStdDevRatioRatioSD
'System.Drawing Gif'Gif/cheers.gif410.11 us8.054 us13.677 us1.000.05
'ImageSharp Gif'Gif/cheers.gif267,157.15 us5,052.624 us4,726.228 us652.1123.54
'Magick.NET Gif'Gif/cheers.gif133,070.94 us1,241.900 us1,100.912 us324.8210.65
'System.Drawing Gif'Gif/leo.gif60.79 us1.211 us1.990 us1.000.05
'ImageSharp Gif'Gif/leo.gif26,881.59 us267.821 us223.643 us442.6814.48
'Magick.NET Gif'Gif/leo.gif16,154.25 us71.026 us62.962 us266.028.49
'System.Drawing Gif'Gif/trans.gif1,039.49 us5.305 us4.430 us1.000.01
'ImageSharp Gif'Gif/trans.gif6,088.86 us52.509 us46.548 us5.860.05
'Magick.NET Gif'Gif/trans.gif680.33 us13.171 us16.657 us0.650.02

Default Old Cache.

MethodTestImageMeanErrorStdDevRatioRatioSD
'System.Drawing Gif'Gif/cheers.gif388.95 us7.760 us8.937 us1.000.03
'ImageSharp Gif'Gif/cheers.gif261,284.38 us5,218.765 us5,359.287 us672.1020.03
'Magick.NET Gif'Gif/cheers.gif132,819.38 us1,167.753 us1,092.317 us341.658.05
'System.Drawing Gif'Gif/leo.gif61.03 us1.181 us1.160 us1.000.03
'ImageSharp Gif'Gif/leo.gif27,214.74 us469.391 us416.103 us446.0510.54
'Magick.NET Gif'Gif/leo.gif16,272.16 us133.222 us124.616 us266.705.30
'System.Drawing Gif'Gif/trans.gif1,042.26 us6.012 us5.624 us1.000.01
'ImageSharp Gif'Gif/trans.gif6,142.48 us73.934 us65.541 us5.890.07
'Magick.NET Gif'Gif/trans.gif672.63 us4.758 us4.218 us0.650.01

So why such a performance difference for the caches when using the websafe palette? Here's what I think.

When the palette is generated from the image, both caches perform similarly because nearly every lookup is a hit. However, with a generic palette like the 141 websafe colors, which doesn’t match the image’s color distribution, more cache misses occur and force a fallback to a full palette scan. In this case, the new CoarseCache's overhead from bucket scanning and branch checks starts to add up. The older 4 MB CoarseCache avoids this by using direct, branch-free indexing, resulting in roughly 3x faster performance despite its larger memory footprint.

The websafe palette test was designed to mimic System.Drawing’s output, but since that library doesn’t actually do any quantization work, it’s hard to draw meaningful comparisons. I’m also not sure what the inner workings of the ImageMagick encoder are, but I suspect it isn’t applying dithering either, since the input and output colors are always identical. That limits the usefulness of the test to mostly comparing our own quantizers.

What I can say about the benchmark, though, is that it’s testing a worst-case scenario for the new cache. It’s a configuration that would only be used in the real world as a novelty, since the output quality is so poor. The default setup, on the other hand, works really well.

@JimBobSquarePants

Copy link
Copy Markdown
MemberAuthor

@antonfirsov I've reverted to the original coarse cache for now as I really need to push on with this.

Everything but the new pooled quantizer is a copy of the V3 work.

@JimBobSquarePants
JimBobSquarePants merged commit 62da42d into mainMay 14, 2025
@JimBobSquarePants
JimBobSquarePants deleted the js/fix-2866-v4 branch May 14, 2025 11:47
Sign up for freeto 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.

Error block in image result after saving after loading some files

4 participants

@JimBobSquarePants@rickbrew@antonfirsov