lovell / sharp

High performance Node.js image processing, the fastest module to resize JPEG, PNG, WebP, AVIF and TIFF images. Uses the libvips library.
https://sharp.pixelplumbing.com
Apache License 2.0
29.37k stars 1.3k forks source link

Color tint feature not working as expected #1235

Closed wezside closed 6 years ago

wezside commented 6 years ago

If I use the following code I would expect three tinted images, a red tint, green tint, blue tint. Here is what the code produces:

var c = color({r: 255, g: 0, b: 0});
//var c = color({r: 0, g: 255, b: 0});
//var c = color({r: 0, g: 0, b: 255});
var tmp = __dirname + '/../public/upload/tmp/monalisa.jpg';
sharp(tmp)
    .tint(c)
    .toFile(__dirname + '/../public/upload/tmp/output.jpg')
    .then(function(data)
    {

})
.catch(function(err) { console.log(err); });

red green blue

wezside commented 6 years ago

Not sure if this will help. But by adding linear() the code tints the image as expected: [Edit] This is incorrect please read the next comment

var c = color({r: 255, g: 0, b: 0});
var tmp = __dirname + '/../public/upload/tmp/monalisa.jpg';
sharp(tmp)
    .linear(1.0, 1.0)
    .tint(c)
    .toFile(__dirname + '/../public/upload/tmp/output.jpg')
    .then(function(data)
    {

})
.catch(function(err) { console.log(err); });

output

wezside commented 6 years ago

So after some more digging into the source it would appear that the issue is related to the LAB colour space usage. sharp uses the color module to retrieve the darkness this.options.tintA = color.a(). This produces a lab colour which can be positive or negative for lightness/darkness, difference in red and green and difference in yellow and blue [source]. It appears that Vips colourspace VIPS_INTERPRETATION_LAB is not able to interpret these negative values (maybe it requires this normalised?) but in the interim for me to get the tint to work I used the LCH colourspace. The module colour unfortunately doesn't expose these conversions from the underlying module color-convert so this is a workaround at best.

In colour.js

const convert = require('color-convert');
function tint (rgb) {
    const colour = color(rgb);
    const lch = convert.rgb.lch(rgb.red(), rgb.green(), rgb.blue()); 
    this.options.tintA = lch[1];
    this.options.tintB = lch[2];
    return this;
}

In operations.cc change the luminance colour space to LCH:

VImage luminance = image.colourspace(VIPS_INTERPRETATION_LCH)[0];

Rebuild sharp:

npm rebuild --build-from-source sharp

Result for colour {r: 0, g: 246, b: 255} (Cyan)

output

lovell commented 6 years ago

Thank you for reporting this @wezside and great detective work on what's causing it.

@jcupitt What are the valid ranges for the AB chroma values of LAB in libvips? The docs currently say "bands have the obvious range" and I'm sure you've probably told me before but I can't remember and can't find them mentioned in previous libvips issues.

wezside commented 6 years ago

No problem. Great library. It's worth pointing out that my "fix" still has some issues in outer ranges (like 350 degree hue). I had one tint colour lch [ 20, 31, 350 ] which doesn't implement correctly. Not sure if these are precision errors or calculation errors in the colour space conversions.

jcupitt commented 6 years ago

Hello, float LAB is 0-100 for L and +/- 128 for ab. I guess tint() colourizes a monochrome image? I usually do this by making an identity LUT and remapping white to the target RGB. If you do the remapping in some other colourspace, you can get different sorts of interpolation.

I'll try a small example.

jcupitt commented 6 years ago

Here's a simple tint in Ruby:

#!/usr/bin/env ruby

require 'vips'

colour = [255, 0, 0]

image = Vips::Image.new_from_file ARGV[0]
image = image.colourspace "b-w"

lut = Vips::Image.identity
lut = (lut / 256) * colour
lut = lut.cast "uchar"

image = image.maplut lut

image.write_to_file ARGV[1]

That'll do an 8-bit image, you'd need to change the 256 and the identity for a 16-bit one.

jcupitt commented 6 years ago

I see sharp is going to LAB, keeping L and setting a constant value for ab. I remember experimenting with this: it doesn't work that well, unfortunately, since people expect saturation to scale with luminance, but constant ab will give you saturated dark colours and washed out bright ones (relatively).

Here's another tint. This one makes a gradient between any two colours in CIELAB and maps black-white in the original to that.

#!/usr/bin/env ruby

require 'vips'

# make a lut which is a smooth gradient from start colour to stop colour, with
# start and stop in CIELAB
def gradient(start, stop)
    lut = Vips::Image.identity / 255
    lut = lut * stop + (lut * -1 + 1) * start
    lut.colourspace(:srgb, source_space: :lab)
end

# various colours as CIELAB triples
black = [0, 0, 0]
red = [53, 80, 67]
green = [88, -86, 83]
blue = [32, 79, -108]
white = [100, 0, 0]

image = Vips::Image.new_from_file ARGV[0]
image = image.colourspace("b-w").maplut(gradient black, red)
image.write_to_file ARGV[1]

Sample output:

x

jcupitt commented 6 years ago

(the odd lut * stop + (lut * -1 + 1) * start is necessary in Ruby since it doesn't allow constant - image. In C++ you could just write lut * stop + (1 - lut) * start)

lovell commented 6 years ago

Brilliant, thanks @jcupitt, I'll try your first example as that looks perfectly suitable and should be a bit simpler/faster than a (L)AB conversion.

lovell commented 6 years ago

On closer inspection it turns out there were a couple of bugs in the current implementation where negative AB values would be ignored and LAB interpretation was sometimes lost. Commit 54a71fc1 addresses these and adds new test cases that would have caught this problem.

This fix will be in v0.20.3 - thanks again for reporting @wezside and thanks as always for your help @jcupitt.

lovell commented 6 years ago

v0.20.3 now available.