Skip to content

Commit 8f2716b

Browse files
GIF: background handling & quantizer overflow fix (#3133)
* GIF: background handling & quantizer overflow fix * Fix tests
1 parent ff36e83 commit 8f2716b

12 files changed

Lines changed: 171 additions & 74 deletions

File tree

src/ImageSharp/Formats/Gif/GifDecoderCore.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,11 @@ private void ReadLogicalScreenDescriptorAndGlobalColorTable(BufferedReadStream s
990990

991991
byte index = this.logicalScreenDescriptor.BackgroundColorIndex;
992992
this.backgroundColorIndex = index;
993-
this.gifMetadata.BackgroundColorIndex = index;
993+
ReadOnlyMemory<Color>? globalColorTable = this.gifMetadata.GlobalColorTable;
994+
if (globalColorTable.HasValue && index < globalColorTable.Value.Length)
995+
{
996+
this.gifMetadata.BackgroundColor = globalColorTable.Value.Span[index];
997+
}
994998
}
995999

9961000
private unsafe struct ScratchBuffer

src/ImageSharp/Formats/Gif/GifEncoderCore.cs

Lines changed: 23 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,6 @@ internal sealed class GifEncoderCore
4545
/// </summary>
4646
private readonly IPixelSamplingStrategy pixelSamplingStrategy;
4747

48-
/// <summary>
49-
/// The default background color of the canvas when animating.
50-
/// This color may be used to fill the unused space on the canvas around the frames,
51-
/// as well as the transparent pixels of the first frame.
52-
/// The background color is also used when a frame disposal mode is <see cref="FrameDisposalMode.RestoreToBackground"/>.
53-
/// </summary>
54-
private readonly Color? backgroundColor;
55-
5648
/// <summary>
5749
/// The number of times any animation is repeated.
5850
/// </summary>
@@ -76,7 +68,6 @@ public GifEncoderCore(Configuration configuration, GifEncoder encoder)
7668
this.skipMetadata = encoder.SkipMetadata;
7769
this.colorTableMode = encoder.ColorTableMode;
7870
this.pixelSamplingStrategy = encoder.PixelSamplingStrategy;
79-
this.backgroundColor = encoder.BackgroundColor;
8071
this.repeatCount = encoder.RepeatCount;
8172
this.transparentColorMode = encoder.TransparentColorMode;
8273
}
@@ -113,7 +104,17 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
113104
TransparentColorMode mode = this.transparentColorMode;
114105

115106
// Create a new quantizer options instance augmenting the transparent color mode to match the encoder.
116-
QuantizerOptions options = (this.encoder.Quantizer?.Options ?? new QuantizerOptions()).DeepClone(o => o.TransparentColorMode = mode);
107+
QuantizerOptions options = (this.encoder.Quantizer?.Options ?? new QuantizerOptions()).DeepClone(o =>
108+
{
109+
o.TransparentColorMode = mode;
110+
111+
// Animated GIF delta frames can use one padded color-table index as transparency.
112+
// Express that through MaxColors so custom quantizers receive the same budget.
113+
if (image.Frames.Count > 1 && o.MaxColors == QuantizerConstants.MaxColors)
114+
{
115+
o.MaxColors = QuantizerConstants.MaxColors - 1;
116+
}
117+
});
117118

118119
if (globalQuantizer is null)
119120
{
@@ -145,6 +146,11 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
145146
IPixelSamplingStrategy strategy = this.pixelSamplingStrategy;
146147

147148
ImageFrame<TPixel> encodingFrame = image.Frames.RootFrame;
149+
150+
// This color is encoded as the logical-screen background index and is also
151+
// used when de-duplicating frames that restore to the GIF background.
152+
Color backgroundColor = this.encoder.BackgroundColor ?? gifMetadata.BackgroundColor ?? Color.Transparent;
153+
byte backgroundIndex = 0;
148154
if (useGlobalTableForFirstFrame)
149155
{
150156
using IQuantizer<TPixel> firstFrameQuantizer = globalQuantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, options);
@@ -158,6 +164,8 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
158164
}
159165

160166
quantized = firstFrameQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds);
167+
TPixel backgroundPixel = backgroundColor.ToPixel<TPixel>();
168+
backgroundIndex = firstFrameQuantizer.GetQuantizedColor(backgroundPixel, out _);
161169
}
162170
else
163171
{
@@ -184,8 +192,6 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
184192
frameMetadata.TransparencyIndex = ClampIndex(transparencyIndex);
185193
}
186194

187-
byte backgroundIndex = GetBackgroundIndex(quantized, gifMetadata, this.backgroundColor);
188-
189195
// Get the number of bits.
190196
int bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length);
191197
this.WriteLogicalScreenDescriptor(image.Metadata, image.Width, image.Height, backgroundIndex, useGlobalTable, bitDepth, stream);
@@ -222,6 +228,7 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
222228
image,
223229
globalQuantizer,
224230
globalFrameQuantizer,
231+
backgroundColor,
225232
transparencyIndex,
226233
frameMetadata.DisposalMode,
227234
cancellationToken);
@@ -253,6 +260,7 @@ private void EncodeAdditionalFrames<TPixel>(
253260
Image<TPixel> image,
254261
IQuantizer globalQuantizer,
255262
PaletteQuantizer<TPixel> globalFrameQuantizer,
263+
Color backgroundColor,
256264
int globalTransparencyIndex,
257265
FrameDisposalMode previousDisposalMode,
258266
CancellationToken cancellationToken)
@@ -284,6 +292,7 @@ private void EncodeAdditionalFrames<TPixel>(
284292
globalFrameQuantizer,
285293
useLocal,
286294
gifMetadata,
295+
backgroundColor,
287296
previousDisposalMode);
288297

289298
previousFrame = currentFrame;
@@ -327,6 +336,7 @@ private void EncodeAdditionalFrame<TPixel>(
327336
PaletteQuantizer<TPixel> globalFrameQuantizer,
328337
bool useLocal,
329338
GifFrameMetadata metadata,
339+
Color backgroundColor,
330340
FrameDisposalMode previousDisposalMode)
331341
where TPixel : unmanaged, IPixel<TPixel>
332342
{
@@ -347,7 +357,7 @@ private void EncodeAdditionalFrame<TPixel>(
347357
previous.Metadata.GetGifMetadata().DisposalMode;
348358

349359
Color background = !useTransparency && disposalMode == FrameDisposalMode.RestoreToBackground
350-
? this.backgroundColor ?? Color.Transparent
360+
? backgroundColor
351361
: Color.Transparent;
352362

353363
// Deduplicate and quantize the frame capturing only required parts.
@@ -534,44 +544,6 @@ private static int GetTransparentIndex<TPixel>(IndexedImageFrame<TPixel>? quanti
534544
return index;
535545
}
536546

537-
/// <summary>
538-
/// Returns the index of the background color in the palette.
539-
/// </summary>
540-
/// <param name="quantized">The current quantized frame.</param>
541-
/// <param name="metadata">The gif metadata</param>
542-
/// <param name="background">The background color to match.</param>
543-
/// <typeparam name="TPixel">The pixel format.</typeparam>
544-
/// <returns>The <see cref="byte"/> index of the background color.</returns>
545-
private static byte GetBackgroundIndex<TPixel>(IndexedImageFrame<TPixel>? quantized, GifMetadata metadata, Color? background)
546-
where TPixel : unmanaged, IPixel<TPixel>
547-
{
548-
int match = -1;
549-
if (quantized != null)
550-
{
551-
if (background.HasValue)
552-
{
553-
TPixel backgroundPixel = background.Value.ToPixel<TPixel>();
554-
ReadOnlySpan<TPixel> palette = quantized.Palette.Span;
555-
for (int i = 0; i < palette.Length; i++)
556-
{
557-
if (!backgroundPixel.Equals(palette[i]))
558-
{
559-
continue;
560-
}
561-
562-
match = i;
563-
break;
564-
}
565-
}
566-
else if (metadata.BackgroundColorIndex < quantized.Palette.Length)
567-
{
568-
match = metadata.BackgroundColorIndex;
569-
}
570-
}
571-
572-
return ClampIndex(match);
573-
}
574-
575547
/// <summary>
576548
/// Writes the file header signature and version to the stream.
577549
/// </summary>

src/ImageSharp/Formats/Gif/GifMetadata.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ private GifMetadata(GifMetadata other)
2626
{
2727
this.RepeatCount = other.RepeatCount;
2828
this.ColorTableMode = other.ColorTableMode;
29-
this.BackgroundColorIndex = other.BackgroundColorIndex;
29+
this.BackgroundColor = other.BackgroundColor;
3030

3131
if (other.GlobalColorTable?.Length > 0)
3232
{
@@ -59,10 +59,9 @@ private GifMetadata(GifMetadata other)
5959
public ReadOnlyMemory<Color>? GlobalColorTable { get; set; }
6060

6161
/// <summary>
62-
/// Gets or sets the index at the <see cref="GlobalColorTable"/> for the background color.
63-
/// The background color is the color used for those pixels on the screen that are not covered by an image.
62+
/// Gets or sets the background color used for pixels on the screen that are not covered by an image.
6463
/// </summary>
65-
public byte BackgroundColorIndex { get; set; }
64+
public Color? BackgroundColor { get; set; }
6665

6766
/// <summary>
6867
/// Gets or sets the collection of comments about the graphics, credits, descriptions or any
@@ -101,6 +100,7 @@ public FormatConnectingMetadata ToFormatConnectingMetadata()
101100
{
102101
AnimateRootFrame = true,
103102
ColorTableMode = this.ColorTableMode,
103+
BackgroundColor = this.BackgroundColor ?? Color.Transparent,
104104
PixelTypeInfo = this.GetPixelTypeInfo(),
105105
RepeatCount = this.RepeatCount,
106106
};

src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -392,11 +392,11 @@ internal void FreeNode(short index)
392392
internal struct Node
393393
{
394394
public bool Leaf;
395-
public int PixelCount;
396-
public int Red;
397-
public int Green;
398-
public int Blue;
399-
public int Alpha;
395+
public long PixelCount;
396+
public long Red;
397+
public long Green;
398+
public long Blue;
399+
public long Alpha;
400400
public short PaletteIndex;
401401
public short NextReducibleIndex;
402402
private InlineArray16<short> children;
@@ -510,12 +510,13 @@ public void Reduce(Hexadecatree tree)
510510
return;
511511
}
512512

513-
// Now merge the (presumably reduced) children.
514-
int pixelCount = 0;
515-
int sumRed = 0;
516-
int sumGreen = 0;
517-
int sumBlue = 0;
518-
int sumAlpha = 0;
513+
// Allocation fallback can accumulate samples on an interior node. Seed the merge
514+
// with this node's own sums so reduction preserves those samples with its children.
515+
long pixelCount = this.PixelCount;
516+
long sumRed = this.Red;
517+
long sumGreen = this.Green;
518+
long sumBlue = this.Blue;
519+
long sumAlpha = this.Alpha;
519520
Span<short> children = this.Children;
520521

521522
for (int i = 0; i < children.Length; i++)
@@ -524,7 +525,7 @@ public void Reduce(Hexadecatree tree)
524525
if (childIndex != -1)
525526
{
526527
ref Node child = ref tree.Nodes[childIndex];
527-
int pixels = child.PixelCount;
528+
long pixels = child.PixelCount;
528529
sumRed += child.Red;
529530
sumGreen += child.Green;
530531
sumBlue += child.Blue;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using SixLabors.ImageSharp.Formats;
5+
using SixLabors.ImageSharp.Formats.Gif;
6+
using SixLabors.ImageSharp.PixelFormats;
7+
using SixLabors.ImageSharp.Processing;
8+
9+
namespace SixLabors.ImageSharp.Tests.Issues;
10+
11+
// https://github.com/SixLabors/ImageSharp.Drawing/discussions/396
12+
public class Issue_396
13+
{
14+
[Theory]
15+
[WithFile(TestImages.Png.Issue396Dragon, PixelTypes.Rgba32)]
16+
public void GeneratesGifForInspection<TPixel>(TestImageProvider<TPixel> provider)
17+
where TPixel : unmanaged, IPixel<TPixel>
18+
{
19+
Image<Rgba32>[] handImages =
20+
[
21+
Image.Load<Rgba32>(TestFile.Create(TestImages.Png.Issue396Hand1).Bytes),
22+
Image.Load<Rgba32>(TestFile.Create(TestImages.Png.Issue396Hand2).Bytes)
23+
];
24+
25+
try
26+
{
27+
using Image<TPixel> inputImage = provider.GetImage();
28+
using Image<Rgba32> outputImage = new(handImages[0].Width, handImages[0].Height);
29+
30+
const float scaleFactor = 0.6F;
31+
const float squashStepFactor = 0.22F;
32+
33+
for (int i = 0; i < handImages.Length; i++)
34+
{
35+
using Image<Rgba32> frameImage = new(handImages[i].Width, handImages[i].Height);
36+
float squashFactorY = 1 - (i * squashStepFactor);
37+
38+
frameImage.ProcessPixelRows(accessor =>
39+
{
40+
Rgba32 gray = Color.Gray.ToPixel<Rgba32>();
41+
42+
for (int y = 0; y < accessor.Height; y++)
43+
{
44+
accessor.GetRowSpan(y).Fill(gray);
45+
}
46+
});
47+
48+
Rectangle targetRect = new(
49+
(int)((frameImage.Width - (frameImage.Width * scaleFactor)) / 2),
50+
(int)(frameImage.Height * (((1 - scaleFactor) / 2) + (scaleFactor * (1 - squashFactorY)))),
51+
(int)(frameImage.Width * scaleFactor),
52+
(int)(frameImage.Height * scaleFactor * squashFactorY));
53+
54+
using Image<Rgba32> dragonFrame = inputImage.CloneAs<Rgba32>();
55+
dragonFrame.Mutate(context => context.Resize(targetRect.Size));
56+
57+
frameImage.Mutate(context =>
58+
{
59+
context.DrawImage(dragonFrame, targetRect.Location, 1F);
60+
context.DrawImage(handImages[i], Point.Empty, 1F);
61+
});
62+
63+
ImageFrame<Rgba32> outputFrame = outputImage.Frames.AddFrame(frameImage.Frames.RootFrame);
64+
GifFrameMetadata gifFrameMetadata = outputFrame.Metadata.GetGifMetadata();
65+
gifFrameMetadata.FrameDelay = 40;
66+
gifFrameMetadata.DisposalMode = FrameDisposalMode.RestoreToBackground;
67+
}
68+
69+
for (int i = handImages.Length - 1; i > 0; i--)
70+
{
71+
outputImage.Frames.AddFrame(outputImage.Frames[i]);
72+
}
73+
74+
outputImage.Frames.RemoveFrame(0);
75+
GifMetadata gifMetadata = outputImage.Metadata.GetGifMetadata();
76+
gifMetadata.RepeatCount = 0;
77+
78+
Assert.Equal((handImages.Length * 2) - 1, outputImage.Frames.Count);
79+
foreach (ImageFrame<Rgba32> frame in outputImage.Frames)
80+
{
81+
Assert.Equal(FrameDisposalMode.RestoreToBackground, frame.Metadata.GetGifMetadata().DisposalMode);
82+
}
83+
84+
outputImage.DebugSaveMultiFrame(provider, "Issue396-source-frames", appendPixelTypeToFileName: false);
85+
provider.Utility.SaveTestOutputFile(
86+
outputImage,
87+
"gif",
88+
new GifEncoder(),
89+
"Issue396-encoded",
90+
appendPixelTypeToFileName: false,
91+
appendSourceFileOrDescription: false);
92+
93+
// Save and decode the GIF so the encoder's optimized frame diffs can be inspected separately.
94+
using MemoryStream gifStream = new();
95+
outputImage.SaveAsGif(gifStream);
96+
gifStream.Position = 0;
97+
98+
using Image<Rgba32> decodedImage = Image.Load<Rgba32>(gifStream);
99+
decodedImage.DebugSaveMultiFrame(provider, "Issue396-decoded-frames", appendPixelTypeToFileName: false);
100+
101+
Assert.Equal(outputImage.Frames.Count, decodedImage.Frames.Count);
102+
}
103+
finally
104+
{
105+
foreach (Image<Rgba32> handImage in handImages)
106+
{
107+
handImage.Dispose();
108+
}
109+
}
110+
}
111+
}

tests/ImageSharp.Tests/TestImages.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ public static class Png
7979
public const string AnimatedFrameCount = "Png/animated/issue-animated-frame-count.png";
8080
public const string Issue2666 = "Png/issues/Issue_2666.png";
8181
public const string Issue2882 = "Png/issues/Issue_2882.png";
82+
public const string Issue396Dragon = "Png/issues/Issue_396_dragon.png";
83+
public const string Issue396Hand1 = "Png/issues/Issue_396_hand_1.png";
84+
public const string Issue396Hand2 = "Png/issues/Issue_396_hand_2.png";
8285

8386
// Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html
8487
public const string Filter0 = "Png/filter0.png";
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:a98b1ec707af066f77fad7d1a64b858d460986beb6d27682717dd5e221310fd4
2+
oid sha256:fff91e78a87c2b2db661609dd70af2d7a74e6a0de18924ab2b6272e12c60977d
33
size 9270

tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 2 additions & 2 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)