Fix Imagick grayscale flattening saturated colors - #1516
Conversation
olivervogel
left a comment
There was a problem hiding this comment.
Thanks for the fix. I have one question.
| } | ||
|
|
||
| // return to srgb colorspace with grayscale image | ||
| $result = $frame->native()->transformImageColorspace(Imagick::COLORSPACE_SRGB); |
There was a problem hiding this comment.
Shouldn't the image be returned to its original color space instead of hard-coded RGB?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
721de39 to
b327f8b
Compare
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.
0b1a9dd to
e84775b
Compare
|
Awesome. Thank you! |
GrayscaleModifieron 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():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()toCOLORSPACE_GRAY, then back toCOLORSPACE_SRGB. The vips driver already does the same withInterpretation::B_WthenInterpretation::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 reportsRgb\Colorspace. Encoding to png/jpeg/gif/webp is unchanged.Tests:
testColorChangeonly assertedisGrayscale(), and128,128,128satisfies that, so nothing caught it. I addedtestSaturatedColorsKeepDistinctBrightnesson both drivers, using the existingblocks.pngfixture. 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 withFailed asserting that 128 is less than 128.