ImageMagick-style tint
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"

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

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:

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

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);
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');
However, this would produce a different image than the one from ImageMagick.

I probably made a mistake somewhere. 😅 /cc @jcupitt
I think the weighting function is an upside down parabola:

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:

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.
No. fricken. way. It looks fantastic! Thank you all so much, y'all are incredible! 🎉
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.
Man, you guys are the kindest. Thank you so much again, I could not be more appreciative! 🙏
... 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:

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.
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)
Yes, RGB is really awful for colour work like this, it's very hard to control.
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?
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]);
}
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
v0.33.0 now available with this improvement, thanks all.