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

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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

JimBobSquarePants
Copy link
Member

@JimBobSquarePants JimBobSquarePants commented Feb 26, 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

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

/// </summary>
private sealed class Octree
internal sealed class Octree : IDisposable
Copy link
Member Author

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
Member Author

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!

Copy link

@Copilot Copilot AI left a comment

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
Copy link

rickbrew commented Mar 2, 2025

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
Copy link
Member

antonfirsov commented Mar 27, 2025

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)

Method TestImage Mean Error StdDev
'ImageSharp Gif' Gif/cheers.gif 561,493.93 us 11,153.117 us 17,031.987 us
'ImageSharp Gif' Gif/leo.gif 37,311.68 us 451.528 us 422.360 us

On this PR (V4)

Method TestImage Mean Error StdDev
'ImageSharp Gif' Gif/cheers.gif 579,361.13 us 8,316.918 us 7,779.651 us
'ImageSharp Gif' Gif/leo.gif 116,420.41 us 1,395.623 us 1,305.467 us

EncodeGif_DefaultEncoder

On #2882 (V3)

Method TestImage Mean Error StdDev
'ImageSharp Gif' Gif/cheers.gif 258,390.1 us 5,132.65 us 5,040.94 us
'ImageSharp Gif' Gif/leo.gif 21,148.4 us 384.27 us 340.64 us

On this PR (V4)

Method TestImage Mean Error StdDev
'ImageSharp Gif' Gif/cheers.gif 233,308.61 us 4,548.075 us 4,031.750 us
'ImageSharp Gif' Gif/leo.gif 20,780.87 us 411.151 us 403.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);
Copy link
Member

@antonfirsov antonfirsov Mar 27, 2025

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
Member Author

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
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
Member Author

@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
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
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
Copy link
Member Author

JimBobSquarePants commented Mar 28, 2025

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.

Copy link

@Copilot Copilot AI left a comment

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
File Description
src/ImageSharp/Formats/IQuantizingImageEncoder.cs Added explicit public modifiers on interface members.
src/ImageSharp/Formats/IFormatMetadata.cs, IFormatFrameMetadata.cs, IAnimatedImageEncoder.cs Made similar changes to expose interface members explicitly.
src/ImageSharp/Formats/Gif/*.cs Revised GIF metadata and frame processing logic; removed redundant color table copying.
src/ImageSharp/Formats/GifEncoderCore.cs Introduced a fallback quantizer and updated frame quantization options; added a TODO regarding metadata checks.
src/ImageSharp/Formats/GifDecoderCore.cs Updated 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.cs Renamed and modified functions to replace transparent pixels rather than clearing them.
src/ImageSharp/Advanced/AotCompilerTools.cs Added the AOT pre-seeding for pixel maps.
src/ImageSharp/Common/InlineArray.cs Added 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
Member Author

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

Method TestImage Mean Error StdDev Ratio RatioSD
'System.Drawing Gif' Gif/cheers.gif 394.81 us 7.746 us 8.920 us 1.00 0.03
'ImageSharp Gif' Gif/cheers.gif 271,412.34 us 5,207.020 us 5,347.226 us 687.79 20.05
'Magick.NET Gif' Gif/cheers.gif 132,836.36 us 545.717 us 483.764 us 336.62 7.50
'System.Drawing Gif' Gif/leo.gif 63.29 us 1.237 us 1.926 us 1.00 0.04
'ImageSharp Gif' Gif/leo.gif 24,851.62 us 97.855 us 81.713 us 393.00 11.84
'Magick.NET Gif' Gif/leo.gif 16,251.59 us 108.177 us 101.189 us 257.00 7.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
Copy link
Member

antonfirsov commented Apr 3, 2025

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
Member Author

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.

Method TestImage Mean Error StdDev Ratio RatioSD
'System.Drawing Gif' Gif/cheers.gif 402.87 us 8.043 us 9.878 us 1.00 0.03
'ImageSharp Gif' Gif/cheers.gif 619,254.41 us 5,247.679 us 4,651.931 us 1,537.98 38.30
'Magick.NET Gif' Gif/cheers.gif 131,894.89 us 1,033.196 us 915.902 us 327.57 8.11
'System.Drawing Gif' Gif/leo.gif 57.95 us 1.150 us 1.536 us 1.00 0.04
'ImageSharp Gif' Gif/leo.gif 131,399.18 us 1,557.266 us 1,300.388 us 2,268.99 63.14
'Magick.NET Gif' Gif/leo.gif 16,085.41 us 48.683 us 40.653 us 277.76 7.29
'System.Drawing Gif' Gif/trans.gif 1,034.94 us 6.914 us 5.774 us 1.00 0.01
'ImageSharp Gif' Gif/trans.gif 4,188.09 us 30.656 us 27.175 us 4.05 0.03
'Magick.NET Gif' Gif/trans.gif 665.61 us 4.025 us 3.568 us 0.64 0.00

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

Method TestImage Mean Error StdDev Ratio RatioSD
'System.Drawing Gif' Gif/cheers.gif 369.97 us 7.106 us 9.486 us 1.00 0.04
'ImageSharp Gif' Gif/cheers.gif 492,040.30 us 7,378.671 us 6,541.000 us 1,330.77 37.41
'Magick.NET Gif' Gif/cheers.gif 132,217.81 us 698.538 us 653.413 us 357.60 9.11
'System.Drawing Gif' Gif/leo.gif 60.01 us 1.190 us 1.887 us 1.00 0.04
'ImageSharp Gif' Gif/leo.gif 34,840.45 us 191.300 us 178.942 us 581.14 17.85
'Magick.NET Gif' Gif/leo.gif 16,220.66 us 86.744 us 76.896 us 270.56 8.29
'System.Drawing Gif' Gif/trans.gif 1,074.64 us 19.856 us 28.477 us 1.00 0.04
'ImageSharp Gif' Gif/trans.gif 4,304.03 us 22.128 us 19.616 us 4.01 0.10
'Magick.NET Gif' Gif/trans.gif 668.40 us 2.163 us 1.918 us 0.62 0.02

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

Default New Cache

Method TestImage Mean Error StdDev Ratio RatioSD
'System.Drawing Gif' Gif/cheers.gif 410.11 us 8.054 us 13.677 us 1.00 0.05
'ImageSharp Gif' Gif/cheers.gif 267,157.15 us 5,052.624 us 4,726.228 us 652.11 23.54
'Magick.NET Gif' Gif/cheers.gif 133,070.94 us 1,241.900 us 1,100.912 us 324.82 10.65
'System.Drawing Gif' Gif/leo.gif 60.79 us 1.211 us 1.990 us 1.00 0.05
'ImageSharp Gif' Gif/leo.gif 26,881.59 us 267.821 us 223.643 us 442.68 14.48
'Magick.NET Gif' Gif/leo.gif 16,154.25 us 71.026 us 62.962 us 266.02 8.49
'System.Drawing Gif' Gif/trans.gif 1,039.49 us 5.305 us 4.430 us 1.00 0.01
'ImageSharp Gif' Gif/trans.gif 6,088.86 us 52.509 us 46.548 us 5.86 0.05
'Magick.NET Gif' Gif/trans.gif 680.33 us 13.171 us 16.657 us 0.65 0.02

Default Old Cache.

Method TestImage Mean Error StdDev Ratio RatioSD
'System.Drawing Gif' Gif/cheers.gif 388.95 us 7.760 us 8.937 us 1.00 0.03
'ImageSharp Gif' Gif/cheers.gif 261,284.38 us 5,218.765 us 5,359.287 us 672.10 20.03
'Magick.NET Gif' Gif/cheers.gif 132,819.38 us 1,167.753 us 1,092.317 us 341.65 8.05
'System.Drawing Gif' Gif/leo.gif 61.03 us 1.181 us 1.160 us 1.00 0.03
'ImageSharp Gif' Gif/leo.gif 27,214.74 us 469.391 us 416.103 us 446.05 10.54
'Magick.NET Gif' Gif/leo.gif 16,272.16 us 133.222 us 124.616 us 266.70 5.30
'System.Drawing Gif' Gif/trans.gif 1,042.26 us 6.012 us 5.624 us 1.00 0.01
'ImageSharp Gif' Gif/trans.gif 6,142.48 us 73.934 us 65.541 us 5.89 0.07
'Magick.NET Gif' Gif/trans.gif 672.63 us 4.758 us 4.218 us 0.65 0.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.

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.

Error block in image result after saving after loading some files
3 participants