From fa56b3d2558bc02ba9fad13892f2c87a4c4f6987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarkko=20P=C3=B6yry?= Date: Sat, 28 Jan 2017 22:04:49 +0200 Subject: [PATCH 1/2] Add tests for CMS transform auxiliary channel preservation. See bug #1662. --- Tests/test_imagecms.py | 93 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 136590667..a661ab24d 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -1,7 +1,7 @@ from helper import unittest, PillowTestCase, hopper import datetime -from PIL import Image +from PIL import Image, ImageMode from io import BytesIO import os @@ -332,6 +332,97 @@ class TestImageCms(PillowTestCase): with self.assertRaises(TypeError): ImageCms.ImageCmsProfile(1).tobytes() + def assert_aux_channel_preserved(self, mode, transform_in_place, preserved_channel): + def create_test_image(): + # set up test image with something interesting in the tested aux + # channel. + nine_grid_deltas = [ + (-1, -1), (-1, 0), (-1, 1), + ( 0, -1), ( 0, 0), ( 0, 1), + ( 1, -1), ( 1, 0), ( 1, 1), + ] + chans = [] + bands = ImageMode.getmode(mode).bands + for band_ndx, band in enumerate(bands): + channel_type = 'L' # 8-bit unorm + channel_pattern = hopper(channel_type) + + # paste pattern with varying offsets to avoid correlation + # potentially hiding some bugs (like channels getting mixed). + paste_offset = ( + int(band_ndx / float(len(bands)) * channel_pattern.size[0]), + int(band_ndx / float(len(bands) * 2) * channel_pattern.size[1]) + ) + channel_data = Image.new(channel_type, channel_pattern.size) + for delta in nine_grid_deltas: + channel_data.paste(channel_pattern, tuple(paste_offset[c] + delta[c]*channel_pattern.size[c] for c in range(2))) + chans.append(channel_data) + return Image.merge(mode, chans) + + source_image = create_test_image() + preserved_channel_ndx = source_image.getbands().index(preserved_channel) + source_image_aux = source_image.split()[preserved_channel_ndx] + + # create some transform, it doesn't matter which one + source_profile = ImageCms.createProfile("sRGB") + destination_profile = ImageCms.createProfile("sRGB") + t = ImageCms.buildTransform(source_profile, destination_profile, inMode=mode, outMode=mode) + + # apply transform + if transform_in_place: + ImageCms.applyTransform(source_image, t, inPlace=True) + result_image = source_image + else: + result_image = ImageCms.applyTransform(source_image, t, inPlace=False) + result_image_aux = result_image.split()[preserved_channel_ndx] + + self.assert_image_equal(source_image_aux, result_image_aux) + + def test_preserve_auxiliary_channels_rgba(self): + self.assert_aux_channel_preserved(mode='RGBA', transform_in_place=False, preserved_channel='A') + + def test_preserve_auxiliary_channels_rgba_in_place(self): + self.assert_aux_channel_preserved(mode='RGBA', transform_in_place=True, preserved_channel='A') + + def test_preserve_auxiliary_channels_rgbx(self): + self.assert_aux_channel_preserved(mode='RGBX', transform_in_place=False, preserved_channel='X') + + def test_preserve_auxiliary_channels_rgbx_in_place(self): + self.assert_aux_channel_preserved(mode='RGBX', transform_in_place=True, preserved_channel='X') + + def test_auxiliary_channels_isolated(self): + # test data in aux channels does not affect non-aux channels + aux_channel_formats = [ + # format, profile, color-only format, source test image + ('RGBA', 'sRGB', 'RGB', hopper('RGBA')), + ('RGBX', 'sRGB', 'RGB', hopper('RGBX')), + ('LAB', 'LAB', 'LAB', Image.open('Tests/images/hopper.Lab.tif')), + ] + for src_format in aux_channel_formats: + for dst_format in aux_channel_formats: + for transform_in_place in [True, False]: + # inplace only if format doesn't change + if transform_in_place and src_format[0] != dst_format[0]: + continue + + # convert with and without AUX data, test colors are equal + source_profile = ImageCms.createProfile(src_format[1]) + destination_profile = ImageCms.createProfile(dst_format[1]) + source_image = src_format[3] + test_transform = ImageCms.buildTransform(source_profile, destination_profile, inMode=src_format[0], outMode=dst_format[0]) + + # test conversion from aux-ful source + if transform_in_place: + test_image = source_image.copy() + ImageCms.applyTransform(test_image, test_transform, inPlace=True) + else: + test_image = ImageCms.applyTransform(source_image, test_transform, inPlace=False) + + # reference conversion from aux-less source + reference_transform = ImageCms.buildTransform(source_profile, destination_profile, inMode=src_format[2], outMode=dst_format[2]) + reference_image = ImageCms.applyTransform(source_image.convert(src_format[2]), reference_transform) + + self.assert_image_equal(test_image.convert(dst_format[2]), reference_image) if __name__ == '__main__': unittest.main() From 4a1ad8986f7f1e616ca34a2201316baa5a049116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarkko=20P=C3=B6yry?= Date: Sat, 28 Jan 2017 22:05:49 +0200 Subject: [PATCH 2/2] Preserve auxiliary channels during CMS transform. --- _imagingcms.c | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/_imagingcms.c b/_imagingcms.c index fe905f969..bc83183f7 100644 --- a/_imagingcms.c +++ b/_imagingcms.c @@ -291,6 +291,97 @@ findLCMStype(char* PILmode) } } +#define Cms_Min(a, b) ((a) < (b) ? (a) : (b)) + +static int +pyCMSgetAuxChannelChannel (cmsUInt32Number format, int auxChannelNdx) +{ + int numColors = T_CHANNELS(format); + int numExtras = T_EXTRA(format); + + if (T_SWAPFIRST(format) && T_DOSWAP(format)) { + // reverse order, before anything but last extra is shifted last + if (auxChannelNdx == numExtras - 1) + return numColors + numExtras - 1; + else + return numExtras - 2 - auxChannelNdx; + } + else if (T_SWAPFIRST(format)) { + // in order, after color channels, but last extra is shifted to first + if (auxChannelNdx == numExtras - 1) + return 0; + else + return numColors + 1 + auxChannelNdx; + } + else if (T_DOSWAP(format)) { + // reverse order, before anything + return numExtras - 1 - auxChannelNdx; + } + else { + // in order, after color channels + return numColors + auxChannelNdx; + } +} + +static void +pyCMScopyAux (cmsHTRANSFORM hTransform, Imaging imDst, const Imaging imSrc) +{ + cmsUInt32Number dstLCMSFormat; + cmsUInt32Number srcLCMSFormat; + int numSrcExtras; + int numDstExtras; + int numExtras; + int ySize; + int xSize; + int channelSize; + int srcChunkSize; + int dstChunkSize; + int e; + + // trivially copied + if (imDst == imSrc) + return; + + dstLCMSFormat = cmsGetTransformOutputFormat(hTransform); + srcLCMSFormat = cmsGetTransformInputFormat(hTransform); + + // currently, all Pillow formats are chunky formats, but check it anyway + if (T_PLANAR(dstLCMSFormat) || T_PLANAR(srcLCMSFormat)) + return; + + // copy only if channel format is identical, except OPTIMIZED is ignored as it + // does not affect the aux channel + if (T_FLOAT(dstLCMSFormat) != T_FLOAT(srcLCMSFormat) + || T_FLAVOR(dstLCMSFormat) != T_FLAVOR(srcLCMSFormat) + || T_ENDIAN16(dstLCMSFormat) != T_ENDIAN16(srcLCMSFormat) + || T_BYTES(dstLCMSFormat) != T_BYTES(srcLCMSFormat)) + return; + + numSrcExtras = T_EXTRA(srcLCMSFormat); + numDstExtras = T_EXTRA(dstLCMSFormat); + numExtras = Cms_Min(numSrcExtras, numDstExtras); + ySize = Cms_Min(imSrc->ysize, imDst->ysize); + xSize = Cms_Min(imSrc->xsize, imDst->xsize); + channelSize = T_BYTES(dstLCMSFormat); + srcChunkSize = (T_CHANNELS(srcLCMSFormat) + T_EXTRA(srcLCMSFormat)) * channelSize; + dstChunkSize = (T_CHANNELS(dstLCMSFormat) + T_EXTRA(dstLCMSFormat)) * channelSize; + + for (e = 0; e < numExtras; ++e) { + int y; + int dstChannel = pyCMSgetAuxChannelChannel(dstLCMSFormat, e); + int srcChannel = pyCMSgetAuxChannelChannel(srcLCMSFormat, e); + + for (y = 0; y < ySize; y++) { + int x; + char* pDstExtras = imDst->image[y] + dstChannel * channelSize; + const char* pSrcExtras = imSrc->image[y] + srcChannel * channelSize; + + for (x = 0; x < xSize; x++) + memcpy(pDstExtras + x * dstChunkSize, pSrcExtras + x * srcChunkSize, channelSize); + } + } +} + static int pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { @@ -301,9 +392,19 @@ pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) Py_BEGIN_ALLOW_THREADS + // transform color channels only for (i = 0; i < im->ysize; i++) cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize); + // lcms by default does nothing to the auxiliary channels leaving those + // unchanged. To do "the right thing" here, i.e. maintain identical results + // with and without inPlace, we replicate those channels to the output. + // + // As of lcms 2.8, a new cmsFLAGS_COPY_ALPHA flag is introduced which would + // do the same thing automagically. Unfortunately, lcms2.8 is not yet widely + // enough available on all platforms, so we polyfill it here for now. + pyCMScopyAux(hTransform, imOut, im); + Py_END_ALLOW_THREADS return 0;