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

ImageMagick-style tint #3338

Closed
shahkashani opened this issue Aug 18, 2022 · 14 comments
Closed

ImageMagick-style tint #3338

shahkashani opened this issue Aug 18, 2022 · 14 comments

Comments

@shahkashani
Copy link

shahkashani commented Aug 18, 2022

Question about an existing feature

I'm pretty sure this is just me being stupid, but is there a way to use tint to achieve something like the below?

ImageMagick

convert "input.png" -fill "#c31306" -tint 100 "output.png"

output-im

Sharp

await sharp(image).tint('#c31306')

output-sharp

Note how the highs and lows are also tinted in the Sharp case, but not with ImageMagick.

The ImageMagick source code indicates that they use a weighting function for their tinting:

%  TintImage() applies a color vector to each pixel in the image.  The length
%  of the vector is 0 for black and white and at its maximum for the midtones.
%  The vector weighting function is f(x)=(1-(4.0*((x-0.5)*(x-0.5))))

But I am not sure how to convert this into Sharp terms. Perhaps I could use recomb somehow?

I have even tried to use composite, but none of the blend modes seem to be able to achieve what I want. multiply gets close, but I think ultimately what I need is something like Cairo's color blend mode, which doesn't exist in Sharp or vips.

Any help would be highly appreciated!

What are you trying to achieve?

A tint that preserves hights and lows.

When you searched for similar issues, what did you find that might be related?

I found some tickets regarding tint not behaving as expected, but those issues have been resolved.

Please provide a minimal, standalone code sample, without other dependencies, that demonstrates this question

const image = await sharp('input.png').toBuffer();
const outputBuffer = await sharp(image).tint('#c31306');
await outputBuffer.toFile('output.png');

Please provide sample image(s) that help explain this question

Raw image:

sandwich

@lovell
Copy link
Owner

lovell commented Aug 18, 2022

Bonjour, this has been discussed previously at #1235 - sharp sets the chroma in the LAB colour space and doesn't scale the luminance, but perhaps should. The suggestion in #1235 (comment) might be worth exploring, and happy to accept a PR, if you're able.

@shahkashani
Copy link
Author

Much appreciated. I tried the above snippet and ended up with the following, which still isn't right (note how all the highs are gone), but I'll keep looking around.

Desired result

output-im

Vips

output-vips

  let im = vips.Image.newFromFile(input);
  const start = [0, 0, 0];
  const stop = [42, 64, 55];   // #c31306
  let lut = vips.Image.identity().divide(255);
  lut = lut.multiply(stop).add(lut.multiply(-1).add(1).multiply(start));
  lut = lut.colourspace(vips.Interpretation.srgb, {
    source_space: vips.Interpretation.lab
  });
  if (im.hasAlpha()) {
    const withoutAlpha = im.extractBand(0, { n: im.bands - 1 });
    const alpha = im.extractBand(im.bands - 1);
    im = withoutAlpha.colourspace(vips.Interpretation.b_w)
      .maplut(lut)
      .bandjoin(alpha);
  } else {
    im = im.colourspace(vips.Interpretation.b_w).maplut(lut);
  }
  const outputBuffer = im.writeToBuffer('.png');
  fs.writeFileSync(output, outputBuffer);

@kleisauke
Copy link
Contributor

The vector weighting function is f(x)=(1-(4.0*((x-0.5)*(x-0.5))))

I think the equivalent in libvips would be lut = (1 - (4.0 * ((lut - 0.5) ** 2))) * tint. Using wasm-vips, this can be written like this:

// #c31306 as CIELAB triple
const tint = [41.349, 63.353, 53.1];

// let lut = vips.Image.identity() / 255;
let lut = vips.Image.identity().divide(255);

// lut = (1 - (4.0 * ((lut - 0.5) ** 2))) * tint
lut = lut.subtract(0.5).pow(2).multiply(4).multiply(-1).add(1).multiply(tint);

lut = lut.colourspace(vips.Interpretation.srgb/* 'srgb' */, {
  source_space: vips.Interpretation.lab // 'lab'
});

// Load an image from file
let im = vips.Image.newFromFile('185454088-9c627d29-c4b4-4d3d-9ce5-0fa4666bb566.png');

if (im.hasAlpha()) {
  // Separate alpha channel
  const withoutAlpha = im.extractBand(0, { n: im.bands - 1 });
  const alpha = im.extractBand(im.bands - 1);
  im = withoutAlpha.maplut(lut).bandjoin(alpha);
} else {
  im = im.maplut(lut);
}

// Finally, write the result to a blob
const outBuffer = im.writeToBuffer('.png');

(Playground link)

However, this would produce a different image than the one from ImageMagick.
output

I probably made a mistake somewhere. 😅 /cc @jcupitt

@jcupitt
Copy link
Contributor

jcupitt commented Oct 7, 2022

I think the weighting function is an upside down parabola:

image

So you need to multiply the tint by the weight, but then use a plain ramp for L (otherwise you'll send white back to black again).

I had a stab in python:

#!/usr/bin/python3

import sys
import pyvips

# #c31306 as CIELAB triple
tint = [41.349, 63.353, 53.1]

lut = pyvips.Image.identity() / 255

# so max at 0.5, tailing to 0 at black and white
lab = (1 - 4.0 * ((lut - 0.5) * (lut - 0.5))) * tint

# we want L to stay the same, we just take ab from the lab tint
lut = (lut * 100).bandjoin(lab[1:])

# and turn to sRGB
lut = lut.colourspace("srgb", source_space="lab")

image = pyvips.Image.new_from_file(sys.argv[1], access="sequential")
image = image.colourspace("b-w").maplut(lut)
image.write_to_file(sys.argv[2])

To make:

x

It looks a bit dark, but that's because it's in CIELAB and I think IM is working in RGB. Adding a gamma would probably fix it.

@shahkashani
Copy link
Author

No. fricken. way. It looks fantastic! Thank you all so much, y'all are incredible! 🎉

@kleisauke
Copy link
Contributor

Great! Here's an updated wasm-vips playground link based on the above Python sample.

It looks a bit dark, but that's because it's in CIELAB and I think IM is working in RGB. Adding a gamma would probably fix it.

Removing the monochrome colourspace conversion could also make it a bit brighter.

@shahkashani
Copy link
Author

Man, you guys are the kindest. Thank you so much again, I could not be more appreciative! 🙏

@jcupitt
Copy link
Contributor

jcupitt commented Oct 9, 2022

... I thought of a simple way to fix the gamma problem. You build the initial LUT in RGB space, go to LAB, do the tint, then go back to RGB again. Now when you apply the LUT to an RGB image you don't distort the gamma.

#!/usr/bin/python3

import sys
import pyvips

if len(sys.argv) != 3:
    print(f"usage: {sys.argv[0]} INPUT-IMAGE OUTPUT-IMAGE")
    sys.exit(1)

# #c31306 as an rgb triple
tint = [195, 19, 6]

# turn to CIELAB
tint = (pyvips.Image.black(1, 1) + tint).colourspace("lab", source_space="srgb")
tint = [x.avg() for x in tint.bandsplit()]

# start with an RGB greyscale, then go to LAB
lab = pyvips.Image.identity(bands=3).colourspace("lab", source_space="srgb")

# scale to 0-1 and make a weighting function
x = lab[0] / 100
weight = 1 - 4.0 * ((x - 0.5) * (x - 0.5))

# we want L to stay the same, we just weight ab 
lab = lab[0].bandjoin((weight * tint)[1:])

# and turn to sRGB
lut = lab.colourspace("srgb", source_space="lab")

image = pyvips.Image.new_from_file(sys.argv[1], access="sequential")
image = image.colourspace("b-w").maplut(lut)
image.write_to_file(sys.argv[2])

I see:

x

From left to right, that's the original, this code, the IM result, and a block of #c31306. I think this code looks the best -- IM has made the image too bright (compare the detail visible in the dark areas compared to the original), and the tint looks washed out and too yellow compared to the actual tint colour.

@kleisauke
Copy link
Contributor

Nice! Looking at IM's code, I think they operate directly on RGB, which is probably the reason for this difference.

(here's an updated wasm-vips playground link)

@jcupitt
Copy link
Contributor

jcupitt commented Oct 9, 2022

Yes, RGB is really awful for colour work like this, it's very hard to control.

@james090500
Copy link

I'd love to see some progress on this. I tried to implement this with no avail on my side. Are we just waiting on a functional PR?

@jcupitt
Copy link
Contributor

jcupitt commented Nov 11, 2023

I don't know sharp well enough to make a PR, but I reworked that python code into c++, it might help someone else:

/* compile with
 *
 *  g++ tint.cc `pkg-config vips-cpp --cflags --libs`
 */

#include <vips/vips8>

using namespace vips;

// make a LUT which applies a tint to a mono image
static VImage
make_tint(std::vector<double> tint) 
{   
    // turn the tint to CIELAB
    tint = (VImage::black(1, 1) + tint) 
        .colourspace(VIPS_INTERPRETATION_LAB,
            VImage::option()->set("source_space", VIPS_INTERPRETATION_sRGB))
        .getpoint(0, 0);

    // start with an RGB greyscale, then go to LAB
    auto lab = VImage::identity(VImage::option()->set("bands", 3))
        .colourspace(VIPS_INTERPRETATION_LAB,
            VImage::option()->set("source_space", VIPS_INTERPRETATION_sRGB));
            
    // scale to 0-1 and make a weighting function
    auto l = lab[0] / 100;
    auto weight = 1 - 4.0 * ((l - 0.5) * (l - 0.5));
    
    // weight ab
    auto ab = (weight * tint)
        .extract_band(1, VImage::option()->set("n", 2));

    // use weighted ab 
    lab = lab[0].bandjoin(ab);

    // and turn to sRGB
    return lab
        .colourspace(VIPS_INTERPRETATION_sRGB,
            VImage::option()->set("source_space", VIPS_INTERPRETATION_LAB));
}

int
main (int argc, char **argv)
{
    // tint with #c31306 as an rgb triple
    auto tint = make_tint({195, 19, 6});

    auto image = VImage::new_from_file(argv[1], VImage::option()
        ->set("access", "sequential"));
    image = image.colourspace(VIPS_INTERPRETATION_B_W).maplut(tint);
    image.write_to_file(argv[2]);
}

lovell added a commit that referenced this issue Nov 18, 2023
Co-authored-by: John Cupitt <jcupitt@gmail.com>
lovell added a commit that referenced this issue Nov 18, 2023
Co-authored-by: John Cupitt <jcupitt@gmail.com>
lovell added a commit that referenced this issue Nov 18, 2023
Co-authored-by: John Cupitt <jcupitt@gmail.com>
@lovell
Copy link
Owner

lovell commented Nov 18, 2023

Thanks @jcupitt! I've opened a PR with your LUT-based approach so it's easier to see the difference this improvement makes to the test fixtures/expectations - see #3859

@lovell lovell added this to the v0.33.0 milestone Nov 18, 2023
@lovell
Copy link
Owner

lovell commented Nov 29, 2023

v0.33.0 now available with this improvement, thanks all.

@lovell lovell closed this as completed Nov 29, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants