9

Pillow's basic Image.resize function doesn't appear to have any options for SRGB-aware filtering. Is there a way to do SRGB-aware resizing in Pillow?

I could do it manually by converting the image to float and applying the SRGB transforms myself...but I'm hoping there's a built-in way.

Nathan Reed
  • 3,583
  • 1
  • 26
  • 33
  • It's not clear that SRGB-awareness is necessary to resize an image. What make you think it is? – martineau Jul 08 '15 at 18:40
  • 2
    @martineau If you resize an image smaller, the apparent luminance ideally shouldn't change. If you're not color-space-aware, it *will* change. – Nathan Reed Jul 08 '15 at 18:42
  • More precisely, If *c(r',g',b')* commutes with the interpolation formula, then and only then, awareness of c is not required. The *c* for sRGB is not linear, so it does not commute with any linear filter. It may happen that it commutes with multiplication and powers. I should be easy to prove or disprove through simple real algebra. But that is not used for image resize so that does not matter in this case. – user877329 Jun 26 '17 at 18:11

3 Answers3

8

I ended up implementing sRGB-aware resize myself using the following routine. It takes an 8-bit RGB image and a target size and resampling filter.

from PIL import Image
import numpy as np

def SRGBResize(im, size, filter):
    # Convert to numpy array of float
    arr = np.array(im, dtype=np.float32) / 255.0
    # Convert sRGB -> linear
    arr = np.where(arr <= 0.04045, arr/12.92, ((arr+0.055)/1.055)**2.4)
    # Resize using PIL
    arrOut = np.zeros((size[1], size[0], arr.shape[2]))
    for i in range(arr.shape[2]):
        chan = Image.fromarray(arr[:,:,i])
        chan = chan.resize(size, filter)
        arrOut[:,:,i] = np.array(chan).clip(0.0, 1.0)
    # Convert linear -> sRGB
    arrOut = np.where(arrOut <= 0.0031308, 12.92*arrOut, 1.055*arrOut**(1.0/2.4) - 0.055)
    # Convert to 8-bit
    arrOut = np.uint8(np.rint(arrOut * 255.0))
    # Convert back to PIL
    return Image.fromarray(arrOut)
Nathan Reed
  • 3,583
  • 1
  • 26
  • 33
  • The linear to sRGB conversion sometimes causes problem. It looks to me it "overflows" and left weird color pixels on the image. I was trying to resize this image to 50%: https://i.imgur.com/WNw6uNJ.png the result is like this: https://i.imgur.com/dFqyW34.png – fireattack Sep 02 '20 at 18:28
  • 2
    @fireattack Thanks for the report. It looks like this can happen if you use bicubic or Lanczos filtering during the resize step. As these are filters with negative lobes, they can produce negative values (or greater than 1 values) in the output, and then when we try to take a power of that it produces NaN. I've fixed it by adding `.clip(0.0, 1.0)` after the resize. – Nathan Reed Sep 02 '20 at 18:51
2

After a lot of reading and trial and error I have stumbled upon a good solution. It assumes an sRGB image, converts it to linear colour space to do the resizing, then converts back to sRGB.

There is a slight downside in that a colour depth of 8 bits per pixel is used even when the image is in it's linear form. This results in a loss of variance in darker regions. Reading from this issue post it seems there is no way to convert to a higher depth using Pillow unfortunately.

from PIL import Image
from PIL.ImageCms import profileToProfile

SRGB_PROFILE = 'sRGB.icc'
LINEARIZED_PROFILE = 'linearized-sRGB.icc'

im = Image.open(IN_PATH)
im = profileToProfile(im, SRGB_PROFILE, LINEARIZED_PROFILE)
im = im.resize((WIDTH, HEIGHT), Image.ANTIALIAS)
im = profileToProfile(im, LINEARIZED_PROFILE, SRGB_PROFILE)

im.save(OUT_PATH)

You'll need a linearised ICC colour profile as Pillow/lcms can't do it without. You can get one from this issue post and the author mentions in the file "no copyright, use freely". You'll also need an sRGB profile which should be easily obtainable from your OS or online.

Much of the processing time is taken up computing the transformations from sRGB and back again. If you are going to be doing a lot of these operations you can store these transformations to re-use them like so:

from PIL.ImageCms import buildTransform, applyTransform

SRGB_TO_LINEARIZED = buildTransform(SRGB_PROFILE, LINEARIZED_PROFILE, 'RGB', 'RGB')
LINEARIZED_TO_SRGB = buildTransform(LINEARIZED_PROFILE, SRGB_PROFILE, 'RGB', 'RGB')

im = applyTransform(im, SRGB_TO_LINEARIZED)
im = im.resize((WIDTH, HEIGHT), Image.ANTIALIAS)
im = applyTransform(im, LINEARIZED_TO_SRGB)

I hope this helps and I'd be interested to hear if anyone has any ideas on resolving the 8 bit colour space issue.

Damian Moore
  • 1,306
  • 1
  • 11
  • 13
  • 1
    The 8-bit issue can be resolved by using floating-point PIL images for the intermediate steps. – Nathan Reed Sep 02 '20 at 18:53
  • That's interesting @NathanReed - thanks! Do you have any more info on this? I've tried converting the image mode to float using `im = im.convert('F')` before `im = profileToProfile(im, SRGB_PROFILE, LINEARIZED_PROFILE)` but get an error `PIL.ImageCms.PyCMSError: cannot build transform`. – Damian Moore Oct 13 '20 at 11:37
  • Sorry, I've never tried to work with profiles in Pillow. Perhaps they don't work with float images. I have done it by converting to a numpy array and doing color transformations myself in numpy. – Nathan Reed Oct 14 '20 at 20:24
1

99% of image resize implementations will not get sRGB right (which, unfortunately, is 99.9% of image material), and those who do usually will do it right by default and give you the option to opt out of gamma de/encoding.

[opinionated mode on, read with care]

IOW, if there is no option you likely have to add the code yourself - or just use pamscale. If a library doesn't get sRGB right it will have other flaws anyway.

[opinionated mode off]

You could de/encode yourself as discussed in

http://www.imagemagick.org/discourse-server/viewtopic.php?t=15955

but a from quick glance it seems pillow is not capable of doing that trick.

Simon Thum
  • 576
  • 4
  • 15
  • I did end up doing it myself by converting the image to a numpy array, applying the sRGB-to-linear transform, back to a Pillow image (now floating-point), resize, then back to numpy, linear-to-sRGB, round to 8-bit and finally back to Pillow. I'll post an answer with the code next time I'm at work. Sigh. – Nathan Reed Jul 11 '15 at 23:07
  • Glad you did it - unfortunately that's exemplary of the extra effort correct image scaling usually requires. One might think that's what libraries like pillow are for ;) – Simon Thum Jul 14 '15 at 09:43
  • I share your dismal view of image processing libraries that don't handle gamma properly. I wonder if one that does even exists... – Andrew Wagner Feb 08 '16 at 14:40
  • Well, I once used Intel IPP. It doesn't "do it right by default" but at least it takes floating point images and has the en/decoding step on board. But I'm sure others exist (GEGL comes to mind). – Simon Thum Feb 09 '16 at 11:14
  • Quick version of how to do scaling right with ImageMagick/GraphicsMagick: `convert dalai_lama.jpg -colorspace RGB -resize 50% -colorspace sRGB out.png` – Damian Moore Oct 05 '17 at 11:12
  • ImageWorsener http://entropymine.com/imageworsener/ is a tool that does it right by default. There are example images to test with here: http://www.ericbrasseur.org/gamma.html – Damian Moore Oct 05 '17 at 11:14
  • Yep, ImageWorsener is very well done. It clearly deserves more attention. – Simon Thum Oct 06 '17 at 14:24