Update: this is now on Github, under the MIT license. This page will be maintained for archival purposes.
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 |
---|---|---|
Dec 2013 | 1.0.3 | Moved to Github |
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()