Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Output of profile conversion in combination with negate seems off #4045

Open
3 tasks done
adriaanmeuris opened this issue Mar 29, 2024 · 5 comments
Open
3 tasks done
Labels

Comments

@adriaanmeuris
Copy link

Possible bug

Is this a possible bug in a feature of sharp, unrelated to installation?

  • Running npm install sharp completes without error.
  • Running node -e "require('sharp')" completes without error.

If you cannot confirm both of these, please open an installation issue instead.

Are you using the latest version of sharp?

  • I am using the latest version of sharp as reported by npm view sharp dist-tags.latest.

If you cannot confirm this, please upgrade to the latest version and try again before opening an issue.

If you are using another package which depends on a version of sharp that is not the latest, please open an issue against that package instead.

What is the output of running npx envinfo --binaries --system --npmPackages=sharp --npmGlobalPackages=sharp?

  System:
    OS: macOS 14.3.1
    CPU: (12) arm64 Apple M2 Pro
    Memory: 88.27 MB / 16.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 18.18.2 - ~/.nvm/versions/node/v18.18.2/bin/node
    Yarn: 1.22.21 - ~/.nvm/versions/node/v18.18.2/bin/yarn
    npm: 10.3.0 - ~/.nvm/versions/node/v18.18.2/bin/npm
    pnpm: 7.28.0 - ~/Library/pnpm/pnpm
  npmPackages:
    sharp: ^0.33.3 => 0.33.3 

What are the steps to reproduce?

I need to process a CMYK TIFF file (tiger-cmyk-fogra.tif) with an embedded Coated FOGRA39 profile. My goal is to convert this image to the U.S. Web Coated (SWOP) v2 profile and invert the colors, ideally in a single operation. The following code performs these tasks in two steps and achieves the desired result:

// Convert from FOGRA to SWOP and then invert in two steps
await sharp('tiger-cmyk-fogra.tif')
  .withIccProfile('U.S. Web Coated (SWOP) v2.icc')
  .toFile('tiger-cmyk-swop.tif');

await sharp('tiger-cmyk-swop.tif')
  .keepIccProfile()
  .negate()
  .toFile('tiger-cmyk-swop-inverted.tif');

However, when I attempt to combine these operations into a single step using various approaches, none yield the same result as the two-step process. Here are the methods I've tried:

// Attempt 1
await sharp('tiger-cmyk-fogra.tif')
  .withIccProfile(swopPath)
  .negate()
  .toFile('tiger-cmyk-swop-inverted-attempt1.tif');

// Attempt 2
await sharp('tiger-cmyk-fogra.tif')
  .toColourspace('cmyk')
  .withIccProfile(swopPath)
  .negate()
  .toFile('tiger-cmyk-swop-inverted-attempt2.tif');

// Attempt 3
await sharp('tiger-cmyk-fogra.tif')
  .pipelineColourspace('cmyk')
  .withIccProfile(swopPath)
  .negate()
  .toFile('tiger-cmyk-swop-inverted-attempt3.tif');

// Attempt 4
await sharp('tiger-cmyk-fogra.tif')
  .toColourspace('cmyk')
  .pipelineColourspace('cmyk')
  .withIccProfile(swopPath)
  .negate()
  .toFile('tiger-cmyk-swop-inverted-attempt4.tif');

Each attempt successfully inverts the image and applies the SWOP profile but does not match the visual result of the two-step operation:

2-step operation attempt 1 attemp 2 attempt 3 attempt 4
tiger-cmyk-swop-inverted tiger-cmyk-swop-inverted1 tiger-cmyk-swop-inverted2 tiger-cmyk-swop-inverted3 tiger-cmyk-swop-inverted4

When viewed in Photoshop (after inverting back):

2-step operation attempt 1 attemp 2 attempt 3 attempt 4
tiger-cmyk-swop-inverted tiger-cmyk-swop-inverted1 tiger-cmyk-swop-inverted2 tiger-cmyk-swop-inverted3 tiger-cmyk-swop-inverted4

What is the expected behaviour?

To match the output when converting in 2 steps.

I guess attempt 4 has the correct code as it ensures all operations will use this colourspace before converting to the output colourspace, but the resulting image data seems off.

Question

How can I achieve the same result as the two-step operation in a single operation?

@lovell
Copy link
Owner

lovell commented Apr 13, 2024

My best guess would be that this relates to (a lack of) gamut.

Does the following change help? It should ensure the input CMYK profile is respected when present, preventing the narrower, built-in CMYK profile being introduced and therefore allow the removal of pipelineColourspace(), which is hopefully a bit simpler (and matches your "Attempt 1" example).

--- a/src/pipeline.cc
+++ b/src/pipeline.cc
@@ -348,6 +348,7 @@ class PipelineWorker : public Napi::AsyncWorker {
         baton->colourspacePipeline != VIPS_INTERPRETATION_CMYK
       ) {
         image = image.icc_transform(processingProfile, VImage::option()
+          ->set("embedded", TRUE)
           ->set("input_profile", "cmyk")
           ->set("intent", VIPS_INTENT_PERCEPTUAL));
       }

It's also possible that Photoshop's idea of inverting CMYK values is different to libvips. In addition sharp always tells the underlying lcms to use perceptual rendering intent, which might differ from Photoshop's treatment.

@adriaanmeuris
Copy link
Author

Thanks for the suggestion. I've did some tests without negating to see if the built-in CMYK profile is introduced by comparing with the output from vips.

Attached a 1000x1000 tiff file with all pixels set to 0 / 100 / 100 / 0 with embedded profile Coated Fogra39.

# Get source pixel value (output: 0 255 255 0)
vips getpoint fogra.tif 0 0

# Convert to U.S. Web Coated (SWOP) v2
vips icc_transform fogra.tif swop.tif swop.icc

# Get destination pixel valuex (output: 10 255 255 1)
vips getpoint swop.tif 0 0

The destination pixels match exactly those of Photoshop when converting to the SWOP profile using relative colorimetric intent and black point compensation turned off (= the defaults of vips icc_transform).

I did the same icc transform using sharp with every combination oftoColourspace and pipelineColourspace:

await sharp('fogra.tif').toColourspace('cmyk').pipelineColourspace('cmyk').withIccProfile('swop.icc').toFile('swop1.tif');
await sharp('fogra.tif').toColourspace('cmyk').withIccProfile('swop.icc').toFile('swop2.tif');
await sharp('fogra.tif').pipelineColourspace('cmyk').withIccProfile('swop.icc').toFile('swop3.tif');
await sharp('fogra.tif').withIccProfile('swop.icc').toFile('swop4.tif');

Here's the output pixel values:

Test Output
vips icc_transform fogra.tif swop.tif swop.icc && vips getpoint swop.tif 0 0 10 255 255 1
vips getpoint swop1.tif 0 0 25 255 255 4
vips getpoint swop2.tif 0 0 23 244 238 3
vips getpoint swop3.tif 0 0 24 251 247 3
vips getpoint swop4.tif 0 0 25 255 255 4

After applying your code change in pipeline.cc, I see no change in these output values so I suspect the built-in CMYK profile is still being introduced somehow.

Happy to test any other suggestion.

@lovell
Copy link
Owner

lovell commented Apr 16, 2024

Thank you, this is useful info, and points to the difference being rendering intent:

$ vips icc_transform fogra.tif swop.tif swop.icc --intent=perceptual
$ vips getpoint swop.tif 0 0
25 255 255 4 

sharp uses perceptual intent to try to avoid too much visually-obvious gamut clipping, but this can come at the cost of saturation levels, which from these examples appears to adversely affect CMYK-to-CMYK transformations.

Maybe we should switch to always using relative intent when transforming CMYK images?

@roeldl
Copy link

roeldl commented Apr 16, 2024

Thank you for taking a look at this. I have tested the initial bug and played around in pipeline.cc by moving the image.icc_transform which is triggered by the withIccProfile parameter to the beginning of the pipeline.

It seems that when the conversion happens before the negate that the right result is produced.

So in the case below, when withIccProfile is set on the sharp instance before the negate. I think the conversion should happen before the negate operation is executed.

await sharp('tiger-cmyk-fogra.tif')
  .toColourspace('cmyk')
  .pipelineColourspace('cmyk')
  .withIccProfile(swopPath)
  .negate()
  .toFile('tiger-cmyk-swop-inverted-attempt4.tif');

Place in the pipeline where the conversion to ICC happens:

if (!baton->withIccProfile.empty()) {

Negate works when moved to:

I hope this helps! Thanks again for your help.

@adriaanmeuris
Copy link
Author

@lovell Thanks for clarifying the difference in values due to the perceptual intent being used as. Great suggestion to use relative intent by default when transforming CMYK images, as it would match the behaviour from vips.

Alternatively, providing an optional parameter for withIccProfile could offer users the flexibility to select their preferred intent, though that might extend beyond the scope of this issue.

Getting back to the negate issue, I've reviewed @roeldl 's feedback. I initially assumed that the chained method calls from Javascript would correspond directly to their execution order in the C pipeline. I understand this is not the case, which explains why colors are off, as the colorspace transformation is applied to already negated image data.

I tried moving the color conversion to the top of the pipeline as suggested by @roeldl. It generates the correct output, but it causes many tests to fail.

So, I tried moving the implementation of negate to the bottom of the pipeline, after applying the output ICC profile:

// Apply output ICC profile
if (!baton->withIccProfile.empty()) {
  // ... implementation
}

// Negate the colours in the image
if (baton->negate) {
  image = sharp::Negate(image, baton->negateAlpha);
}

This produces the right result (see below + inverted image in Photoshop to compare with the original file)

ICC transform + negate after pipeline change Inverted in Photoshop
output output-inverted

I do see one test failing, though: negate (png, trans).

Expected Resulting file (RGB negated, alpha is preserved)
negate-trans negate-result

Do you think it makes sense to move negate to the bottom of the pipeline, rather than working with negated colours throughout the pipeline?

I appreciate any directions on correcting the failing test if this solution fits, or other recommendations you might have for this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants