Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(modifiers): keep the colorspace of the source in grayscale - #122

Merged
olivervogel merged 1 commit into
Intervention:developfrom
nlemoine:fix/grayscale-keeps-colorspace
Aug 25, 2026
Merged

fix(modifiers): keep the colorspace of the source in grayscale#122
olivervogel merged 1 commit into
Intervention:developfrom
nlemoine:fix/grayscale-keeps-colorspace

Conversation

@nlemoine

Copy link
Copy Markdown
Contributor

GrayscaleModifier converts to B_W and then hardcodes SRGB on the way back, so a cmyk source comes out srgb. Same thing the Imagick driver did in Intervention/image#1516, where olivervogel said grayscale() is just a visual color operation and that the vips driver behaving this way "is actually more of a bug than a feature". This is that change for vips.

On tests/resources/cmyk.jpg, pixel (0,0):

source                     cmyk(5 58 7 0)
before                     rgb(163 163 163)   interpretation srgb
now                        cmyk(0 0 0 40)     interpretation cmyk

The grey lands in the black channel and the ink channels come out at zero. The srgb path is untouched, the second call was already going back to srgb there. Bandcount is unchanged in every direction, 3 stays 3, 4 stays 4, cmyk stays 4, and alpha survives the B_W round trip. I had CanNormalizeBands in here at first and took it out, it never fires on this path and it would have turned a 3 band srgb source into 4.

One thing that is not obvious. colourspace() guesses a sensible source interpretation on the way in, but it takes its target verbatim. So restoring the raw interpretation throws for the ones that are tags rather than color spaces, and those are reachable:

$manager->decode(Vips\Image::black(10, 10, ['bands' => 3]));   // multiband
$manager->decode(Vips\Image::newFromArray([[1,2],[3,4]]));     // matrix

multiband, matrix and rgb all threw a raw Jcupitt\Vips\Exception with the plain restore. They fall back to srgb now, the way they behaved before. yxy, labq and scrgb round-trip fine and are preserved.

The two conversions are also built as one pipeline and committed with a single setNative(). With two calls the B_W intermediate was already committed when the restore threw, so the caller was left holding a 1 band image with its color gone.

Worth knowing, on the encoder side. A cmyk source now stays cmyk through to the encoder, and libvips attaches its built-in cmyk profile:

cmyk.jpg plain encode         2750558 bytes   (the fixture carries a 2.7 MB profile)
after grayscale, before       1803 bytes
after grayscale, now          963682 bytes    strip: true gives 962199

So grayscale() used to double as "drop the cmyk profile", because it laundered the image to srgb. It no longer does. The output is a valid cmyk JPEG, though most browsers will not render one. strip: true does not remove the profile either, JpegEncoder maps strip to ForeignKeep::ICC, which keeps ICC. Tell me if you would rather handle that at the encoder level.

Also worth knowing: grayscale() is no longer idempotent on cmyk. Repeated calls drift the black channel, raw K goes 101, 113, 126, 138, 149, and by the fifth pass a little color comes back. Each pass is another round trip through the cmyk profile. It converged before because it landed on srgb and stayed there.

Three tests, one on the cmyk colorspace and the resulting ink values, one on the tag fallback, one pinning the srgb path and its bandcount. The first fails on develop. The key assertion is a range rather than a fixed value, the exact number depends on the lcms2 the build linked against and I only have one locally. Full suite, phpstan and phpcs pass, the only failure is FontProcessorTest::testBoxSizeTtf which fails on develop too.

Going back to SRGB moved a cmyk source to another colorspace, which is
not what a color operation should do. Remember the interpretation of the
source and return to it instead.

A cmyk source now stays cmyk and the grey lands in the black channel,
cmyk(0 0 0 40) where the source pixel was cmyk(5 58 7 0). An srgb source
is unaffected, the second call was already going back to srgb, and the
bandcount is unchanged in every direction.

colourspace() takes its target verbatim while it guesses a sane source,
so interpretations that are tags rather than color spaces have no route
back. multiband, matrix and rgb sources threw with the plain restore,
they fall back to srgb the way they did before. The two conversions are
also committed in one go now, a failing restore used to leave the caller
holding the b-w intermediate with its bands gone.
@olivervogel

Copy link
Copy Markdown
Member

Thank you.

@olivervogel
olivervogel merged commit 64609e9 into Intervention:develop Aug 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants