Skip to content

Conversation

@JimBobSquarePants
Copy link
Member

@JimBobSquarePants JimBobSquarePants commented Feb 16, 2020

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 matches 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

A deep investigation into our quantization and dithering algorithms yielded issues we needed to fix.

  • Error Diffusion algorithm offset was incorrect.
  • Ordered Dithering algorithm was completely incorrect.
  • Quantization could not utilize both algorithms.
  • Quantization could not be isolated to a given bounds.
  • Color.Transparent did not match W3C specification, rather copied incorrect System.Drawing value.
  • Tests were leaking memory.

This PR:

  • Updates Error Diffusion algorithm with correct implementation.
  • Updates Ordered Dithering algorithm with correct implementation.
  • Merges IErrorDiffusion and IOrderedDither APIs into new IDither api.
  • Updates and simplifies dithering processor to use new API
  • Updates and simplifies format quantization and quantization processor to use new API.
  • Introduces new QuantizerOptions for easier bundling of options.
  • Introduces new dither scaling options to allow variable dithering.
  • Fixed Color.Transparent value.
  • Improves performance across the board.
  • Fixes memory leak in Bitmap Encoder
  • Fixes memory leak in Quantizer tests.

Examples

Here's an example of the new dithering output vs the old using BayerDither8x8 ordered dithering. Note the much improved handling of gradients.

Old
DitherFilter_WorksWithAllDitherers_Bike_BayerDither8x8-Old

New
DitherFilter_WorksWithAllDitherers_Bike_BayerDither8x8-New

With the new DitherScale options built into QuantizerOptions it's possible to incrementally dither output.

Here's incremental quantization and dithering of a sample image. I've used the websafe palette to accentuate differences.

Original
david

0%
FloydSteinbergDither 0%

25%
FloydSteinbergDither 25%

50%
FloydSteinbergDither 50%

75%
FloydSteinbergDither 75%

100%
FloydSteinbergDither 100%

Benchmarks

Note: Previous benchmarks did not have Core 3.1 versions. Memory increase is due to changing of color lookup caching strategy.

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18363
Intel Core i7-8650U CPU 1.90GHz(Kaby Lake R), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK = 3.1.101

[Host]     : .NET Core 3.1.1 (CoreCLR 4.700.19.60701, CoreFX 4.700.19.60801), X64 RyuJIT
Job-OJKYBT : .NET Framework 4.8 (4.8.4121.0), X64 RyuJIT
Job-RZWLFP : .NET Core 2.1.15 (CoreCLR 4.6.28325.01, CoreFX 4.6.28327.02), X64 RyuJIT
Job-NUYUQV : .NET Core 3.1.1 (CoreCLR 4.700.19.60701, CoreFX 4.700.19.60801), X64 RyuJIT

IterationCount=3  LaunchCount=1  WarmupCount=3

Before

Method Job Runtime Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
DoDiffuse Clr Clr 124.93 ms 33.297 ms 1.8251 ms - - - 2 KB
DoDiffuse Core Core 89.63 ms 9.895 ms 0.5424 ms - - - 1.91 KB

After

Method Runtime Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
DoDiffuse .NET 4.7.2 30.535 ms 19.217 ms 1.0534 ms - - - 26.25 KB
DoDither .NET 4.7.2 14.174 ms 1.625 ms 0.0891 ms - - - 31.38 KB
DoDiffuse .NET Core 2.1 15.984 ms 3.686 ms 0.2020 ms - - - 25.98 KB
DoDither .NET Core 2.1 8.646 ms 1.635 ms 0.0896 ms - - - 28.99 KB
DoDiffuse .NET Core 3.1 16.235 ms 9.612 ms 0.5269 ms - - - 25.96 KB
DoDither .NET Core 3.1 8.429 ms 1.270 ms 0.0696 ms - - - 31.61 KB

Copy link
Member

@antonfirsov antonfirsov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the algorithms, focused on the API. I think we may improve it further.

Comment on lines 11 to 44
public interface IDither
{
/// <summary>
/// Gets the <see cref="Dithering.DitherType"/> which determines whether the
/// transformed color should be calculated and supplied to the algorithm.
/// </summary>
public DitherType DitherType { get; }

/// <summary>
/// Transforms the image applying a dither matrix.
/// When <see cref="DitherType"/> is <see cref="DitherType.ErrorDiffusion"/> this
/// this method is destructive and will alter the input pixels.
/// </summary>
/// <param name="image">The image.</param>
/// <param name="bounds">The region of interest bounds.</param>
/// <param name="source">The source pixel</param>
/// <param name="transformed">The transformed pixel</param>
/// <param name="x">The column index.</param>
/// <param name="y">The row index.</param>
/// <param name="bitDepth">The bit depth of the target palette.</param>
/// <param name="scale">The dithering scale used to adjust the amount of dither. Range 0..1.</param>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <returns>The dithered result for the source pixel.</returns>
TPixel Dither<TPixel>(
ImageFrame<TPixel> image,
Rectangle bounds,
TPixel source,
TPixel transformed,
int x,
int y,
int bitDepth,
float scale)
where TPixel : struct, IPixel<TPixel>;
}
Copy link
Member

@antonfirsov antonfirsov Feb 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job with the unification, but I'm still wondering if we can find a better abstraction/representation here. The worst thing is that is hard to introduce any optimization with the current design, because of the per-pixel virtual Dither<T> method. Having a 2-element enum defining the two possible algorithms in parallel with the two matching subclasses is a warning sign for me.

A little bit unorthodox in the world of OO, but I'm wondering about your thoughts on the following idea: Instead of pretending that we are doing something abstract and pluggable in this method, we could simply represent the ditherers by the data (and only the data!) that defines them:

public struct ErrorDiffusionParameters
{
    public int Offset { get; }

    public DenseMatrix<float> Matrix { get; }
}

public struct OrderedDitherParameters
{
    public DenseMatrix<float> ThresholdMatrix { get; }
}

public sealed class Dither
{
    // Determines which algorithm to use
    public DitherType DitherType { get; }

    // Parameters in case of ErrorDiffusion
    public ErrorDitherParameters? ErrorDiffusionParameters { get; }

    // Parameters in case of OrderedDither
    public OrderedDitherParameters? OrderedDitherParameters { get; }

    public static Dither CreateErrorDiffusion(int offset, DenseMatrix<float> matrix) { ... }

    public static Dither CreateOrderedDither(DenseMatrix<float> thresholdMatrix) { .... }
}

public static class KnownDitherings
{
    public static Dither BayerDither2x2 { get; } = CreateBayerDither2x2();

    private static Dither CreateBayerDither2x2()
    {
       DenseMatrix<float> thresholdMatrix;
       
       // calculate the Bayer  thresholdMatrix 

       return Dither.CreateOrderedDither(thresholdMatrix);
    }
}

// Implementation could be moved to utility methods, 
// since we are branching on `DitherType` anyways.
// Use the per-pixel variants for now, but it would be easy to switch to bulk variants later,
// without getting lost in API design
internal static class DitherExtensions
{
    internal static TPixel ApplyOrderedDither<TPixel>(
            this Dither dither,
            ImageFrame<TPixel> image,
            Rectangle bounds,
            TPixel source,
            TPixel transformed,
            int x,
            int y,
            int bitDepth,
            float scale)
            where TPixel : struct, IPixel<TPixel>
    {
        // ....
    }

    internal static TPixel ApplyErrorDither<TPixel>(
            this Dither dither,
            ImageFrame<TPixel> image,
            Rectangle bounds,
            TPixel source,
            TPixel transformed,
            int x,
            int y,
            int bitDepth,
            float scale)
            where TPixel : struct, IPixel<TPixel>
    {
        // ....
    }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very interesting! I'll have a play around later tonight.

Copy link
Member Author

@JimBobSquarePants JimBobSquarePants Feb 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One issue is the sheer amount of dithering approaches.

  • Random,
  • BlueNoise
  • Halftone
  • etc

Each one means an extra nullable options property which seems a bit icky.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know that, but it changes the picture a lot, because it means we need real extensibility here. The DitherType enum makes it hard to add a new dithering, since it needs touching processor/quantizer code as well.

I think we should keep thinking and find a reasonable abstraction. I have an idea for a double-dispatch trick, I will gist it up later today.

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, good point. I'd really like to investigate the possibility of IDither implementations being a struct passed via a generic argument also. (IResampler in the future also in a different PR).

Been looking at the JIT ASM to see what the difference is between the two approaches and it's chalk and cheese.

Current class implementation
https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKGIAYACY8gOgCUBXAOwwEt8YLAMIR8AB14AbGFADKMgG68wMXAG4aNYgGYmpBkIYBvGgzNNdxFAwCyACgCUx0+dcvXZhdigMfAXgYuGAB3Bk4uIKg7IND5bGkAEwAxCAhHBw1qD3Modm47cgz3BgBfGjLqLV1cGHiYBL0w7kiTSqzzHSaImTsASRSIBgAzVIcjN3bsjAALXlwWAYYAkYhM1wqJ107+1IZFowBzGAw1CuLOqy67Xh4GBDHi1xm5hdSWAEEEhLt7tfMKs5tTo3DAyIbYFQMHYQVquEEMT7feG/cqaWi6bDAXAYKAQjCNAYgaHOSYMYoAbRsx2mEASvXEkjsVJmtPpYkkAHkxHwIFx5u8DgdYLhcLwFDBelxJDcbgcHABdc66eGI663e6w7JMADsdwYAGoGOQ/mYAWjOjU6g0yAw4olFiA9rtWuUgA=

Generic struct version
https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8ABAJgEYBYAKGIAYACY8gOgCUBXAOwwEt8YLAMIR8AB14AbGFADKMgG68wMXAG4aNYgGYmpBkIYBvGgzNNdxFAwCyACgCUx0+dcvXZhdigMfAXgYuGAB3Bk4uIKgAHnlsaQATADEICAA+OyDQ2ITkiEcHDWoPcyh2bjtyAvcGAF8aOuoaAG1ZAAtvMQAZbGAynn5BACleDABxGEjlOwwATzEYCAAzO2yYJJSHBwBdLV1cGDi1vTDuSKiAFVTq4NaZGAZzhhAGAElcmhMi8x0TiJk7R6LDZGNxfYoYVq8XAsXIMAJAiCFVwNUGuH6PWFGADmMAwaga1R+Vl+dl4PAYCAcn2KZghUJhKRYAEF4vE7JSkeYGgTGrRdGSMDJFtgVK9ctTzAKGCy2VKOfVNHyGD1cBgoCKMMdciA3ilnGCGNUmjZca0IPEXuJJHYTRDzZaxJIAPJiPgQLjQplYrGwXC4XgKGAvLiSMlkrHbQn88ky0nkykS4rEADsFIYAGoGOROWZuYqfvtDvFjqt1hAngxMYqGkA===

Regarding utility methods or extensions. We'll have to build ones that handle the different behaviors in the FrameQuantizer<TPixel> and PaletteDitherProcessor<TPixel>.

PaletteDitherProcessor

this.pixelMap.GetClosestColor(sourcePixel, out TPixel transformed);

FrameQuantizer

outputSpan[rowStart + x - offsetX] = this.GetQuantizedColor(sourcePixel, paletteSpan, out TPixel transformed);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll keep that in mind!

Copy link
Member

@antonfirsov antonfirsov Feb 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I mostly figured out PaletteQuantizer, but I have no idea how to handle PaletteDitherProcessor:
https://gist.github.com/antonfirsov/cbad26bffc39fdd9803b7978a2e87abb

This is getting extremely complex and time-consuming if we want to do it right. Wouldn't it be wiser to hide the API for now, and open a post-RC1 issue?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're both heading in the same direction. Bear with me, I think I can crack it today.

/// <summary>
/// Gets the default dithering algorithm to use.
/// </summary>
public static IDither DefaultDither { get; } = KnownDitherings.FloydSteinberg;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't really a constant. Woldn't it better to define it in KnownDitherings instead? (KnownDitherings.Default)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd have to do that across the board for all our Known*** types.

@codecov
Copy link

codecov bot commented Feb 20, 2020

Codecov Report

Merging #1114 into master will increase coverage by 0.04%.
The diff coverage is n/a.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1114      +/-   ##
==========================================
+ Coverage   82.02%   82.06%   +0.04%     
==========================================
  Files         701      680      -21     
  Lines       28797    28655     -142     
  Branches     3281     3244      -37     
==========================================
- Hits        23621    23516     -105     
+ Misses       4494     4462      -32     
+ Partials      682      677       -5     
Flag Coverage Δ
#unittests 82.06% <93.77%> (+0.04%) ⬆️
Impacted Files Coverage Δ
...g/Processors/Dithering/OrderedDither.KnownTypes.cs 100.00% <0.00%> (ø)
...sing/Processors/Quantization/QuantizerConstants.cs 100.00% <0.00%> (ø)
...ocessors/Quantization/EuclideanPixelMap{TPixel}.cs 90.00% <0.00%> (ø)
...essing/Processors/Quantization/QuantizerOptions.cs 100.00% <0.00%> (ø)
src/ImageSharp/Processing/KnownDitherings.cs 100.00% <0.00%> (ø)
...arp/Processing/Processors/Dithering/ErrorDither.cs 93.61% <0.00%> (ø)
...rocessors/Quantization/FrameQuantizerExtensions.cs 100.00% <0.00%> (ø)
...sing/Processors/Dithering/ErroDither.KnownTypes.cs 100.00% <0.00%> (ø)
...ocessors/Quantization/QuantizeProcessor{TPixel}.cs 100.00% <0.00%> (+100.00%) ⬆️
...ssing/Processors/Quantization/QuantizeProcessor.cs 100.00% <0.00%> (+100.00%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update dfa34e9...1bd4704. Read the comment docs.

Copy link
Member

@antonfirsov antonfirsov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing improvement!

Could not find any serious issue, just wondering about ditherer equality behavior, otherwise LGTM.

Comment on lines +125 to +126
public QuantizedFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> FrameQuantizerExtensions.QuantizeFrame(ref this, source, bounds);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice trick! Did not get it first.

/// An error diffusion dithering implementation.
/// <see href="http://www.efg2.com/Lab/Library/ImageProcessing/DHALF.TXT"/>
/// </summary>
public readonly partial struct ErrorDither : IDither, IEquatable<ErrorDither>
Copy link
Member

@antonfirsov antonfirsov Feb 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what are the use-cases for equality comparison. Should we also overload ==? Do we also need to implement IEquatable<IDither>? Don't we need a few tests to make sure it works as intended?

While analyzing this I found #1116.

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 we should implement IEquatable<IDither> plus operators. I'll do that now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Also fixed the DenseMatrix<T> hashcode generation and added tests.

@JimBobSquarePants JimBobSquarePants merged commit 07c16a5 into master Feb 20, 2020
@JimBobSquarePants JimBobSquarePants deleted the js/quantization-tweaks branch February 20, 2020 04:45
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.

4 participants