Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions src/pipeline.cc
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,20 @@ class PipelineWorker : public Napi::AsyncWorker {
}
}
VipsAccess access = baton->input->access;
image = sharp::EnsureColourspace(image, baton->colourspacePipeline);
// A device-independent colourspace is reached through the ICC profile connection space, either Lab or XYZ
bool const labPcs =
baton->colourspacePipeline == VIPS_INTERPRETATION_LAB ||
baton->colourspacePipeline == VIPS_INTERPRETATION_LABS ||
baton->colourspacePipeline == VIPS_INTERPRETATION_LCH;
bool const xyzPcs =
baton->colourspacePipeline == VIPS_INTERPRETATION_scRGB ||
baton->colourspacePipeline == VIPS_INTERPRETATION_XYZ ||
baton->colourspacePipeline == VIPS_INTERPRETATION_YXY;
// lcms needs 8/16-bit device values, so the conversion must wait for the import
bool const deferColourspace = sharp::HasProfile(image) && (labPcs || xyzPcs);
if (!deferColourspace) {
image = sharp::EnsureColourspace(image, baton->colourspacePipeline);
}

int nPages = baton->input->pages;
if (nPages == -1) {
Expand Down Expand Up @@ -348,6 +361,7 @@ class PipelineWorker : public Napi::AsyncWorker {
baton->input->ignoreIcc = true;
}
char const *processingProfile = image.interpretation() == VIPS_INTERPRETATION_RGB16 ? "p3" : "srgb";
bool importedToPcs = false;
if (
sharp::HasProfile(image) &&
image.interpretation() != VIPS_INTERPRETATION_LABS &&
Expand All @@ -357,10 +371,20 @@ class PipelineWorker : public Napi::AsyncWorker {
) {
// Convert to sRGB/P3 using embedded profile
try {
image = image.icc_transform(processingProfile, VImage::option()
->set("embedded", true)
->set("depth", sharp::Is16Bit(image.interpretation()) ? 16 : 8)
->set("intent", VIPS_INTENT_PERCEPTUAL));
if (deferColourspace &&
(image.format() == VIPS_FORMAT_UCHAR || image.format() == VIPS_FORMAT_USHORT)) {
// XYZ PCS is linear and device-independent, so colour management and linearisation are a single operation
image = image.icc_import(VImage::option()
->set("embedded", true)
->set("pcs", labPcs ? VIPS_PCS_LAB : VIPS_PCS_XYZ)
->set("intent", VIPS_INTENT_PERCEPTUAL));
importedToPcs = true;
} else {
image = image.icc_transform(processingProfile, VImage::option()
->set("embedded", true)
->set("depth", sharp::Is16Bit(image.interpretation()) ? 16 : 8)
->set("intent", VIPS_INTENT_PERCEPTUAL));
}
} catch(...) {
sharp::VipsWarningCallback(nullptr, G_LOG_LEVEL_WARNING, "Invalid embedded profile", nullptr);
}
Expand All @@ -373,6 +397,10 @@ class PipelineWorker : public Napi::AsyncWorker {
->set("intent", VIPS_INTENT_PERCEPTUAL));
}

if (deferColourspace) {
image = sharp::EnsureColourspace(image, baton->colourspacePipeline);
}

// Flatten image to remove alpha channel
if (baton->flatten && image.has_alpha()) {
image = sharp::Flatten(image, baton->flattenBackground);
Expand Down Expand Up @@ -855,6 +883,19 @@ class PipelineWorker : public Napi::AsyncWorker {
image = sharp::EnsureAlpha(image, baton->ensureAlpha);
}

// Leave PCS before the output colourspace is applied, which would gamut-clip
if (importedToPcs) {
try {
image = image.colourspace(VIPS_INTERPRETATION_XYZ).icc_export(VImage::option()
->set("output_profile", baton->withIccProfile.empty()
? processingProfile
: const_cast<char*>(baton->withIccProfile.data()))
->set("intent", VIPS_INTENT_PERCEPTUAL));
} catch(...) {
sharp::VipsWarningCallback(nullptr, G_LOG_LEVEL_WARNING, "Invalid profile", nullptr);
}
}

// Ensure output colour space
if (sharp::Is16Bit(image.interpretation())) {
image = image.cast(VIPS_FORMAT_USHORT);
Expand Down Expand Up @@ -888,7 +929,7 @@ class PipelineWorker : public Napi::AsyncWorker {
}

// Apply output ICC profile
if (!baton->withIccProfile.empty()) {
if (!importedToPcs && !baton->withIccProfile.empty()) {
try {
image = image.icc_transform(const_cast<char*>(baton->withIccProfile.data()), VImage::option()
->set("input_profile", processingProfile)
Expand Down
88 changes: 88 additions & 0 deletions test/unit/colourspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,94 @@ suite('Colour space conversion', () => {
t.assert.strictEqual(b, 34);
});

suite('Device-independent pipeline colourspace', () => {
const size = 64;

// Alternating black and white pixels, so every reduced pixel averages the two
const checkerboard = () => {
const data = Buffer.alloc(size * size * 3);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 3;
data.fill((x + y) % 2 ? 255 : 0, i, i + 3);
}
}
return sharp(data, { raw: { width: size, height: size, channels: 3 } })
.png()
.withIccProfile('p3')
.toBuffer();
};

const swatch = (profile) => {
const data = Buffer.alloc(size * size * 3);
for (let i = 0; i < data.length; i += 3) {
data.set([200, 60, 30], i);
}
const image = sharp(data, { raw: { width: size, height: size, channels: 3 } }).png();
return (profile ? image.withIccProfile(profile) : image).toBuffer();
};

for (const [space, expected] of [['srgb', 128], ['rgb16', 128], ['scrgb', 188], ['xyz', 188]]) {
test(`Reduces a profiled image in ${space}`, async (t) => {
t.plan(1);
const { data } = await sharp(await checkerboard())
.pipelineColourspace(space)
.resize(8)
.raw()
.toBuffer({ resolveWithObject: true });
const mean = data.reduce((sum, value) => sum + value, 0) / data.length;
t.assert.ok(Math.abs(mean - expected) <= 1, `expected ~${expected}, saw ${mean.toFixed(2)}`);
});
}

for (const space of ['scrgb', 'xyz', 'yxy', 'lab', 'labs', 'lch']) {
test(`Applies the embedded profile in ${space}`, async (t) => {
t.plan(1);
const render = async (profile) => [...await sharp(await swatch(profile))
.pipelineColourspace(space)
.raw()
.toBuffer()].slice(0, 3);
const p3 = await render('p3');
const srgb = await render();
t.assert.ok(p3.every((value, i) => Math.abs(value - srgb[i]) <= 5), `p3 ${p3}, sRGB ${srgb}`);
});
}

test('Keeps the P3 working profile in rgb16', async (t) => {
t.plan(3);
const [r, g, b] = await sharp(fixtures.inputPngP3)
.pipelineColourspace('rgb16')
.withIccProfile('p3')
.raw()
.toBuffer();
t.assert.strictEqual(r, 242);
t.assert.strictEqual(g, 0);
t.assert.strictEqual(b, 0);
});

test('Ignores the input profile regardless of pipeline colourspace', async (t) => {
t.plan(1);
const render = async (space) => [...await sharp(fixtures.inputPngP3, { ignoreIcc: true })
.pipelineColourspace(space)
.withIccProfile('srgb') // output profile differing from the embedded one, so the export is not an identity
.raw()
.toBuffer()].slice(0, 3);
t.assert.deepStrictEqual(await render('scrgb'), await render('srgb'));
});

test('Passthrough P3 without gamut loss', async (t) => {
t.plan(3);
const [r, g, b] = await sharp(fixtures.inputPngP3)
.pipelineColourspace('scrgb')
.withIccProfile('p3')
.raw()
.toBuffer();
t.assert.strictEqual(r, 241);
t.assert.strictEqual(g, 0);
t.assert.strictEqual(b, 0);
});
});

test('Invalid pipelineColourspace input', (t) => {
t.plan(1);
t.assert.throws(() => {
Expand Down