Replies: 1 comment 1 reply
-
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.
-
Prerequisites
DEBUG
andRELEASE
modeImageSharp version
3.1.6
Other ImageSharp packages and versions
n/a
Environment (Operating system, version and so on)
Windows 10
.NET Framework version
.NET 8
Description
I've been saving 256-color images as 8-bit indexed pngs, gifs and bmps, using a
PaletteQuantizer
. In theory, they should not be negatively affected by quantization, because the images already contain 256 or fewer colors. In practice, I'm occasionally seeing quality loss, with output images not making use of the full palette.This appears to be caused by the
EuclideanPixelMap
(specifically itsColorDistanceCache
) thatPaletteQuantizer
is using. The color distance cache is lopping off the lowest 3 bits of the RGB values, which is quite troublesome for gradients. From the discussion in #1350 I understand that this is a memory usage optimization, but in my case a simple dictionary would work just fine, because all colors are already present in the palette anyway. If a trade-off has to be made, perhaps it should be configurable?I also know that it's possible to create a custom
IQuantizer
implementation, but there are two problems with that:IndexedImageFrame<TPixel>
doesn't have a public constructor. It's possible to create an instance using reflection, but that's brittle.PngEncoderCore
andGifEncoderCore
only use the given quantizer for the first frame. For subsequent frames, they use aPaletteQuantizer
, which re-introduces the quality loss problem.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
Images
Expected output:
Actual output (zoom in to see the banding):
Beta Was this translation helpful? Give feedback.
All reactions