Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature proposal: Dithering #457

Merged
merged 110 commits into from
Jan 14, 2024
Merged

Feature proposal: Dithering #457

merged 110 commits into from
Jan 14, 2024

Conversation

Lehonti
Copy link
Contributor

@Lehonti Lehonti commented Aug 23, 2023

Explanation: Sometimes we want to render an image using a different (usually more limited) palette, but also have the result look decent. Some of the most common algorithms for this 'diffuse the error' to neighboring pixels. In other words, instead of, say, simply applying a threshold to each pixel, they rely on neighboring pixels to help approximate the overall original look of the area.

This is the output in my development setup after dithering the image that is used to test the effects, with the default settings of this proposed effect, which consist of a fixed 16-color palette and the Floyd-Steinberg diffusion matrix:

output_16

The output looks sane, but we still need to verify that it is actually correct.

I will freely admit the code that I'm proposing needs to be improved. I wrote it quickly in order to have something working. Opening a pull request opens the door for discussion, though.

The implementation was inspired by this article:

https://tannerhelland.com/2012/12/28/dithering-eleven-algorithms-source-code.html

@cameronwhite
Copy link
Member

Very cool, thanks for working on this! I'll need to take a closer look through the code later once it's ready for review

A couple initial thoughts:

  • I think this is also similar to the Quantize effect in Paint.NET, although I'm not on a windows machine to test right now. From the documentation I think it's just using more automated methods of choosing a palette with some number of colours, but I'm not sure what it does for error diffusion
  • For the EffectData, you'll want this just consist of simple data members that can be used by the standard effect dialog to configure the effect, and then those can be translated into more complex structures internally

Copy link
Member

@cameronwhite cameronwhite left a comment

Choose a reason for hiding this comment

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

Added a few comments from reading through the first bit of code

int deltaB = color1.B - color2.B;

// Euclidean distance
return Math.Sqrt (deltaR * deltaR + deltaG * deltaG + deltaB * deltaB);
Copy link
Member

Choose a reason for hiding this comment

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

I think working in terms of squared distance would be fine here and let you avoid the square root and keep things in integers?

byte newR = Utility.ClampToByte (color.R + (int) (factor * errorRed));
byte newG = Utility.ClampToByte (color.G + (int) (factor * errorGreen));
byte newB = Utility.ClampToByte (color.B + (int) (factor * errorBlue));
return ColorBgra.FromBgra (newB, newG, newR, 255);
Copy link
Member

Choose a reason for hiding this comment

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

Should this preserve the original alpha?

public override void Render (ImageSurface src, ImageSurface dest, ReadOnlySpan<RectangleI> rois)
{
var src_data = src.GetReadOnlyPixelData ();
var src_copy = new ColorBgra[src_data.Length];
Copy link
Member

Choose a reason for hiding this comment

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

The article you linked to mentioned that you only need a couple rows to store the errors accumulated for the current + next row (varies based the matrix of course), so that could be a future optimization.

Or, at least avoid the extra copy by copying into the destination image and then working in-place there

var src_copy = new ColorBgra[src_data.Length];
src_data.CopyTo (src_copy);
var dst_data = dest.GetPixelData ();
foreach (var rect in rois) {
Copy link
Member

Choose a reason for hiding this comment

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

Because this algorithm is sequential, we definitely need to do some testing of rendering it live in Pint with the live preview dialog. I think it will try to run the effect separately on many small tiles which won't behave correctly?

var dst_data = dest.GetPixelData ();
foreach (var rect in rois) {
for (int y = 0; y < rect.Height - Data.DiffusionMatrix.RowsBelow; y++) {
for (int x = Data.DiffusionMatrix.ColumnsToLeft; x < rect.Width - Data.DiffusionMatrix.ColumnsToRight; x++) {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not really sure about the indexing here, this seems to ignore the left and top coordinates of the rectangle?
Also I think the dithering should likely still be applied to the first pixel(s) of the row?

@Lehonti
Copy link
Contributor Author

Lehonti commented Aug 24, 2023

Thank you for the feedback.

I took a look at the 'Quantize' effect in Paint.NET. It's kinda sorta similar but it has a few key differences. Among those that I could notice:

  • There is a choice between the "Octree" and "Median Cut" algorithms. I am not sure about the details of each, but I think "Octree" achieves a nicer result for photos, and "Median Cut" looks slightly more Andy Warhol-esque.
  • The user can choose the number of colors in the palette, but the colors itself can't be chosen. Instead, a color palette optimized for the image in question is created dynamically. I'm not saying this is a bad feature to have...I would also like Pinta to be able to create an optimized palette, but I would also like to be able to use my own palette.
  • It indeed does error diffusion. There's a setting for the "Dithering level". By the visual output, it seems that if the setting is higher, the error is spread over a larger area. This would also be nice to have in Pinta, too, although I need to do more research and see how it can be achieved.
  • It deals with transparency. Yeah, I should modify my feature so that it does the same.
  • Paint.NET allows for a transparency threshold (how translucent must a color be for it to be considered transparent?). Of course this would also be nice to have in Pinta

pdnquantize

@Lehonti
Copy link
Contributor Author

Lehonti commented Aug 24, 2023

Also, points taken. I will address the issues you've raised, little by little, when I have the time.

The first thing I've done is changing the EffectData so that its fields are enum-valued, at least for now. This will allow to choose among pre-defined settings. Ideally, in the future, the standard effect dialog can be extended so that it can edit palettes, and such.

@cameronwhite
Copy link
Member

Sounds good! Yeah I agree that being able to use a specific palette seems like a useful option to still have

Lehonti Ramos and others added 4 commits August 25, 2023 09:15
@cameronwhite
Copy link
Member

Just to let you know, I'll be on vacation the next couple weeks so I won't be responsive on here for a bit :)

John Doe and others added 7 commits August 27, 2023 15:32
@Lehonti
Copy link
Contributor Author

Lehonti commented Aug 27, 2023

I finally made the dithering work in the application itself instead of the fake Pinta setup I used at the beginning. Also, no pixels are being ignored (addressing one of the points you made)

One thing I'm noticing is that the ROIs are being passed one by one, by which I mean the Render() method is being called as many times as the image has rows, and the array with ROIs has only one item at a time, instead of passing the ROIs all at once. I wonder why that is. I think it would be better to just pass all of them at once. It would make it possible for the effect not to 'spill over' pixels that are not part of any ROI, as we would be able to keep track of all pixels in all ROIs at the beginning, and limit the effect to those pixels.

@cameronwhite
Copy link
Member

Yeah, this is happening because the live preview manager controls the multithreading, so it decides on the tiles to render and then calls Render() many times in parallel with the different rectangles. (And, if the effect is slow to render it will update the canvas to show partial progress)
The problem as you've noted is that this assumes the effect can be computed independently per region, whereas your effect needs to run serially

@cameronwhite
Copy link
Member

Looking at the latest diff more, what might be happening is:

  • In the first loop you're overwriting dest with the src pixels, just within the rectangle being operated on
  • In the second loop, when going through the diffusion matrix I think this can read pixels from dest that are outside the rectangle, since it only clamps to the image width. This could then read some stale values from the dest surface

@Lehonti
Copy link
Contributor Author

Lehonti commented Jan 13, 2024

Thank you, you are right about the clamping, and I just added checks so that the application of the matrix stays within the rectangle.

I updated the images being used for testing, too, and they are indeed different after making this change.

@Lehonti
Copy link
Contributor Author

Lehonti commented Jan 13, 2024

I added a few new palettes. Not necessarily the most important ones, but rather a selection of those that are easy to create programmatically (usually those that can be represented exactly in the color space that most modern computers use).


static Predefined ()
{
BlackWhite = ImmutableArray.CreateRange (new[] { ColorBgra.FromBgr (0, 0, 0), ColorBgra.FromBgr (255, 255, 255) });
Copy link
Member

Choose a reason for hiding this comment

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

ColorBgra.White and Black can be used here


public sealed class DitheringData : EffectData
{
[Caption ("Diffusion Matrix")]
Copy link
Member

Choose a reason for hiding this comment

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

Diffusion Matrix might not be the most helpful for non-technical users. Maybe just something like Dithering Method or Diffusion Method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I changed it to "Error Diffusion Method"


public enum PredefinedDiffusionMatrices
{
Sierra,
Copy link
Member

Choose a reason for hiding this comment

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

These should probably get Caption attributes added, e.g. Floyd-Steinberg should have a hyphen in it from what I see online, along with explanations for translators

It would also be good to see if there are any more descriptive names for users, e.g. "Fake" Floyd-Steinberg might not help a user understand what it does

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I added a CaptionAttribute to those values that have hyphens or spaces; and I labeled the matrix you mentioned as "Floyd-Steinberg Lite"

Copy link
Member

Choose a reason for hiding this comment

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

Thanks - since these captions will also show up as translatable strings, also adding some Translators: ... comments would be good to explain to translators that these are algorithms named after people

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I just added comments for translators making it clear that the matrices were named after people

FakeFloydSteinberg,
}

internal sealed class ErrorDiffusionMatrix
Copy link
Member

Choose a reason for hiding this comment

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

Please also add some doc comments for this class

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just added a doc comment with basic explanation

if (thisItem.Y < roi.Top || thisItem.Y >= roi.Bottom)
continue;

if (thisItem.X < 0 || thisItem.X >= settings.sourceWidth)
Copy link
Member

Choose a reason for hiding this comment

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

This second set of checks should be unnecessary since the rectangle should be inside the image bounds

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At first I added it because I wasn't sure if the ROIs were guaranteed to be inside the image bounds, but in the light of this I removed it.

@Lehonti Lehonti mentioned this pull request Jan 14, 2024
@cameronwhite cameronwhite merged commit c544cfb into PintaProject:master Jan 14, 2024
5 checks passed
@Lehonti Lehonti deleted the feature/error_diffusion_dithering branch January 14, 2024 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants