Improve tint luminance with weighting function (#3859)

Co-authored-by: John Cupitt <jcupitt@gmail.com>
This commit is contained in:
Lovell Fuller 2023-11-19 13:19:34 +00:00 committed by GitHub
parent 139e4b9f65
commit 3f7313d031
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 70 additions and 55 deletions

View File

@ -1,7 +1,7 @@
## tint ## tint
> tint(rgb) ⇒ <code>Sharp</code> > tint(tint) ⇒ <code>Sharp</code>
Tint the image using the provided chroma while preserving the image luminance. Tint the image using the provided colour.
An alpha channel may be present and will be unchanged by the operation. An alpha channel may be present and will be unchanged by the operation.
@ -12,7 +12,7 @@ An alpha channel may be present and will be unchanged by the operation.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| rgb | <code>string</code> \| <code>Object</code> | parsed by the [color](https://www.npmjs.org/package/color) module to extract chroma values. | | tint | <code>String</code> \| <code>Object</code> | Parsed by the [color](https://www.npmjs.org/package/color) module. |
**Example** **Example**
```js ```js

View File

@ -20,6 +20,10 @@ Requires libvips v8.15.0
* Options for `trim` operation must be an Object, add new `lineArt` option. * Options for `trim` operation must be an Object, add new `lineArt` option.
[#2363](https://github.com/lovell/sharp/issues/2363) [#2363](https://github.com/lovell/sharp/issues/2363)
* Improve luminance of `tint` operation with weighting function.
[#3338](https://github.com/lovell/sharp/issues/3338)
[@jcupitt](https://github.com/jcupitt)
* Ensure all `Error` objects contain a `stack` property. * Ensure all `Error` objects contain a `stack` property.
[#3653](https://github.com/lovell/sharp/issues/3653) [#3653](https://github.com/lovell/sharp/issues/3653)

File diff suppressed because one or more lines are too long

View File

@ -19,7 +19,7 @@ const colourspace = {
}; };
/** /**
* Tint the image using the provided chroma while preserving the image luminance. * Tint the image using the provided colour.
* An alpha channel may be present and will be unchanged by the operation. * An alpha channel may be present and will be unchanged by the operation.
* *
* @example * @example
@ -27,14 +27,12 @@ const colourspace = {
* .tint({ r: 255, g: 240, b: 16 }) * .tint({ r: 255, g: 240, b: 16 })
* .toBuffer(); * .toBuffer();
* *
* @param {string|Object} rgb - parsed by the [color](https://www.npmjs.org/package/color) module to extract chroma values. * @param {string|Object} tint - Parsed by the [color](https://www.npmjs.org/package/color) module.
* @returns {Sharp} * @returns {Sharp}
* @throws {Error} Invalid parameter * @throws {Error} Invalid parameter
*/ */
function tint (rgb) { function tint (tint) {
const colour = color(rgb); this._setBackgroundColourOption('tint', tint);
this.options.tintA = colour.a();
this.options.tintB = colour.b();
return this; return this;
} }

View File

@ -212,8 +212,7 @@ const Sharp = function (input, options) {
kernel: 'lanczos3', kernel: 'lanczos3',
fastShrinkOnLoad: true, fastShrinkOnLoad: true,
// operations // operations
tintA: 128, tint: [-1, 0, 0, 0],
tintB: 128,
flatten: false, flatten: false,
flattenBackground: [0, 0, 0], flattenBackground: [0, 0, 0],
unflatten: false, unflatten: false,

6
lib/index.d.ts vendored
View File

@ -239,12 +239,12 @@ declare namespace sharp {
//#region Color functions //#region Color functions
/** /**
* Tint the image using the provided chroma while preserving the image luminance. * Tint the image using the provided colour.
* An alpha channel may be present and will be unchanged by the operation. * An alpha channel may be present and will be unchanged by the operation.
* @param rgb Parsed by the color module to extract chroma values. * @param tint Parsed by the color module.
* @returns A sharp instance that can be used to chain operations * @returns A sharp instance that can be used to chain operations
*/ */
tint(rgb: Color): Sharp; tint(tint: Color): Sharp;
/** /**
* Convert to 8-bit greyscale; 256 shades of grey. * Convert to 8-bit greyscale; 256 shades of grey.

View File

@ -16,30 +16,44 @@ using vips::VError;
namespace sharp { namespace sharp {
/* /*
* Tint an image using the specified chroma, preserving the original image luminance * Tint an image using the provided RGB.
*/ */
VImage Tint(VImage image, double const a, double const b) { VImage Tint(VImage image, std::vector<double> const tint) {
// Get original colourspace std::vector<double> const tintLab = (VImage::black(1, 1) + tint)
.colourspace(VIPS_INTERPRETATION_LAB, VImage::option()->set("source_space", VIPS_INTERPRETATION_sRGB))
.getpoint(0, 0);
// LAB identity function
VImage identityLab = VImage::identity(VImage::option()->set("bands", 3))
.colourspace(VIPS_INTERPRETATION_LAB, VImage::option()->set("source_space", VIPS_INTERPRETATION_sRGB));
// Scale luminance range, 0.0 to 1.0
VImage l = identityLab[0] / 100;
// Weighting functions
VImage weightL = 1.0 - 4.0 * ((l - 0.5) * (l - 0.5));
VImage weightAB = (weightL * tintLab).extract_band(1, VImage::option()->set("n", 2));
identityLab = identityLab[0].bandjoin(weightAB);
// Convert lookup table to sRGB
VImage lut = identityLab.colourspace(VIPS_INTERPRETATION_sRGB,
VImage::option()->set("source_space", VIPS_INTERPRETATION_LAB));
// Original colourspace
VipsInterpretation typeBeforeTint = image.interpretation(); VipsInterpretation typeBeforeTint = image.interpretation();
if (typeBeforeTint == VIPS_INTERPRETATION_RGB) { if (typeBeforeTint == VIPS_INTERPRETATION_RGB) {
typeBeforeTint = VIPS_INTERPRETATION_sRGB; typeBeforeTint = VIPS_INTERPRETATION_sRGB;
} }
// Extract luminance // Apply lookup table
VImage luminance = image.colourspace(VIPS_INTERPRETATION_LAB)[0];
// Create the tinted version by combining the L from the original and the chroma from the tint
std::vector<double> chroma {a, b};
VImage tinted = luminance
.bandjoin(chroma)
.copy(VImage::option()->set("interpretation", VIPS_INTERPRETATION_LAB))
.colourspace(typeBeforeTint);
// Attach original alpha channel, if any
if (HasAlpha(image)) { if (HasAlpha(image)) {
// Extract original alpha channel
VImage alpha = image[image.bands() - 1]; VImage alpha = image[image.bands() - 1];
// Join alpha channel to normalised image image = RemoveAlpha(image)
tinted = tinted.bandjoin(alpha); .colourspace(VIPS_INTERPRETATION_B_W)
.maplut(lut)
.colourspace(typeBeforeTint)
.bandjoin(alpha);
} else {
image = image
.colourspace(VIPS_INTERPRETATION_B_W)
.maplut(lut)
.colourspace(typeBeforeTint);
} }
return tinted; return image;
} }
/* /*

View File

@ -15,9 +15,9 @@ using vips::VImage;
namespace sharp { namespace sharp {
/* /*
* Tint an image using the specified chroma, preserving the original image luminance * Tint an image using the provided RGB.
*/ */
VImage Tint(VImage image, double const a, double const b); VImage Tint(VImage image, std::vector<double> const tint);
/* /*
* Stretch luminance to cover full dynamic range. * Stretch luminance to cover full dynamic range.

View File

@ -736,8 +736,8 @@ class PipelineWorker : public Napi::AsyncWorker {
} }
// Tint the image // Tint the image
if (baton->tintA < 128.0 || baton->tintB < 128.0) { if (baton->tint[0] >= 0.0) {
image = sharp::Tint(image, baton->tintA, baton->tintB); image = sharp::Tint(image, baton->tint);
} }
// Remove alpha channel, if any // Remove alpha channel, if any
@ -1527,8 +1527,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
baton->normalise = sharp::AttrAsBool(options, "normalise"); baton->normalise = sharp::AttrAsBool(options, "normalise");
baton->normaliseLower = sharp::AttrAsUint32(options, "normaliseLower"); baton->normaliseLower = sharp::AttrAsUint32(options, "normaliseLower");
baton->normaliseUpper = sharp::AttrAsUint32(options, "normaliseUpper"); baton->normaliseUpper = sharp::AttrAsUint32(options, "normaliseUpper");
baton->tintA = sharp::AttrAsDouble(options, "tintA"); baton->tint = sharp::AttrAsVectorOfDouble(options, "tint");
baton->tintB = sharp::AttrAsDouble(options, "tintB");
baton->claheWidth = sharp::AttrAsUint32(options, "claheWidth"); baton->claheWidth = sharp::AttrAsUint32(options, "claheWidth");
baton->claheHeight = sharp::AttrAsUint32(options, "claheHeight"); baton->claheHeight = sharp::AttrAsUint32(options, "claheHeight");
baton->claheMaxSlope = sharp::AttrAsUint32(options, "claheMaxSlope"); baton->claheMaxSlope = sharp::AttrAsUint32(options, "claheMaxSlope");

View File

@ -69,8 +69,7 @@ struct PipelineBaton {
bool premultiplied; bool premultiplied;
bool tileCentre; bool tileCentre;
bool fastShrinkOnLoad; bool fastShrinkOnLoad;
double tintA; std::vector<double> tint;
double tintB;
bool flatten; bool flatten;
std::vector<double> flattenBackground; std::vector<double> flattenBackground;
bool unflatten; bool unflatten;
@ -239,8 +238,7 @@ struct PipelineBaton {
attentionX(0), attentionX(0),
attentionY(0), attentionY(0),
premultiplied(false), premultiplied(false),
tintA(128.0), tint{ -1.0, 0.0, 0.0, 0.0 },
tintB(128.0),
flatten(false), flatten(false),
flattenBackground{ 0.0, 0.0, 0.0 }, flattenBackground{ 0.0, 0.0, 0.0 },
unflatten(false), unflatten(false),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -8,16 +8,19 @@ const assert = require('assert');
const sharp = require('../../'); const sharp = require('../../');
const fixtures = require('../fixtures'); const fixtures = require('../fixtures');
// Allow for small rounding differences between platforms
const maxDistance = 6;
describe('Tint', function () { describe('Tint', function () {
it('tints rgb image red', function (done) { it('tints rgb image red', function (done) {
const output = fixtures.path('output.tint-red.jpg'); const output = fixtures.path('output.tint-red.jpg');
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#FF0000') .tint('#FF0000')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(true, info.size > 0); assert.strictEqual(true, info.size > 0);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-red.jpg'), 18); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-red.jpg'), maxDistance);
done(); done();
}); });
}); });
@ -25,12 +28,12 @@ describe('Tint', function () {
it('tints rgb image green', function (done) { it('tints rgb image green', function (done) {
const output = fixtures.path('output.tint-green.jpg'); const output = fixtures.path('output.tint-green.jpg');
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#00FF00') .tint('#00FF00')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(true, info.size > 0); assert.strictEqual(true, info.size > 0);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-green.jpg'), 27); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-green.jpg'), maxDistance);
done(); done();
}); });
}); });
@ -38,12 +41,12 @@ describe('Tint', function () {
it('tints rgb image blue', function (done) { it('tints rgb image blue', function (done) {
const output = fixtures.path('output.tint-blue.jpg'); const output = fixtures.path('output.tint-blue.jpg');
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#0000FF') .tint('#0000FF')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(true, info.size > 0); assert.strictEqual(true, info.size > 0);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-blue.jpg'), 14); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-blue.jpg'), maxDistance);
done(); done();
}); });
}); });
@ -51,13 +54,13 @@ describe('Tint', function () {
it('tints rgb image with sepia tone', function (done) { it('tints rgb image with sepia tone', function (done) {
const output = fixtures.path('output.tint-sepia-hex.jpg'); const output = fixtures.path('output.tint-sepia-hex.jpg');
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#704214') .tint('#704214')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(320, info.width); assert.strictEqual(320, info.width);
assert.strictEqual(240, info.height); assert.strictEqual(240, info.height);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-sepia.jpg'), 10); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-sepia.jpg'), maxDistance);
done(); done();
}); });
}); });
@ -65,13 +68,13 @@ describe('Tint', function () {
it('tints rgb image with sepia tone with rgb colour', function (done) { it('tints rgb image with sepia tone with rgb colour', function (done) {
const output = fixtures.path('output.tint-sepia-rgb.jpg'); const output = fixtures.path('output.tint-sepia-rgb.jpg');
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint([112, 66, 20]) .tint([112, 66, 20])
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(320, info.width); assert.strictEqual(320, info.width);
assert.strictEqual(240, info.height); assert.strictEqual(240, info.height);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-sepia.jpg'), 10); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-sepia.jpg'), maxDistance);
done(); done();
}); });
}); });
@ -79,13 +82,13 @@ describe('Tint', function () {
it('tints rgb image with alpha channel', function (done) { it('tints rgb image with alpha channel', function (done) {
const output = fixtures.path('output.tint-alpha.png'); const output = fixtures.path('output.tint-alpha.png');
sharp(fixtures.inputPngRGBWithAlpha) sharp(fixtures.inputPngRGBWithAlpha)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#704214') .tint('#704214')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(320, info.width); assert.strictEqual(320, info.width);
assert.strictEqual(240, info.height); assert.strictEqual(240, info.height);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-alpha.png'), 10); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-alpha.png'), maxDistance);
done(); done();
}); });
}); });
@ -93,12 +96,12 @@ describe('Tint', function () {
it('tints cmyk image red', function (done) { it('tints cmyk image red', function (done) {
const output = fixtures.path('output.tint-cmyk.jpg'); const output = fixtures.path('output.tint-cmyk.jpg');
sharp(fixtures.inputJpgWithCmykProfile) sharp(fixtures.inputJpgWithCmykProfile)
.resize(320, 240, { fastShrinkOnLoad: false }) .resize(320, 240)
.tint('#FF0000') .tint('#FF0000')
.toFile(output, function (err, info) { .toFile(output, function (err, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(true, info.size > 0); assert.strictEqual(true, info.size > 0);
fixtures.assertMaxColourDistance(output, fixtures.expected('tint-cmyk.jpg'), 15); fixtures.assertMaxColourDistance(output, fixtures.expected('tint-cmyk.jpg'), maxDistance);
done(); done();
}); });
}); });