Matt Rajca

blog projects github twitter email

Performing Fast Color Space Conversions on iOS and macOS

August 28, 2016

Pixen 3.x defaults to saving artwork in the Generic RGB color space. This is not ideal for a number of reasons. For one, color consistency is not preserved when viewing images on iOS, which uses an sRGB color space system-wide.

In Pixen 4, sRGB will become the default color space for all images. This means that existing artwork will have to be converted from the Generic RGB to sRGB color space.

On macOS, this can be done easily using NSBitmapImageRep. One possible implementation is shown below:

    
    - (nullable NSData *)sRGBColorDataFromGenericColorDataWithSize:(PXSize)size
    {
        NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL
                                                                        pixelsWide:size.width
                                                                        pixelsHigh:size.height
                                                                     bitsPerSample:8
                                                                   samplesPerPixel:4
                                                                          hasAlpha:YES
                                                                          isPlanar:NO
                                                                    colorSpaceName:NSCalibratedRGBColorSpace
                                                                      bitmapFormat:0
                                                                       bytesPerRow:size.width * 4
                                                                      bitsPerPixel:32];
        memcpy(rep.bitmapData, self.bytes, size.width * size.height * 4);
        NSBitmapImageRep *convertedRep = [rep bitmapImageRepByConvertingToColorSpace:[NSColorSpace sRGBColorSpace] renderingIntent:NSColorRenderingIntentDefault];
        return [NSData dataWithBytes:convertedRep.bitmapData length:self.length];
    }
    

Unfortunately, this code is not cross-platform since NSBitmapImageRep doesn't exist on iOS, and one of the goals for the new rendering engine in Pixen 4 was iOS support.

Luckily for us, the Accelerate framework provides vImageConvert* functions for performing vectorized color space conversions. In my testing with 2048x2048 image sizes, these are around 45% faster than the NSBitmapImageRep code shown above (averaged over five runs) and they run on both iOS and macOS.

Usage is fairly straightforward, with some comments inline:

    
    - (nullable NSData *)sRGBColorDataFromGenericColorDataWithSize:(PXSize)size
        CGColorSpaceRef genericRGB = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB);
        
        vImage_CGImageFormat sourceFormat;
        bzero(&sourceFormat, sizeof(sourceFormat));
        
        /* Specify the format we are converting from, including the Generic RGB color space. */
        sourceFormat.bitsPerPixel = 32;
        sourceFormat.bitsPerComponent = 8;
        sourceFormat.bitmapInfo = (CGBitmapInfo)kCGImageAlphaPremultipliedLast;
        sourceFormat.colorSpace = genericRGB;
        
        vImage_CGImageFormat dstFormat;
        bzero(&dstFormat, sizeof(dstFormat));
        
        /* The format we are converting to is identical, with the exception of the sRGB color space. */
        dstFormat.bitsPerPixel = sourceFormat.bitsPerPixel;
        dstFormat.bitsPerComponent = sourceFormat.bitsPerComponent;
        dstFormat.bitmapInfo = sourceFormat.bitmapInfo;
        dstFormat.colorSpace = NULL; // shorthand for sRGB
     
        /* Set up a converter. */
        vImage_Error error = kvImageNoError;
        vImageConverterRef converter = vImageConverter_CreateWithCGImageFormat(&sourceFormat, &dstFormat, NULL, kvImageNoFlags, &error);
        if (converter == NULL) {
            CGColorSpaceRelease(genericRGB);
            NSLog(@"%s: could not convert colorspaces: %zd", __PRETTY_FUNCTION__, error);
            return nil;
        }
        
        /* Set up the source buffer, and simply point to our `NSData`'s data. */
        vImage_Buffer sourceBuffer;
        vImageBuffer_Init(&sourceBuffer, size.height, size.width, sourceFormat.bitsPerPixel, kvImageNoAllocate);
        sourceBuffer.data = (void *)self.bytes;
        
        /* Allocate the destination buffer. */
        vImage_Buffer destBuffer;
        vImageBuffer_Init(&destBuffer, size.height, size.width, dstFormat.bitsPerPixel, kvImageNoFlags);
        
        /* Actually perform the conversion. */
        vImageConvert_AnyToAny(converter, &sourceBuffer, &destBuffer, NULL, kvImageNoFlags);
        CGColorSpaceRelease(genericRGB);
        vImageConverter_Release(converter);
        
        /* Since `destBuffer`'s `data` must be freed, let `NSData` do it for us. */
        return [NSData dataWithBytesNoCopy:destBuffer.data length:self.length freeWhenDone:YES];
    }
    

Update (August 29, 2016): As Stephen Canon mentioned on Twitter, the vImageConverter should be re-used when doing such conversions multiple times. I tried re-using a single vImageConverter in some code that does color space conversions in tight loops, and it resulted in another ~40% performance improvement.