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 (and the 2011 version is faster still), but you should nevertheless try to cache the output and use it for consecutive HTTP requests.
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 started working on it, but it made no sense for my purposes. You can always generate a PNG image of your characters set and use copyRect from an auxiliary canvas containing it – that’s what I’ve done when I needed that. |
| 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 (2048×2048 at least). |
Revision History:
| Date | Version | Notes |
|---|---|---|
| Jul 2012 | 1.0.2 | bytearray initialization fix by Dave Griffith |
| Nov 2011 | 1.0.1 | Updated this to use bytearray (the original code was meant to run in Python 2.4, but 2.6+ makes this a much more efficient approach) and deal exclusively with alpha-channel files (if you need to load other formats, feel free to grab the earlier version) or, better still, send me a patch to improve the load() method. |
| 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, Python 2.4+ version
#!/usr/bin/env python """Simple PNG Canvas for Python - updated for bytearray()""" __version__ = "1.0.2" __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"], ["Dave Griffith"] import os, sys, 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)] # compute a new alpha given a 0-0xFF intensity def intensity(c,i): return [c[0],c[1],c[2],(c[3]*i) >> 8] # compute perceptive grayscale value def grayscale(c): return int(c[0]*0.3 + c[1]*0.59 + c[2]*0.11) # compute 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=bytearray([0xff,0xff,0xff,0xff]),color=bytearray([0,0,0,0xff])): self.width = width self.height = height self.color = color #rgba self.bgcolor = bgcolor self.canvas = bytearray(self.bgcolor * 4 * width * height) def _offset(self, x, y): return y * self.width * 4 + x * 4 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 o = self._offset(x,y) self.canvas[o:o+3] = blend(self.canvas[o:o+3],bytearray(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): d = destination._offset(dx+x-x0,dy+y-y0) o = self._offset(x,y) destination.canvas[d:d+4] = self.canvas[o:o+4] 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): o = self._offset(x,y) rgba = self.canvas[o:o+4] rgba[3] = 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): scanlines = bytearray() for y in range(self.height): scanlines.append('\0') # filter type 0 (None) #print y * self.width * 4, (y+1) * self.width * 4 #print self.canvas[y * self.width * 4:(y+1) * self.width * 4] scanlines.extend(self.canvas[(y * self.width * 4):((y+1) * self.width * 4)]) # image represented as RGBA tuples, no interlacing return signature + \ self.pack_chunk('IHDR', struct.pack("!2I5B",self.width,self.height,8,6,0,0,0)) + \ self.pack_chunk('IDAT', zlib.compress(str(scanlines),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 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 self.canvas = bytearray(self.bgcolor * width * height) if (bitdepth,colortype,compression, filter, interlace) != (8,6,0,0,0): raise TypeError('Unsupported PNG format') # we ignore tRNS for the moment 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*4]] if y == 0: rgba = self.defilter(cur,None,filtertype,4) else: rgba = self.defilter(cur,prev,filtertype,4) prev = cur i = i + width * 4 row = [] j = 0 for x in range(width): self.point(x,y,rgba[j:j+4]) j = j + 4 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 = 512 height = 512 print "Creating Canvas..." c = PNGCanvas(width,height) c.color = bytearray([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,1,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()
