Memory problems with MozJPEG and Pillow

We implemented MozJPEG to be used with Pillow 4.x to create smaller thumbnails of files uploaded by users, when we noticed that sometimes this process did not work. We looked into our logs and noticed the following error message I/O suspension not supported in scan optimization. Time to enter the GSO workflow, GSO stands for Google Stack Overflow, in other words search the Internet. The error message results in links to the source code of MozJPEG, not very helpful at first.

Time to brush up on my C knowledge, OK I never programmed in C but that doesn’t stop me from going through the source.

The error message is defined in jerror.h

#endif
JMESSAGE(JERR_BAD_PARAM, "Bogus parameter")
JMESSAGE(JERR_BAD_PARAM_VALUE, "Bogus parameter value")
JMESSAGE(JERR_UNSUPPORTED_SUSPEND, "I/O suspension not supported in scan optimization")

#ifdef JMAKE_ENUM_LIST

So now we have to find the JERR_UNSUPPORTED_SUSPEND constant. Luckily it appears only in one file, jcmaster.c

while (size >= cinfo->dest->free_in_buffer)
  {
    MEMCOPY(cinfo->dest->next_output_byte, src, cinfo->dest->free_in_buffer);
    src += cinfo->dest->free_in_buffer;
    size -= cinfo->dest->free_in_buffer;
    cinfo->dest->next_output_byte += cinfo->dest->free_in_buffer;
    cinfo->dest->free_in_buffer = 0;

    if (!(*cinfo->dest->empty_output_buffer)(cinfo))
      ERREXIT(cinfo, JERR_UNSUPPORTED_SUSPEND);
  }

Cool, it seems to be related to memory cleanup, just my guess because of the empty_output_buffer line. Now we have to find out where Pillow sets the buffersize for saving an JPEG image.

The file PIL/JpegImagePlugin.py is used for all functions related to a JPEG image, and this includes saving.

The whole save method is a bit large to post here, but the part below determines the buffer size and it’s used to save the image. The buffer size is set to be holding the entire image file.

bufsize = 0
if optimize or progressive:
    # CMYK can be bigger
    if im.mode == 'CMYK':
        bufsize = 4 * im.size[0] * im.size[1]
    # keep sets quality to 0, but the actual value may be high.
    elif quality >= 95 or quality == 0:
        bufsize = 2 * im.size[0] * im.size[1]
    else:
        bufsize = im.size[0] * im.size[1]

# The exif info needs to be written as one block, + APP1, + one spare byte.
# Ensure that our buffer is big enough. Same with the icc_profile block.
bufsize = max(ImageFile.MAXBLOCK, bufsize, len(info.get("exif", b"")) + 5,
            len(extra) + 1)

ImageFile._save(im, fp, [("jpeg", (0, 0)+im.size, 0, rawmode)], bufsize)

I don’t want to change the Pillow source itself cause of potential issues whenever we upgrade Pillow in the future. So the best thing I can do is modify the ImageFile.MAXBLOCK, not that big of deal I think.

I came up with the following solution

new_maxblock = 3 * image.size[0] * image.size[1],  # ...3 bytes per every pixel in the image
old_maxblock = ImageFile.MAXBLOCK
if new_maxblock > ImageFile.MAXBLOCK:
    ImageFile.MAXBLOCK = new_maxblock
requested_size = (int(width), int(height))
image.thumbnail(requested_size, Image.ANTIALIAS)
image.save(thumb_file, "JPEG", progressive=True,)
ImageFile.MAXBLOCK = old_maxblock

We determine a new max block size, as most JPEG files are 24bits color (RGB), we need 3 bytes per pixel. This might be overkill in certain situations but at times I prefer overkill over not having having the thumbnail.

After implementing the above solution the I/O suspension not supported in scan optimization error message has not been seen in the logs.

Related content