Skip to content

Fix Imagick grayscale flattening saturated colors - #1516

Merged
olivervogel merged 2 commits into
Intervention:developfrom
nlemoine:fix/imagick-grayscale-luma
Aug 25, 2026
Merged

Fix Imagick grayscale flattening saturated colors#1516
olivervogel merged 2 commits into
Intervention:developfrom
nlemoine:fix/imagick-grayscale-luma

Conversation

@nlemoine

@nlemoine nlemoine commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

GrayscaleModifier on the Imagick driver turns every saturated color into the same mid grey. All the tonal information is gone.

Repro, on a 200x200 PNG with four flat quadrants (red, green, blue, yellow), sampling each quadrant centre after ->grayscale():

driver red green blue yellow
GD 76 149 29 225
Imagick (before) 128 128 128 128
Imagick (after) 54 182 18 237

Cause: the modifier calls modulateImage(100, 0, 100), which sets HSL saturation to zero. HSL lightness of a fully saturated color is (max+min)/2 = 127.5, so any saturated hue lands on 128 no matter how bright it looks.

Fix: transformImageColorspace() to COLORSPACE_GRAY, then back to COLORSPACE_SRGB. The vips driver already does the same with Interpretation::B_W then Interpretation::SRGB.

I first tried a color matrix with Rec. 601 coefficients, it would have matched GD almost exactly. But it breaks on CMYK sources. The matrix hits the C, M and Y channels, so the tonality comes out inverted, red becomes light and yellow becomes dark. The colorspace transform handles CMYK fine.

Other things I checked: alpha and semi transparency survive, animated GIFs get every frame converted, colorspace() still reports Rgb\Colorspace. Encoding to png/jpeg/gif/webp is unchanged.

Tests: testColorChange only asserted isGrayscale(), and 128,128,128 satisfies that, so nothing caught it. I added testSaturatedColorsKeepDistinctBrightness on both drivers, using the existing blocks.png fixture. It checks that pure blue, red and green stay distinct and in the right order (blue darkest, green brightest). That ordering should hold for any luma based conversion, so the same test works for GD and Imagick. On the old Imagick code it fails with Failed asserting that 128 is less than 128.

@olivervogel olivervogel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix. I have one question.

}

// return to srgb colorspace with grayscale image
$result = $frame->native()->transformImageColorspace(Imagick::COLORSPACE_SRGB);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the image be returned to its original color space instead of hard-coded RGB?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and it does work. transformImageColorspace(GRAY) then back to the source colorspace gives the same greys, on a CMYK gradient I get 54,42,30,18 either way. The CMYK result is k-only (C=M=Y=0, K=201..237), which is what you would want for print.

Two things made me keep sRGB.

First, it is not a change this PR introduces. modulateImage(100, 0, 100) also left a CMYK source in sRGB, I checked. So the old code dropped the colorspace too, it just also flattened the tones.

Second, compositing. On a k-only CMYK base an sRGB overlay goes in wrong, because compositeImage() does not convert. Inserting #ff00ff on the greyscaled image:

base sRGB (this PR)     watermark reads back rgb(255,0,255)
base CMYK (restored)    watermark reads back rgb(0,255,0)

So ->grayscale()->insert(...) or ->text(...) would break for CMYK sources. NativeObjectDecoder already converts GRAY to sRGB on decode for the same kind of reason, the comment there says it is to avoid losing color information when something is placed into the image.

Also the vips driver lands on sRGB as well (Interpretation::B_W then Interpretation::SRGB), and GD has no colorspace at all, so the three drivers agree as it is.

That said, preserving the colorspace is a defensible call if you prefer it, grayscale() is a color operation and setColorspace() is there for the rest. My guess is it would need doing on the vips driver too, and the compositing thing would need a look. Happy to change it here if you want, just tell me which way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree with you on this. grayscale() is just a visual color operation. I think the fact that the VIPS driver behaves the same way is actually more of a bug than a feature. Feel free to update this. I would be happy about that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The second transform restores the colorspace the frame came in with, read per frame because getImageColorspace() only reports whichever frame the iterator sits on.

On cmyk.jpg, pixel (0,0):

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

sRGB path untouched. Alpha survives the GRAY round trip, checked on sRGB, CMYK, GRAY, HSL, HSB and YCbCr.

Same change on the vips driver: Intervention/image-driver-vips#122.

One thing you should know: ->grayscale()->insert() on a CMYK source now composites wrong, the watermark comes back yellow. InsertModifier calls compositeImage() without converting. Not new though, decode(cmyk)->insert() is already wrong on develop with no grayscale at all. The old grayscale was laundering CMYK to sRGB and hiding it. Happy to look at that separately.

The Imagick GrayscaleModifier used modulateImage(100, 0, 100), which sets
HSL saturation to zero. HSL lightness of any fully saturated color is
(max+min)/2, so every saturated hue ended up on the same mid grey and all
tonal information was lost.

Use transformImageColorspace() to GRAY and back to sRGB instead, the same
approach the vips driver already uses. This produces a luma based grey and
also handles CMYK sources correctly.

The existing tests only asserted that the result is grayscale, which the
flat mid grey satisfied. Added a test on both drivers checking that pure
blue, red and green keep a distinct and correctly ordered brightness.
@nlemoine
nlemoine force-pushed the fix/imagick-grayscale-luma branch from 721de39 to b327f8b Compare August 24, 2026 13:30
Going back to sRGB moved a cmyk source to another colorspace, which is
not what a color operation should do. Read the colorspace of each frame
before the conversion and return to it.

A cmyk source now stays cmyk and the grey lands in the black channel,
cmyk(0 0 0 43) where the source pixel was cmyk(5 58 7 0). An sRGB source
is unaffected, the second transform was already going back to sRGB.

The frame test uses a sequence whose frames deliberately do not share a
colorspace. Built on identical frames it passed against a hardcoded sRGB
and against reading the colorspace once outside the loop, so it pinned
nothing.
@olivervogel

Copy link
Copy Markdown
Member

Awesome. Thank you!

@olivervogel
olivervogel merged commit 60abdcb 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.

2 participants