Again with the statusbar cleaning

There are some awfully bright people on the internet, and it’s hard keeping up. This is, I hope the last update to my statusbar cleaning script, statusbare, a script that’s already consumed three whole posts. The latest version of the script is much bigger, but has some significant improvements:

  1. It requires no external image files. The data for the overlay statusbar image are generated on the fly from data stored within the script itself. This came from a Twitter suggestion by Adam Lickel.
  2. It will work for any Retina iOS device in any orientation and wouldn’t be difficult to extend to non-Retina devices. This came out of an extended Twitter conversation with David Cross this morning.
  3. It can handle statusbars with a vertical gradient background in addition to those with a uniform background. This idea was stolen from Phillip Gruneich and Danilo Torrisi.

Let’s start by figuring out how to store an image in the script. The Python Imaging Library’s Image module has a tostring method that turns image data into a (very long) string. We can losslessly compress that string with the zlib module, but the string that results is filled with non-ASCII characters. Entering that string as a literal in the script requires all those non-ASCIIs to be escaped.1 An example of an escaped character is \xb4, which takes up four ASCII characters and sort of defeats the compression.

But all is not lost. We can take the compressed string with the non-ASCIIs and encode it into an all-ASCII representaton through the base64 library. The string that comes out of the encoding is easy to include as a string literal in the script.

As I discussed in an earlier post, I have a white-on-black PNG that I use as an alpha channel mask to create the statusbar overlay graphics.

Mask

I turned this into a string in an interactive Python session:

>>> import Image
>>> a = Image.open('mask640.png')
>>> a = a.convert('L')
>>> s = a.tostring()
>>> import zlib
>>> sc = zlib.compress(s)
>>> import base64
>>> s64 = base64.b64encode(sc)

The s64 string is 2,948 characters long, so after copying it out of the interactive session, I split it up into about 50 lines to make it look reasonable in the script.

Might as well show the script now. This is one I run from the command line on my Mac. The one I run on my iPhone through Pythonista differs by only a few lines: it gets the screenshot from the Camera Roll instead of the command line, and it shows the cleaned-up image in the Pythonista console (where it can be saved or put on the clipboard) instead of writing it out directly to a file. You can find the Pythonista version of the script in this Gist.

python:
  1:  #!/usr/bin/python
  2:  
  3:  from __future__ import division
  4:  import Image
  5:  import sys, os
  6:  import base64, zlib
  7:  
  8:  # This is for iOS devices with retina displays.
  9:  height = 40
 10:  
 11:  # Statusbar image data for an iPhone 4, 4s, 5, 5c, or 5s portrait screenshot.
 12:  # The data string is compressed and base64-encoded.
 13:  img = {'mode': 'L',
 14:         'box':  (640, 40),
 15:         'lbox': (0, 0, 160, 40),
 16:         'cbox': (260, 0, 380, 40),
 17:         'rbox': (500, 0, 640, 40),
 18:         'data': ''.join(
 19:                 ['eJztmnt0FNUdx+8k2U2WPJpwAEliw8sQiMYDlpgDKIIBAwhqBRKxQBWtj9JS',
 20:                  'Ci2hxFiEVuIxelr1YEIUSyFtgbQVewwChRZsSirhFaS2Fs2rYmIeQLJ5LOzO',
 21:                  't/fOzM5jz26ax2YzHu/nj5n53Ufu785+c+/93TuEcDgcDofD4XA4HA6Hw+Fw',
 22:                  'OBwOh/PlxTJ3sD3gfIWZ3fDJYLvA+epymwO/8pFlycg7XH0VrdWH8zIsgfSp',
 23:                  'TwTHhemsyBHG3LCxMiPdCbHUGOY2hrCsAfcwIFgXzdabSyvQMyoeHhx/S9Ca',
 24:                  '4DUj4eUmnXtNL48KsGPdklNR8YAhYcI71yGeWahYa+sA+7YYXf4Md0ea/7pG',
 25:                  'YAll7J27c5+nhlMYcK/7wO7H5PuIgjOdNe/OUVKzDn1mP5kfpVjLTrSf3xyk',
 26:                  'GPl4SFc9q+zmHjaUXJbVb2f7QkPjAm/JkfkOj38QR/7XAu2bT2a5gO/oE+L+',
 27:                  'K/nonCdZm2SP/6IbtGfoevJeBJH1hwlK7kWz6m8KXpLuk5vgKmsGtkjWG8Dn',
 28:                  '5UCdPCQsR9GdOeJrcoUM8U19/XOJW3s4/uUlngtgvxizt5fvyU3ftHHH2VPP',
 29:                  '3eqRN7WK+VS+cWZsEAmKvesnf2dm1bQAe+iLoUxtkv6Cc0pZ9BR2Cjj45I5r',
 30:                  'aJ1IrUzgi7x1F4AirQrVX8dGypYDtOozRNHfZjkzFSbVX+QpWX8hZ7FrJLEs',
 31:                  'cYrTqbUE9jlBZHQpDkqFPjpNPd/VKY32I+r/Fa7/A6LQ1UP9OQSx+xeQ8HqN',
 32:                  'o/b1Uf20NLJFXeuuxw15j1yjaX9I0aWklDAfV3T/ugLFPjgV/d0HtNgIeRA4',
 33:                  'Qt/eM0ABTayEOIuQYQ0QY9UqVH+N8tMGupgQFP39R0560ZT6S8rbfRWy/jJQ',
 34:                  'L022v0QJvZ7AembE23ELvcViK70uA1v2CaWO2wx/QxREL1rzyv/RX0arVKpt',
 35:                  'br8sjTuMra83ZM5zoGm+R4V5TXB4pg0Oj6HrRUV/WbRndDbdCbCx2dKKSwIZ',
 36:                  'AxxiednAU2odTX830N7GSvqj81kaSxFq2aPp9LdI+mEk/b2K56SkeNgtZLjo',
 37:                  'CJWsImwgbIpeTZhCv0Wva7DW+Df8pr/RV5VibaP7Yek4Ymi80KO5zNMsHAy5',
 38:                  't+jsZVw+W7QghFpjT2f27gUOEIl2ZK9Q9GfNP8acakSnlZl0cv0GWQXkMiMN',
 39:                  '+JNaSdNfOO3ueEl/BcArLGWa9Gg6/QlWq3WZrL+jWCSnNeEm2pXzsvF9FNNr',
 40:                  'MtYRNhHQ2GtSV6lHJ3qnP/vbN/pypkAtV9APSyPU6NcUz/ZoOCWsqFLzq74t',
 41:                  'SGkmwHISfwtabog/qKTkxfMLwINsNv0mM2xApVpE099iutoIlfSXYUd9ME15',
 42:                  'hY6eJtQfI0vW30UoYewJ3E2n2n2ykYH36TXCyQaPHBpL2T6q99h16qX+RBz1',
 43:                  '5UitWq62H5bGBEPbNV5ajDlgKPLe0D68voFgK+xjiVF/CcBx6WEj8ATZAfoj',
 44:                  'Ma7hklqE6q85gZL8vSvA74mkv2m7qAZpDFOPyghz668BY2T7MO4jT2OnbEzH',
 45:                  'aXYraY4j0VVlhBSKtDfhqfptp97qr92XI1oY4xD6bmncaWi70kuLa2n65S1p',
 46:                  'NhKWtvkL+ryuP+/Rf8x0UYl56G8SUCo9rGEL2beB2yXrCjrUIvr9lza2bGf6',
 47:                  'uwf4NSHptFK4ufXXgjjZLsVishrbZeN2XGC3Mf++cqCpbiJdL+YTa7HLgUPR',
 48:                  'av3e6g++XoK/x78bDW13xXhpchvech8QhBeg2Bw/T0wd3iWe+hsHZeKgse3T',
 49:                  'hI5qMySrCw1qEZ3+Km5iCUx/wZfQZiPb4YofYm79NUI5IziIB+j6dodsTFVW',
 50:                  'HZH35yweSr7eUmElr7oyQ9I796v1/Tb+aeu4wn5YGkK7ofEXvDQZvFBn3Gvt',
 51:                  '5ZsbILLhXJaenp5HA8P0ce7EaPdRxvNAJvkFIG2pW0X8U63H9v+epfxovrLE',
 52:                  'ZvpjS8UsSzOOkDBz668OSbJ9DHNp+P9b2ZiFcq1k8HH7eGJr20sft7ni3al+',
 53:                  'W/+NblWKyVFtHy0dxw2Ni6u9Bxe37Kxz1O1M8Zo3KOTqnM6jdhDb4hM60CKp',
 54:                  'Zw9wB1kP/IAZycCf1Xpa/KEg6e9W4J0FwEqz66+crVMZNUghc3FCNh6VFrIK',
 55:                  'z9JekBSp54/AfU7XW/0d8H3I6u/9v4c8mr/fW6MLrkt5170e0Q0KHvq7oVpk',
 56:                  'i/H9kAPEz9FqIZMhB4hPAtlqPe/6I5VwlKIzioSaW3/Fyuo7Es4IkohWOfMl',
 57:                  '3bQ13bmHsKUsFSGN8NWjXP/tP5OEglpHbcGofloqIXWG1j/z+oGL7ZiUeXxI',
 58:                  't54FkrBoiaeA1dFhhO31iSPYSCCdtS0FfkfYINGZSKff89r5rk/9/Zh1j/5y',
 59:                  'VnPrbznKJe+ekHZcPoF0DhD2KdLd5aJrqlnQMUnai16Ju9zpftSfv/muofVH',
 60:                  'vReKOknzTprnwwM37vgjWZQ2Y4deoqPhlFUtcLGdFxqFVC6Z+UfoVzM+9Bfn',
 61:                  'ov2j61yLufUX0yx1d1gj2DlpLj62EfaVxafqoLHXyQ6GSUTnbnotdKgflvnx',
 62:                  '/NfvlOha3+9rb3n4h7gwPKBu9Qg1/h23lH3MQlI75H78kBnCHtm4OEyr4EN/',
 63:                  '5BDQRH/FEHPrjzwMfLDhN+04ypwM/RCX38w9D5c6/D2On8oPhdfuEWZ3vaXW',
 64:                  'P5f4sx7q7+cB//4l6hRttuUDtj4s8z3Bxh/0eSYziCz3+P6KzK+m3bDnykZY',
 65:                  'MRvW/pGky/elP/qHttFbsMn1RzI+pj3qyJM/sY3azka1M2nuUknt7wfLT7a9',
 66:                  'Yodrn02rb+bv/4Q5q24WiDBx5d1mfPW9JGTywqlRqjVy1rykbgp/GYlJG6NN',
 67:                  'UsHjUyO1rCWbtE+II1MNqyXTf//M4XA4HA6Hw+FwOBwOh8PhcDgcDocTOP4H',
 68:                  'BgjByw=='])}
 69:  
 70:  # Open the screenshot and cover over its statusbar.
 71:  # Fill across the width with the colors along the left edge.
 72:  screenshot = Image.open(sys.argv[1])
 73:  width = screenshot.size[0]
 74:  bar = (0, 0, width, height)
 75:  for y in range(height):
 76:    p = screenshot.getpixel((0, y))[:3]
 77:    screenshot.paste(p, (0, y, width, y+1))
 78:  
 79:  # Decide whether the overlay text and graphics should be black or white.
 80:  if sum(p)/3 > 192:        # p is the last color from the loop above
 81:    textcolor = 'black'
 82:  else:
 83:    textcolor = 'white'
 84:  
 85:  # If the screenshot is the same width as img, use that as the mask.
 86:  # Otherwise, construct a new mask from the left, center, and right graphics.
 87:  bitmap = Image.fromstring(img['mode'],
 88:                            img['box'],
 89:                            zlib.decompress(base64.b64decode(img['data'])))
 90:  if width == img['box'][0]:
 91:    mask = bitmap
 92:  else:
 93:    mask = Image.new('L', (width, height), 'black')
 94:    limg = bitmap.crop(img['lbox'])
 95:    cimg = bitmap.crop(img['cbox'])
 96:    rimg = bitmap.crop(img['rbox'])
 97:    mask.paste(limg, (0, 0))
 98:    x = (mask.size[0] - cimg.size[0])//2
 99:    mask.paste(cimg, (x, 0))
100:    x = mask.size[0] - rimg.size[0]
101:    mask.paste(rimg, (x, 0))
102:  
103:  # Make the overlay.
104:  statusbar = Image.new('RGBA', (width, height), textcolor)
105:  statusbar.putalpha(mask)
106:  
107:  # Paste the overlay and save.
108:  screenshot.paste(statusbar, bar, statusbar)
109:  screenshot.save(sys.argv[1])

The string created in the interactive session takes up Lines 19–68. We’ll talk about the other parts of the img dictionary in a bit.

Lines 75–77 are where I adapted Phillip Gruneich and Danilo Torrisi’s ideas for handling gradient backgrounds. Basically, the loop goes through every row of the screenshot’s statusbar area and covers it with the color at the left edge of that row. This technique won’t work well for photo backgrounds or see-through statusbars like Messages’. But it’s better than the solid backgrounds my earlier scripts used.

The image stored in the long string is decoded, decompressed, and turned into a PIL Image in Line 87. You can see from this line why I put the mode and box items in the img dictionary.

If the screenshot is from an iPhone in portrait mode, this image is used as the mask to create the statusbar overlay in Lines 104–105. If the screenshot is of a different width, something more interesting happens.

Line 93 creates a new all-black image, mask, of the same width as the screenshot. Lines 94–96 then crop out the left, center, and right graphics from the bitmap image that we extracted from the long string. The locations of the graphics are pulled from the img dictionary; I figured out where they were by playing around with the full image in Acorn. Finally, lines 97–101 paste the cropped-out graphics into the appropriate places of mask. This is the mask that’s passed along to Lines 104–105 to create the overlay.

What I like about this arrangement is that there’s very little processing for my most common situation, an iPhone screenshot in portrait mode, but that it can handle any other case through crop-and-paste. All credit to David Cross for seeing the advantages of this.

Here’s a cleaned screenshot of PCalc that takes advantage of both the crop-and-paste to handle the landscape orientation and the row-by-row pasting of the background color to handle the background gradient. It would be very sad indeed if a script I wrote couldn’t handle PCalc screenshots.

PCalc sideways

I suppose I should rewrite this as a module with a function that takes the raw screenshot as its argument and returns the cleaned-up image. But I have to leave something for Federico to do.


  1. There are ways to enter non-ASCII literals in Python source, but I don’t want to deal with them in this script.