sharp icon indicating copy to clipboard operation
sharp copied to clipboard

ImageMagick-style tint

Open shahkashani opened this issue 3 years ago • 2 comments

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

shahkashani avatar Aug 18 '22 17:08 shahkashani

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 https://github.com/lovell/sharp/issues/1235#issuecomment-390907151 might be worth exploring, and happy to accept a PR, if you're able.

lovell avatar Aug 18 '22 18:08 lovell

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);

shahkashani avatar Aug 19 '22 09:08 shahkashani

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

kleisauke avatar Oct 01 '22 18:10 kleisauke

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.

jcupitt avatar Oct 07 '22 16:10 jcupitt

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

shahkashani avatar Oct 07 '22 16:10 shahkashani

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.

kleisauke avatar Oct 08 '22 08:10 kleisauke

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

shahkashani avatar Oct 08 '22 08:10 shahkashani

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

jcupitt avatar Oct 09 '22 12:10 jcupitt

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)

kleisauke avatar Oct 09 '22 12:10 kleisauke

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

jcupitt avatar Oct 09 '22 14:10 jcupitt

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?

james090500 avatar Nov 11 '23 00:11 james090500

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]);
}

jcupitt avatar Nov 11 '23 13:11 jcupitt

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 https://github.com/lovell/sharp/pull/3859

lovell avatar Nov 18 '23 11:11 lovell

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

lovell avatar Nov 29 '23 13:11 lovell