Sunday, 9 October 2005

Pure Python PNG Canvas

While searching for a simpler way to generate simple images from a Python snakelet, I hit upon this pure Ruby Sparklines generator and decided to port the canvas bits to Python.

As is, the canvas class lets you draw points, anti-aliased lines and polylines, as well as saving the result to a buffer in PNG format (which you can just spit out to the browser). I will eventually port the Sparklines bits as well, but this may be of immediate use to somebody.

After a few simple optimizations this turned out to be surprisingly fast, but you should nevertheless try to cache the output and use it for consecutive HTTP requests.

I will be working on adding a few more graphics primitives, and this will be updated piecemeal as they’re properly tested.

Mini-FAQ:

Q. A.
Why not use PIL or GD? because either of them is overkill for most purposes. This can be deployed just about anywhere, even on hosting accounts without shell access: No installation, no native bindings, no dependencies. It has, of late, become rather popular with people using Google AppEngine.
How fast is it? Surprisingly fast, actually. Gradients and file operations take a while, but line primitives and block copies are very fast.
What about text? I’m working on it. If you’re in a hurry, generate a PNG image of your font and use copyRect from an auxiliary canvas containing it – that’s what I’m doing…
What about line styles? Not necessary for what I’m doing, so they’re highly unlikely to crop up here.
It blows up loading large images! Yes, it does. Usually on zlib, which is not a big concern – I’m using it to manipulate 256×256 tiles with success. It can create large images though (1024×768 at least).

Revision History:

Date Version Notes
Jan 2009 0.8 Fix for Python deprecation warnings by Eli Bendersky.
Mar 2007 0.7 Near-complete PNG file decoding (not fully tested yet, but loads most images correctly), lots of miscellaneous tweaks.
Oct 2006 0.6 dramatically faster file save (thanks to reading this page).
Oct 2005 0.5 first stab at loading PNG images from files (limited to identically-formatted PNG files). This is a small step towards supporting arbitrary-width bitmapped fonts (which I intend to load from a linear, 1-character-height PNG file).
0.3 added gradient primitives.
0.2 offset fixes.
0.1 initial port.

download source

#!/usr/bin/env python

"""Simple PNG Canvas for Python"""
__version__ = "0.8"
__author__ = "Rui Carmo (http://the.taoofmac.com)"
__copyright__ = "CC Attribution-NonCommercial-NoDerivs 2.0 Rui Carmo"
__contributors__ = ["http://collaboa.weed.rbse.com/repository/file/branches/pgsql/lib/spark_pr.rb"], ["Eli Bendersky"]

import zlib, struct

signature = struct.pack("8B", 137, 80, 78, 71, 13, 10, 26, 10)

# alpha blends two colors, using the alpha given by c2
def blend(c1, c2):
  return [c1[i]*(0xFF-c2[3]) + c2[i]*c2[3] >> 8 for i in range(3)]

# calculate a new alpha given a 0-0xFF intensity
def intensity(c,i):
  return [c[0],c[1],c[2],(c[3]*i) >> 8]

# calculate perceptive grayscale value
def grayscale(c):
  return int(c[0]*0.3 + c[1]*0.59 + c[2]*0.11)

# calculate gradient colors
def gradientList(start,end,steps):
  delta = [end[i] - start[i] for i in range(4)]
  grad = []
  for i in range(steps+1):
    grad.append([start[j] + (delta[j]*i)/steps for j in range(4)])
  return grad

class PNGCanvas:
  def __init__(self, width, height,bgcolor=[0xff,0xff,0xff,0xff],color=[0,0,0,0xff]):
    self.canvas = []
    self.width = width
    self.height = height
    self.color = color #rgba
    bgcolor = bgcolor[0:3] # we don't need alpha for background
    for i in range(height):
      self.canvas.append([bgcolor] * width)

  def point(self,x,y,color=None):
    if x<0 or y<0 or x>self.width-1 or y>self.height-1: return
    if color == None: color = self.color
    self.canvas[y][x] = blend(self.canvas[y][x],color)

  def _rectHelper(self,x0,y0,x1,y1):
    x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1)
    if x0 > x1: x0, x1 = x1, x0
    if y0 > y1: y0, y1 = y1, y0
    return [x0,y0,x1,y1]

  def verticalGradient(self,x0,y0,x1,y1,start,end):
    x0, y0, x1, y1 = self._rectHelper(x0,y0,x1,y1)
    grad = gradientList(start,end,y1-y0)
    for x in range(x0, x1+1):
      for y in range(y0, y1+1):
        self.point(x,y,grad[y-y0])

  def rectangle(self,x0,y0,x1,y1):
    x0, y0, x1, y1 = self._rectHelper(x0,y0,x1,y1)
    self.polyline([[x0,y0],[x1,y0],[x1,y1],[x0,y1],[x0,y0]])

  def filledRectangle(self,x0,y0,x1,y1):
    x0, y0, x1, y1 = self._rectHelper(x0,y0,x1,y1)
    for x in range(x0, x1+1):
      for y in range(y0, y1+1):
        self.point(x,y,self.color)

  def copyRect(self,x0,y0,x1,y1,dx,dy,destination):
    x0, y0, x1, y1 = self._rectHelper(x0,y0,x1,y1)
    for x in range(x0, x1+1):
      for y in range(y0, y1+1):
        destination.canvas[dy+y-y0][dx+x-x0] = self.canvas[y][x]

  def blendRect(self,x0,y0,x1,y1,dx,dy,destination,alpha=0xff):
    x0, y0, x1, y1 = self._rectHelper(x0,y0,x1,y1)
    for x in range(x0, x1+1):
      for y in range(y0, y1+1):
        rgba = self.canvas[y][x] + [alpha]
        destination.point(dx+x-x0,dy+y-y0,rgba)

  # draw a line using Xiaolin Wu's antialiasing technique
  def line(self,x0, y0, x1, y1):
    # clean params
    x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1)
    if y0>y1:
      y0, y1, x0, x1 = y1, y0, x1, x0
    dx = x1-x0
    if dx < 0:
      sx = -1
    else:
      sx = 1
    dx *= sx
    dy = y1-y0

    # 'easy' cases
    if dy == 0:
      for x in range(x0,x1,sx):
        self.point(x, y0)
      return
    if dx == 0:
      for y in range(y0,y1):
        self.point(x0, y)
      self.point(x1, y1)
      return
    if dx == dy:
      for x in range(x0,x1,sx):
        self.point(x, y0)
        y0 = y0 + 1
      return

    # main loop
    self.point(x0, y0)
    e_acc = 0
    if dy > dx: # vertical displacement
      e = (dx << 16) / dy
      for i in range(y0,y1-1):
        e_acc_temp, e_acc = e_acc, (e_acc + e) & 0xFFFF
        if (e_acc <= e_acc_temp):
          x0 = x0 + sx
        w = 0xFF-(e_acc >> 8)
        self.point(x0, y0, intensity(self.color,(w)))
        y0 = y0 + 1
        self.point(x0 + sx, y0, intensity(self.color,(0xFF-w)))
      self.point(x1, y1)
      return

    # horizontal displacement
    e = (dy << 16) / dx
    for i in range(x0,x1-sx,sx):
      e_acc_temp, e_acc = e_acc, (e_acc + e) & 0xFFFF
      if (e_acc <= e_acc_temp):
        y0 = y0 + 1
      w = 0xFF-(e_acc >> 8)
      self.point(x0, y0, intensity(self.color,(w)))
      x0 = x0 + sx
      self.point(x0, y0 + 1, intensity(self.color,(0xFF-w)))
    self.point(x1, y1)

  def polyline(self,arr):
    for i in range(0,len(arr)-1):
      self.line(arr[i][0],arr[i][1],arr[i+1][0], arr[i+1][1])

  def dump(self):
    raw_list = []
    for y in range(self.height):
      raw_list.append(chr(0)) # filter type 0 (None)
      for x in range(self.width):
        raw_list.append(struct.pack("!3B",*self.canvas[y][x]))
    raw_data = ''.join(raw_list)

    # 8-bit image represented as RGB tuples
    # simple transparency, alpha is pure white
    return signature + \
      self.pack_chunk('IHDR', struct.pack("!2I5B",self.width,self.height,8,2,0,0,0)) + \
      self.pack_chunk('tRNS', struct.pack("!6B",0xFF,0xFF,0xFF,0xFF,0xFF,0xFF)) + \
      self.pack_chunk('IDAT', zlib.compress(raw_data,9)) + \
      self.pack_chunk('IEND', '')

  def pack_chunk(self,tag,data):
    to_check = tag + data
    return struct.pack("!I",len(data)) + to_check + struct.pack("!I", zlib.crc32(to_check) & 0xFFFFFFFF)

  def load(self,f):
    assert f.read(8) == signature
    self.canvas=[]
    for tag, data in self.chunks(f):
      if tag == "IHDR":
        ( width,
          height,
          bitdepth,
          colortype,
          compression, filter, interlace ) = struct.unpack("!2I5B",data)
        self.width = width
        self.height = height
        if (bitdepth,colortype,compression, filter, interlace) != (8,2,0,0,0):
          raise TypeError('Unsupported PNG format')
      # we ignore tRNS because we use pure white as alpha anyway
      elif tag == 'IDAT':
        raw_data = zlib.decompress(data)
        rows = []
        i = 0
        for y in range(height):
          filtertype = ord(raw_data[i])
          i = i + 1
          cur = [ord(x) for x in raw_data[i:i+width*3]]
          if y == 0:
            rgb = self.defilter(cur,None,filtertype)
          else:
            rgb = self.defilter(cur,prev,filtertype)
          prev = cur
          i = i+width*3
          row = []
          j = 0
          for x in range(width):
            pixel = rgb[j:j+3]
            row.append(pixel)
            j = j + 3
          self.canvas.append(row)

  def defilter(self,cur,prev,filtertype,bpp=3):
    if filtertype == 0: # No filter
      return cur
    elif filtertype == 1: # Sub
      xp = 0
      for xc in range(bpp,len(cur)):
        cur[xc] = (cur[xc] + cur[xp]) % 256
        xp = xp + 1
    elif filtertype == 2: # Up
      for xc in range(len(cur)):
        cur[xc] = (cur[xc] + prev[xc]) % 256
    elif filtertype == 3: # Average
      xp = 0
      for xc in range(len(cur)):
        cur[xc] = (cur[xc] + (cur[xp] + prev[xc])/2) % 256
        xp = xp + 1
    elif filtertype == 4: # Paeth
      xp = 0
      for i in range(bpp):
        cur[i] = (cur[i] + prev[i]) % 256
      for xc in range(bpp,len(cur)):
        a = cur[xp]
        b = prev[xc]
        c = prev[xp]
        p = a + b - c
        pa = abs(p - a)
        pb = abs(p - b)
        pc = abs(p - c)
        if pa <= pb and pa <= pc:
          value = a
        elif pb <= pc:
          value = b
        else:
          value = c
        cur[xc] = (cur[xc] + value) % 256
        xp = xp + 1
    else:
      raise TypeError('Unrecognized scanline filter type')
    return cur

  def chunks(self,f):
    while 1:
      try:
        length = struct.unpack("!I",f.read(4))[0]
        tag = f.read(4)
        data = f.read(length)
        crc = struct.unpack("!i",f.read(4))[0]
      except:
        return
      if zlib.crc32(tag + data) != crc:
        raise IOError
      yield [tag,data]

if __name__ == '__main__':
  width = 128
  height = 64
  print "Creating Canvas..."
  c = PNGCanvas(width,height)
  c.color = [0xff,0,0,0xff]
  c.rectangle(0,0,width-1,height-1)
  print "Generating Gradient..."
  c.verticalGradient(1,1,width-2, height-2,[0xff,0,0,0xff],[0x20,0,0xff,0x80])
  print "Drawing Lines..."
  c.color = [0,0,0,0xff]
  c.line(0,0,width-1,height-1)
  c.line(0,0,width/2,height-1)
  c.line(0,0,width-1,height/2)
  # Copy Rect to Self
  print "Copy Rect"
  c.copyRect(1,1,width/2-1,height/2-1,0,height/2,c)
  # Blend Rect to Self
  print "Blend Rect"
  c.blendRect(1,1,width/2-1,height/2-1,width/2,0,c)
  # Write test
  print "Writing to file..."
  f = open("test.png", "wb")
  f.write(c.dump())
  f.close()
  # Read test
  print "Reading from file..."
  f = open("test.png", "rb")
  c.load(f)
  f.close()
  # Write back
  print "Writing to new file..."
  f = open("recycle.png","wb")
  f.write(c.dump())
  f.close()