Skip to content

Quantization: reduce memory footprint without compromising speed and quality #1350

Description

@antonfirsov

All our current quantizers rely on a very heavy cache in order to produce acceptable quality:

private readonly ConcurrentDictionary<TPixel, int> distanceCache;

With large images this could consume up to several megabytes of memory every time a larger GIF (or palettized PNG) gets saved. We need to reduce memory usage without compromising speed and quality.

We made a couple of experiments and exchanged many ideas a couple of months ago, but unfortunately all those discussions got lost in the noise of the gitter chatroom. I suggest to recollect and rediscuss those ideas.

Improvements we may consider (according to what I remember from the gitter chat):

  1. Improving the RGB Octree implementation to make it very fast by default so we hopefully don't need any cache. Most important: flattening the tree nodes into array(s) of structs (instead of heap objects).
  2. Extend the octree to RGBA
  3. Replace the dictionary with this cache. It produced promising results in my experiments.
  4. New idea from @JimBobSquarePants: replace the dictionary with an LRU cache implementation like the one in BitFaster.Caching.

@saucecontrol since I remember you had really valuable ideas here, I would really appreciate your feedback on all 4 options, and apologies if I'm making you to repeat yourself because of my terrible memory 😄 If I remember correctly, you also had some promising results in your repos.

Bravely assigning milestone 1.1 for now.

Activity

  1. added this to the milestone on Sep 14, 2020
  2. changed the title [-]Quantization: reduce memory usage without compromising speed and quality[/-] [+]Quantization: reduce memory footprint without compromising speed and quality[/+] on Sep 14, 2020
  3. saucecontrol commented on Sep 15, 2020

    @saucecontrol
    Contributor

    apologies if I'm making you to repeat yourself because of my terrible memory

    I'm afraid I don't recall much detail of those conversations myself 😄

    I did your option 1 for mine, using an octree for the histogram, palette generation, and palette lookup. Unfortunately, due to C#'s lack of first class support for discriminated unions and the JIT's insistence on inserting null checks when dereferencing a member from a struct ptr, the code ended up being quite ugly for perf reasons. But it gives really nice visual results with only about 280KiB of rented pool memory, and in my limited testing was on average an order of magnitude faster than the current ImageSharp octree implementation.

    You can find my implementation here: https://github.com/saucecontrol/PhotoSauce/blob/master/src/MagicScaler/Magic/ColorQuantizer.cs

    Mine differs quite a bit from any other octree implementation I've seen, which makes it a little difficult to follow, but there's method to any madness you see in there, and I'm happy to answer any questions. Unfortunately I haven't the drawing skills to do good diagrams of how it works, so the code will have to speak for itself for now.

    The major architectural differences:

    1. I made it GC allocation free by making the octree node a struct and renting a (256KiB) slab of memory from the ArrayPool to hold all the nodes I'll ever have. Once I fill that slab, I reduce the node set by cutting the depth of the tree by one level. Because the tree depth is variable, it has perfect fidelity for low-color images, and it gets faster per-color the more colors it encounters. And when reducing the palette from the possible 8K nodes in the histogram, I weight them based on population count and distance to the parent node color to prioritize the most important colors. This gives better results than simply merging the first available reducible node and allows the palette to have the max possible entries rather than coming up short when all children of a reducible parent are merged.
    2. I use a set of LUTs to quickly build the entire octree index for any given input color by interleaving the channel bits, so navigating the tree can be done by simply shifting off 3 bits of that pre-calculated index at a time (I reverse the bit order so the next level index is the low 3 bits). In histogram mode, the tree keeps color values and pixel counts at the node level. In lookup mode, it keeps the palette index and value. I build the octree index in GRB order so that the index roughly approximates luma. When looking for the nearest color within the tree, the nearest populated index at a given level is also going to be the nearest color, provided you search them in the right order. If a node doesn't exist at the minimum leaf level I've defined, I do a full search through the palette and cache the result by creating the missing node.

    The LinearBoxesMap idea is clever, but its weakness is of course that it has a pre-defined accuracy limit. If you're trying to fit a 300-color image into a 256-color palette, you should be able to keep perfect matches for the majority of the colors. An octree is great for that because it's a sparse structure, so as long as you make the lookups/navigation fast enough, it does a great job.

    The same ideas could be applied to support RGBA in a 'hexadecitree' with 4 bits per node index instead of 3. The alpha channel could be weighted less or quantized in advance. I was only going for GIF support initially, so I haven't yet done any experiments to figure out the best number of nodes, etc.

  4. antonfirsov commented on Sep 15, 2020

    @antonfirsov
    MemberAuthor

    @saucecontrol thanks for the very detailed reply! What is the pixel format of Span<byte> image ? (Didn't have a chance do dig into your code yet.)

    Our big problem is that currently we need the following cache to produce good enough quality when images are transparent:

    // Octree only maps the RGB component of a color
    // so cannot tell the difference between a fully transparent
    // pixel and a black one.
    if (this.isDithering || color.Equals(default))
    {
    return (byte)this.pixelMap.GetClosestColor(color, out match);
    }

    @JimBobSquarePants in case of pixels having no alpha channel, we likely don't need this path. For users caring about perf we can suggest then to work with Image<Rgb24> all the way.
    In the case when alpha channel is present: what if we premultiply with alpha before/while saving the image, instead of using the Euclidean map?

  5. JimBobSquarePants commented on Sep 15, 2020

    @JimBobSquarePants
    Member

    @antonfirsov I found that the difference in quality was noticeable with or without transparency when I experimented with removing the color map entirely.

    Looking back at EuclideanPixelMap<TPixel> though I don't know why we use Vector4. Both Octree and Wu convert to Rgba32 when building histograms so we don't need the extra precision. Perhaps we can do something faster there?

  6. saucecontrol commented on Sep 15, 2020

    @saucecontrol
    Contributor

    What is the pixel format of Span image ?

    My implementation works only on BGR24 input. In the final palette derivation step, when merging nodes, I convert the colors to 32bpc linear for better blending/averaging results.

    I also have only a single dithering algorithm (a modified Floyd-Steinberg) integrated directly into the palette matching for perf reasons. I didn't really investigate which part of the speed differences between mine and the current ImageSharp implementation come from the histogram vs lookup vs dithering parts of the code, but if your main concern is reducing memory, I do believe you can eliminate the cache without negative perf impact as long as your octree traversal is fast. And of course a faster octree speeds up your histogram step as well so it may be a win-win.

    The key to keeping memory usage under control while keeping accuracy is to allow the lookup octree to reach full 8 bit depth for the nodes that contain your palette entries but then restrict the depth for parts of the gamut not covered by the palette. My implementation caps that at 4 bits (a max of 4096 nodes) so I can fit everything in my 256KiB slab. You give up some accuracy on those, but since they represent colors not in the original image anyway (they're used for colors created by dithering error propagation), it's not a big deal.

    Again, my use case was animated GIF, so it was very important to me to get it completely optimized for processing hundreds of RGB frames at a go. I don't know if you'd want to generalize your implementation more to allow the same code to handle RGB and RGBA, but if it were me, I'd have separate implementations for those. The RGBA path would naturally require more memory, but my guess would be that around 2MiB would do it if you do some psychovisual trickery with the alpha channel ;)

  7. antonfirsov commented on Sep 16, 2020

    @antonfirsov
    MemberAuthor

    Looking back at EuclideanPixelMap though I don't know why we use Vector4. Both Octree and Wu convert to Rgba32 when building histograms so we don't need the extra precision. Perhaps we can do something faster there?

    private readonly Vector4[] vectorCache;
    private readonly ConcurrentDictionary<TPixel, int> distanceCache;

    @JimBobSquarePants Vector4 is not the problem here since paletteCache has only 256 elements. It's the dictonary that grows out of control. I agree with @saucecontrol that it's the fast Octree itself that should be play the role of the cache and ideally we should eliminate the dictionary, or any similar heavy lookup. Based on #1350 (comment), I think MagicScaler's Octree should provide better results in terms of quality than ours. For me this sounds like something worth a POC as a next step. @JimBobSquarePants you still skeptical? Do you see any issues I don't? (Especially re dithering.)

    Note: converting pixel spans to Bgr24 should be cheap after implementing #1354

  8. JimBobSquarePants commented on Sep 16, 2020

    @JimBobSquarePants
    Member

    @antonfirsov I only mention the Vector4 usage because converting to/from RGB compatible types is much faster.

    My biggest concern here is that we are focusing too much on Octree. All of the quantizers use the pixel map to improve output quality (due to naïve color distance formula) and two of the quantizers depend on it entirely for palette application.

    public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
    => (byte)this.pixelMap.GetClosestColor(color, out match);

    So... Perhaps we can do something to allow fast linear color space conversion using LUTs since we're working with pixel formats with byte accuracy in the quantizers @saucecontrol I know you do this but I can never read your code (I'm convinced you've been sent here from the future) which could improve the accuracy of the built in palette matching mechanisms. Maybe if the quality is improved we can drop the pixel map entirely.

    Then we can figure out what to do for the other quantizers that depend on it.

    The pixel map was always a crutch. That's why I buried it far away from the API surface.

  9. saucecontrol commented on Sep 16, 2020

    @saucecontrol
    Contributor

    @JimBobSquarePants I think I might be confused about how your implementation works now. I thought the purpose of the pixel map was to improve speed by eliminating the need for a full search through the palette to find the nearest palette color for each input pixel color. You're saying it has something to do with improving quality? How does that work?

    You can actually use an octree for palette lookup regardless of how the palette was originally obtained, so the idea isn't necessarily that you go all octree all the time. If you needed to map an image to e.g. a standard web palette, you would build out an octree with the values from the palette to start. As you map the input image to the palette, any time you navigate the octree and the node for your pixel color doesn't exist, you do the linear search through the palette and then cache the winner by creating that node so that next time you see that color or one like it, it's already there. Essentially, the octree works as a sparse cache in that mode. The real win is in the fact that it can group similar colors instead of having a key per distinct input value, because you pick the max tree depth for those cache nodes. It's actually very similar to @antonfirsov's LinearBoxesMap concept except that the accuracy is variable (from 4 to 8 bits in my implementation) instead of being fixed at e.g. 5 bits, and any unused part of the color space never gets allocated any memory space.

  10. JimBobSquarePants commented on Sep 16, 2020

    @JimBobSquarePants
    Member

    @saucecontrol Using the pixel map actually results in much slower quantization than not using one.

    The quality improvement is achieved by finding the closest color in the resolved palette to the given pixel via Euclidian distance. I originally started using that approach because a bug in my dithering caused exceptions when trying the match the dithered result against the palette. I stick with it now that issue is gone because I still see quite dramatic differences in quality when compared against both Wu and Octree quantizers.

    The dictionary caches results since the distance calculation is horribly slow.

    private int GetClosestColorSlow(TPixel color, ref TPixel paletteRef, out TPixel match)
    {
    // Loop through the palette and find the nearest match.
    int index = 0;
    float leastDistance = float.MaxValue;
    var vector = color.ToVector4();
    ref Vector4 vectorCacheRef = ref MemoryMarshal.GetReference<Vector4>(this.vectorCache);
    for (int i = 0; i < this.Palette.Length; i++)
    {
    Vector4 candidate = Unsafe.Add(ref vectorCacheRef, i);
    float distance = Vector4.DistanceSquared(vector, candidate);
    // If it's an exact match, exit the loop
    if (distance == 0)
    {
    index = i;
    break;
    }
    if (distance < leastDistance)
    {
    // Less than... assign.
    index = i;
    leastDistance = distance;
    }
    }
    // Now I have the index, pop it into the cache for next time
    this.distanceCache[color] = index;
    match = Unsafe.Add(ref paletteRef, index);
    return index;
    }

    I'm intrigued by the variable bit depth approach. From our previous conversations the output of your quantizer color wise was amazing (though you were having dithering issues)

  11. antonfirsov commented on Sep 16, 2020

    @antonfirsov
    MemberAuthor

    All of the quantizers use the pixel map to improve output quality

    This is the problem. We need a significantly more memory-efficient lookup mechanism.

    Octree comes in the picture as probably the best candidate for such a lookup in case of 3-component images, assuming that @saucecontrol 's implementation is producing decent quality, and it's possible to integrate it with our generic dithering logic.

  12. saucecontrol commented on Sep 17, 2020

    @saucecontrol
    Contributor

    Ah, yeah, we're talking about the same thing. I think @antonfirsov and I were phrasing it poorly by talking about eliminating the cache. The map is a necessity as long as you're doing dithering, because even if you had a perfect histogram to start (which you can't always), error diffusion might still introduce colors that fall outside the input image's gamut. And you'll want that map to cache any new palette entries it has resolved.

    The real issue is that a dictionary isn't a good map/cache because its keys are opaque integers and its allocations are GC-heavy. Using an octree as the map/cache can potentially solve both of those.

    I don't know if there's any part of my code you can lift directly, just because it's so tuned for speed specific to my use case. Reviewing it now, there's some ugly stuff in there. Conceptually, though, I think you could model after it because I haven't seen anything that balances quality, speed, and memory as well it does.

    I did end up resolving my dithering issues we chatted about on gitter, by the way. My final implementation just ended up being Floyd-Steinberg with the error weighted to 7/8. I'd say the quality is generally equal or better than what ImageSharp does today, although there's not a ton of improvement to be had there -- your implementation is already very good quality-wise.

  13. JimBobSquarePants commented on Sep 17, 2020

    @JimBobSquarePants
    Member

    The real issue is that a dictionary isn't a good map/cache because its keys are opaque integers and its allocations are GC-heavy. Using an octree as the map/cache can potentially solve both of those.

    Yep, that's why I was going to just insert an LRU cache in there for now but if there's a better cache then I'm happy for anyone to have a go at replacing it.

    If there was a cheaper way to match the dithered result to the palette and retain (or improve) output quality then I'm all ears. It'll need someone else to experiment/implement though.

  14. antonfirsov commented on Sep 17, 2020

    @antonfirsov
    MemberAuthor

    I don't think a dictonary-based LRU cache is a good candidate, it's still very heavy, and at the same time likely much slower than a dictionary. In other words: it seems like a low hanging fruit, but I can bet for 95% that it just won't work well enough, so I would discourage investing into it.

    If we want a "best effort" map as a quick workaround, let's use LinearBoxesMap. It can cache up to 4 colors per 5-byte box for exact match, just like a dictionary. I made some experiments to investigate the likehood of colors overflowing boxes, and for most palettes it was very unlikely. In those rare cases when a color does not fit, the map will return "misses" and the linear euclidean search path activates (just like it would happen with LRU). For images with less uniform palette distribution (eg. monochrome or monocolor images), the result will be much worse of course.

  15. 6 remaining items

  16. rickbrew commented on Oct 12, 2020

    @rickbrew

    I have similar code in Paint.NET for image quantization when saving indexed images (8-bit, 4-bit, 2-bit, 1-bit). After palette generation, a class called PaletteTable holds the (up to) 256 color palette and has a GetClosestColor() method that does a linear search w/ Euclidean distance metric. It also uses a Dictionary for caching the results, and it does help a lot. But, memory usage is exactly what you expect: proportional to the total # of unique colors in the image. Usually it's fine and the speedup is worth it for this case, although it's still nowhere near as fast as I'd like. It still feels slow, in other words.

    I researched how to better implement PaletteTable and GetClosestColor and came across something called a k-d tree https://en.wikipedia.org/wiki/K-d_tree . It's like a binary tree that pivots on successive axes at each level of the tree (e.g. R, then G, then B). I believe this is the best data structure / algorithm for this, but haven't had the time or justification to dive into it and prove that. It should only only need O(n) memory where n <= 256 (the # of colors, i.o.w.). GetClosestColor should then be able to run in O(log2 n) time for each call.

  17. antonfirsov commented on Oct 12, 2020

    @antonfirsov
    MemberAuthor

    @rickbrew I made experiments with two KD tree implementations:

    1. One "traditional" with heap-allocated nodes, (C) @brianpopow
    2. Another one using a binary heap data structure and flat buffers in the hope of improving memory access patterns.

    Unfortunately, even with 2, this turned out to be almost as slow as the linear search with the Euclidean distances. O(log2 n) doesn't really help, if the traversal steps are too complex. (-> branch mispredictions, cache misses)

  18. rickbrew commented on Oct 12, 2020

    @rickbrew

    Thanks @antonfirsov. I suspected that the cost of the data structure and lookup wouldn't result in a 16x increase in perf (that is, O(256) vs O(log2 256) = O(16). Good to know!

    Maybe I'll play around with your implementation at some point to see if I can squeeze some better performance out of it (I only glanced through it -- it may already be maxed out!). If I do I'll post back here with my improvements.

  19. modified the milestones: , Future on Nov 11, 2020
  20. saucecontrol commented on Apr 11, 2021

    @saucecontrol
    Contributor

    @JimBobSquarePants following our chat a couple weeks back on Discord, I did a bit more experimentation with our quantizers, and I discovered a couple of interesting things. 1) MagicScaler was doing significantly better at generating a palette for a given image but 2) ImageSharp was doing significantly better at quantizing an image to a given palette. The net result was that MagicScaler tended to edge out ImageSharp in quality because dithering hides a great many faults, and a palette with a better gamut match will do better in either case. But in running both without dithering, and especially in trying ImageSharp's quantization with a MagicScaler-created palette, I could see the shortcomings in my mapping quality.

    With that, I set about overhauling my implementation, and I ended up with something that improves the accuracy of the mappings while being just a bit faster than it was before (and in half the memory). I've also scrapped the code that preserved the histogram tree to use as the basis of the mapping tree, meaning it now builds out the mapping tree straight off the palette, which would make that code easier to adapt to your needs. The code is hopefully much easier to follow now as well, because I've split the octree nodes used for the histogram phase and the nodes for the mapping phase into separate structs. The new version is here: https://github.com/saucecontrol/PhotoSauce/blob/master/src/MagicScaler/Magic/OctreeQuantizer.cs

  21. rickbrew commented on Apr 11, 2021

    @rickbrew

    @JimBobSquarePants @saucecontrol I've also spent a lot of time in the Paint.NET quantization code making fixes and improvements. I've talked with Jim about this a little on Twitter, and have made my code available over at https://github.com/rickbrew/PaintDotNet.Quantization

    The most important part of my findings are in sections 1 and 7 of the README over at that repo. Section 1 is just a simple fix for the Octree binning (ye ol' MSDN article bugs). It will result in more memory usage and CPU time, but should improve the quality/correctness of the palette.

    Section 7 doesn't yet have a writeup as I have some other demands on my time right now. The tl;dr is that I've found a better way to map colors to palette entries that is faster and uses less memory. The code is at https://github.com/rickbrew/PaintDotNet.Quantization/blob/main/PaintDotNet/Imaging/Quantization/ProximityPaletteMap.cs and a discussion is at https://twitter.com/rickbrewPDN/status/1379238853832155136 . Pictures are really needed to properly explain the algorithm and hopefully I'll get to all that once I'm done with taxes and some other things. It should be easy to integrate into ImageSharp and/or PhotoSauce.

    Benchmark results, in the PDN codebase at least, are very strongly in favor of the new "proximity map" code. Memory use is about 1MB max, as compared to basically unbounded for the linear search + Dictionary cache approach. The code isn't even that complicated, and further experimentation -- in search of an even better result -- should be straightforward.

    https://twitter.com/rickbrewPDN/status/1379235212366737410

    image

  22. saucecontrol commented on Apr 11, 2021

    @saucecontrol
    Contributor

    Wow, great to see so much work going on in this area! I'll give your project a look for sure. When I was chatting with James on Discord, he shared the tweet where you pointed out the shift math error in that MSDN code. That's certainly contributing to the ImageSharp palette quality today.

    I've got my quantizer down to a max of 87KB of memory now, and I built an AVX2 implementation of nearest distance match to a palette color to make brute-force search through the whole palette fast when filling in missing nodes in my octree. I subsample large images to a max of 4 megapixels when building the histogram, so those aren't directly comparable, but here's what my numbers look like on the worst-case image (all 24 bit colors), mapped to 5 bits of accuracy on my 4 year old laptop 😄 :

    image

  23. saucecontrol commented on Apr 11, 2021

    @saucecontrol
    Contributor

    BTW, @rickbrew, since you're in the high-perf C# clan, I'd suggest you join us over in the #lowlevel channel on the C# Discord. Tons of good chat and info on there.

  24. JimBobSquarePants commented on Jun 10, 2021

    @JimBobSquarePants
    Member

    Pop quiz. @saucecontrol What does your Octree output for this image look like?

    blur

    I notice that @rickbrew encoding this image with Paint.NET leads to blending with a white background.
    blur-pdn

    My local Octree Quantizer currently produces which simply drops the alpha component.
    QuantizeImageShouldPreserveMaximumColorPrecision_Rgba32_blur_Octree

    While Wu can deliver far better since it can handle multiple transparent colors (which png can support).
    QuantizeImageShouldPreserveMaximumColorPrecision_Rgba32_blur_Wu

  25. saucecontrol commented on Jun 10, 2021

    @saucecontrol
    Contributor

    Mine currently outputs this, because it has a hard-coded alpha threshold of 33.33% (85/255 to be precise).
    magicscaler

    With a lower threshold , it would look more like your octree output. I currently don't have a way to control that because I didn't build a proper configuration API for codecs -- you can really only configure JPEG in the current API. I'm presently working on fixing that, at which point I'll also allow matting against a bg color, along the lines of what PDN is doing there (with the color configurable, ofc). Those two configs combined are the best solution for GIF, same as Photoshop, et. al. give you.

    I thought a bit more about using the octree for variable transparency like PNG supports. My original thought was to extend the octree to a hexadecitree, but that really expands the memory requirements, and I think it might be better to handle alpha completely independently so that you can limit the number of partially transparent colors when they appear only on edges. That's on my list to explore when I get more time.

  26. JimBobSquarePants commented on Jun 10, 2021

    @JimBobSquarePants
    Member

    Thanks @saucecontrol

    I could, fairly easily, build background blending into the current architecture but I think I'll leave that for the future. I'm going to be pushing a PR very soon that improves our memory usage though.

  27. saucecontrol commented on Jun 10, 2021

    @saucecontrol
    Contributor

    Cool, looking forward to seeing what you've come up with!

    For reference, here's what my quantizer does with 1/255 alpha threshold

    lowthresh

    And matted against white. It looks lighter than the above browser-blended and PDN output because I do linear light blending with background when matting. Not so great in this case but avoids the red+green=brown problem.

    whitebg

    Just need to get that config API built... :)

    (Edit: updated first image to match ImageSharp's threshold value)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions