Quality loss when quantizing an image that already has (fewer than) 256 colors #2862
-
Prerequisites
ImageSharp version3.1.6 Other ImageSharp packages and versionsn/a Environment (Operating system, version and so on)Windows 10 .NET Framework version.NET 8 DescriptionI've been saving 256-color images as 8-bit indexed pngs, gifs and bmps, using a This appears to be caused by the I also know that it's possible to create a custom
What I actually want to achieve is a bit more strict: I want to preserve the exact index data of the original image, even when a palette contains duplicate colors. I don't expect a standard quantizer to handle such a niche scenario, but it would be very useful if encoders would use the given quantizer for all frames, not just the first frame. Steps to Reproduce// Create a grayscale palette (or any other palette with colors that are very close to each other):
var palette = Enumerable.Range(0, 256).Select(i => new Rgba32((byte)i, (byte)i, (byte)i)).ToArray();
// Create an image with a smooth black-white gradient:
using var image = new Image<Rgba32>(254, 4);
for (int y = 0; y < image.Height; y++)
for (int x = 0; x < image.Width; x++)
image[x, y] = palette[x];
// Correct result, for comparison:
image.Save(@"C:\Documents\temp\test_images\gradient.png");
// Banded gradient, uses only 32 colors instead of the full palette:
image.Save(@"C:\Documents\temp\test_images\gradient_8bit.png", new PngEncoder {
ColorType = PngColorType.Palette,
BitDepth = PngBitDepth.Bit8,
Quantizer = new PaletteQuantizer(palette.Select(Color.FromPixel).ToArray())
});
// The same problem occurs when saving as an 8-bit bmp or gif file. Images |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 7 replies
-
Hi @pwitvoet I've converted this into a discussion because the library is working as designed. Thanks for providing such an excellent summary. It's actually something I've been thinking about recently. Just a few notes on your comments:
You shouldn't need to use this constructor. If you look at other quantizer implementations they defer the creation of the type to
Yes, that's not great at the moment but I don't think I can make that configurable. I have plans to improve the bit preservation in the pixel map by replacing the current cache with a hybrid cache that stores the first 65536 unique colors in the image using full fidelity and falls back to the current implementation if not found. That would fix your issue, but I need to benchmark this to make sure it's usable. Proposed implementationusing System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.Memory;
public unsafe struct HybridColorDistanceCacheRGBA : IDisposable
{
private const int IndexRBits = 5;
private const int IndexGBits = 5;
private const int IndexBBits = 5;
private const int IndexABits = 6;
private const int IndexRCount = 1 << IndexRBits; // 32 bins for red
private const int IndexGCount = 1 << IndexGBits; // 32 bins for green
private const int IndexBCount = 1 << IndexBBits; // 32 bins for blue
private const int IndexACount = 1 << IndexABits; // 64 bins for alpha
private const int TotalBins = IndexRCount * IndexGCount * IndexBCount * IndexACount; // 1,048,576 bins
private readonly IMemoryOwner<short> fallbackTable;
private readonly short* fallbackPointer;
private MemoryHandle fallbackHandle;
private readonly Dictionary<int, short> hashTable;
private readonly int maxHashSize; // Maximum size for the hash table
public HybridColorDistanceCacheRGBA(MemoryAllocator allocator, int maxHashSize = 65536)
{
this.fallbackTable = allocator.Allocate<short>(TotalBins);
this.fallbackTable.GetSpan().Fill(-1);
this.fallbackHandle = this.fallbackTable.Memory.Pin();
this.fallbackPointer = (short*)this.fallbackHandle.Pointer;
this.hashTable = new Dictionary<int, short>(maxHashSize);
this.maxHashSize = maxHashSize;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(Rgba32 color, short index)
{
int hash = GetHash(color);
if (this.hashTable.Count < this.maxHashSize)
{
this.hashTable.TryAdd(hash, index);
}
int coarseIndex = GetCoarseIndex(color);
this.fallbackPointer[coarseIndex] = index;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetValue(Rgba32 color, out short match)
{
if (this.hashTable.TryGetValue(GetHash(color), out match))
{
return true; // Exact match found
}
match = this.fallbackPointer[GetCoarseIndex(color)];
return match > -1; // Coarse match found
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetHash(Rgba32 color)
{
/*
* Improved hash function for better distribution:
* The constants used here (73856093, 19349663, 83492791, 113429371) are prime numbers.
* Multiplying the color channels by these primes helps in achieving a uniform distribution
* of hash values by reducing collisions when colors differ slightly.
* This approach ensures that similar colors produce different hash values, improving hash table performance.
*/
return (color.R * 73856093) ^ (color.G * 19349663) ^ (color.B * 83492791) ^ (color.A * 113429371);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetCoarseIndex(Rgba32 color)
{
int rIndex = color.R >> (8 - IndexRBits);
int gIndex = color.G >> (8 - IndexGBits);
int bIndex = color.B >> (8 - IndexBBits);
int aIndex = color.A >> (8 - IndexABits);
return (aIndex * IndexRCount * IndexGCount * IndexBCount) +
(rIndex * IndexGCount * IndexBCount) +
(gIndex * IndexBCount) +
bIndex;
}
public void Clear()
{
this.hashTable.Clear();
this.fallbackTable.GetSpan().Fill(-1);
}
public void Dispose()
{
if (this.fallbackTable != null)
{
this.fallbackHandle.Dispose();
this.fallbackTable.Dispose();
}
}
} |
Beta Was this translation helpful? Give feedback.
Man... Oh man... Have I spent some time on this. It turns out implementing such a thing but keeping it fast is hard!
Here's what I've come up with. It 1.4x - 2x slower than the current approach depending on the number of collisions but will give you your accuracy for the first 256 colors.
Proposed implementation