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 13 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;

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