brendan-duncan / image

Dart Image Library for opening, manipulating, and saving various different image file formats.
MIT License
1.16k stars 262 forks source link

copyResizeCropSquare antialiasing issue with rounded corners #449

Open ekuleshov opened 1 year ago

ekuleshov commented 1 year ago

Thank you for adding support for rounded corners to all the square-related APIs.

I'm trying to use use the copyResizeCropSquare to add support for generating round-corners icons (as required by Apple) to the flutter_launcher_icons. Starting off 1024x1024 icon, shrinking it to 824 size and using corner radius between 180..190.

image = compositeImage(
        Image(
          width: 1024,
          height: 1024,
          numChannels: 4,
          backgroundColor: ColorUint8.rgba(0, 0, 0, 0),
        ),
        copyResizeCropSquare(
          image,
          size: 824,
          radius: 190, // 185.4,
          interpolation:
              image.width >= 824 ? Interpolation.average : Interpolation.linear,
        ),
        center: true,
      );

The composite image has transparent black background. But I get the same results with transparent white and also without the composite image. The result has some noticeable artifacts on the rounded corners.

Not sure if I'm missing something about method call parameters or this method need some additional parameter to specify background color for the transparent antialiasing for the corner arc part.

If it would help, I can try to create a simpler example or a test in the image package.

You could also take a look at my fork of the flutter_launcher_icons at https://github.com/ekuleshov/flutter_launcher_icons.git use the macos_round_corners branch. Then:

cd example/flavors
flutter create .
flutter pub run flutter_launcher_icons

The generated file is located at example/flavors/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png

image image
brendan-duncan commented 1 year ago

I'll take a look at improving the anti-aliased edges for compositing.

ekuleshov-idexx commented 1 year ago

I'll take a look at improving the anti-aliased edges for compositing.

It seems it is not only composing. I get this with just the copyResizeCropSquare() alone. Just zooming in on the same image:

image

brendan-duncan commented 1 year ago

1) I'll make the antialiasing optional. 2) Maybe there's a better solution for antialiasing the edges of a curved crop, like specifying the background color that it will blend with.

ekuleshov commented 1 year ago
  1. I'll make the antialiasing optional.

That is a good idea! Thank you.

2) Maybe there's a better solution for antialiasing the edges of a curved crop, like specifying the background color that it will blend with.

I was wondering about specifying background color for the outer area too. Though I still wonder why antialiased pixels of the cropped image get darker for a transparent background?

brendan-duncan commented 1 year ago

I added a antialias named arg to the crop functions, default to true.

I suspect the pixels are getting darker because I did the math wrong, which sounds like something I would do. I redid the math for antialiasing and they get less dark now:

Before: copyResizeCropSquare_rounded_before

After: copyResizeCropSquare_rounded_after

ekuleshov commented 1 year ago

I suspect the pixels are getting darker because I did the math wrong, which sounds like something I would do. I redid the math for antialiasing and they get less dark now...

Your images still have some noticeable border at the corner arc. :(

Using the following import ref with added antialiased: true param I still see noticeable dark border and those gray colors in the antialiased arc.

  image:
    git:
      url: https://github.com/brendan-duncan/image.git
      ref: d3dbb9a3f6f273f240735641617cc516d904d23b
image

and the same picture with "chess board" for the antialiased part. It looks as the transparent pixels are using direct image color without any lightening or darkening (based on the the outer background). Don't know if it can be done without specifying that background color.

Also found some notes on calculating color for these pixels: https://developer.download.nvidia.com/assets/gamedev/files/sdk/11/FXAA_WhitePaper.pdf http://blog.simonrodriguez.fr/articles/2016/07/implementing_fxaa.html

BTW, it seems like Fast approXimate Anti-Aliasing (FXAA) also could be a good general-use filter too, or used along with resizing images. More comparison of algorithms at http://www.diva-portal.org/smash/get/diva2:972774/FULLTEXT02.pdf

image
brendan-duncan commented 1 year ago

I had started adding an FXAA filter, I'll get that finished. I think the remaining dark border is from my composite function alpha blending function. I'll get that fixed up shortly.

brendan-duncan commented 1 year ago

I had some thoughts about compositing and darkened alpha blending while I was trying to sleep last night, and both threads of thought look promising.

1) The test I put together, based on what you described, composited the rounded cutout on to a black fully transparent image before compositing onto the opaque white image:

    await (Command() // using the new Command API
            ..createImage(width: 64, height: 64)
            ..fill(color: ColorRgb8(255, 255, 255)) // make the background white
            ..compositeImage(Command() // composite the cutout+transparency onto a white image
              ..createImage(width: 64, height: 64, numChannels: 4) // transparent 0,0,0,0 image
              ..compositeImage(Command() // composite the cutout onto the transparent image
                ..decodePngFile('test/_data/png/buck_24.png')
                ..convert(numChannels: 4) // make sure the image to cutout has an alpha channel
                ..copyResizeCropSquare(size: 64, radius: 20)
              )
            )
            ..writeToFile(
                '$testOutputPath/transform/copyResizeCropSquare_rounded.png'))
          .execute();

image

I changed it to not do that extra composite onto transparent black step, and the edges don't get darkened. When it got composited onto the black background, the alpha blending blended to the target color, which is black, so of course they got darkened.

    await (Command()
            ..createImage(width: 64, height: 64)
            ..fill(color: ColorRgb8(255, 255, 255))
            ..compositeImage(Command() // composite the cutout directly onto the white background
              ..decodePngFile('test/_data/png/buck_24.png')
              ..convert(numChannels: 4) // make sure the image has an alpha channel before doing the cutout
              ..copyResizeCropSquare(size: 64, radius: 20)
            )
            ..writeToFile(
                '$testOutputPath/transform/copyResizeCropSquare_rounded.png'))
          .execute();

image

2) The other thought trail I went down was about color space, sRGB vs linear. Images from file formats like JPG and PNG are in sRGB color space, meaning they are gamma corrected for display on monitors. sRGB is a non-linear color space, meaning the brightness intensity isn't a linear function from 0 to 1, but an exponential curve approximately pow(x, 1/2.2). The alpha blending I was doing, which is the standard baseColor invOverlayAlpha + overlayColor overlayAlpha, is a linear mix. So I convert the color to linear color space, do the alpha blend in linear color space, and then convert it back to sRGB color space. Now, even when the image was composited onto the black transparent background like in the original example, the colors still don't seem to get darkened. image

The color space conversion method seems to be the most robust but I have to do more testing, and probably need to make that an option since I don't know if the image is already in linear color space or not (you could do a gamma(image, 2.2) filter to convert the whole image to linear before the composite, and gamma(image, 1.0/2.2) after the composite to convert it back to sRGB).

brendan-duncan commented 1 year ago

I added a bool linearBlend = false named arg to the compositeImage function, to apply the composite in linear color space instead of sRGB color space. Its false by default because it does add extra calculations (6 calls to math.pow). I can optimize it later with a look-up table.

ekuleshov commented 1 year ago

The current code gives a pretty good result! Thank you.

There are some sharp or darker edges around the corner arcs, but those probably are introduced by the additional dropShadow(), which I don't quite understand. I tried to decrease radius of the blur effect, but that gave me some odd results.

image

Here is my code to generate macos-like icon shape with the white main area.

      final Image whiteImage = fill(Image(
        width: 1024, height: 1024, numChannels: 4,
        backgroundColor: ColorUint8.rgba(0, 0, 0, 0),
      ), color: ColorUint8.rgba(255, 255, 255, 255));

      image = compositeImage(
          Image(width: 1024, height: 1024, numChannels: 4,
              backgroundColor: ColorUint8.rgba(0, 0, 0, 0)),
          copyResizeCropSquare(whiteImage, size: 824, radius: 185, antialias: true,
              interpolation: image.width >= 824 ? Interpolation.average : Interpolation.linear),
          center: true, linearBlend: true);

      image = dropShadow(image, 0, 12, 26 ~/ 2, shadowColor: ColorRgba8(0, 0, 0, 64));

I'm using result of the compositeImage to add shadow and after dropShadow() method call the center white area shrinks to about 30 pixels compared to the generated image without the last dropShadow().

image