Source code for topo.plotting.bitmap

"""
Topographica Bitmap Class.

Encapsulates the PIL Image class so that an input matrix can be displayed
as a bitmap image without needing to know about PIL proper.

There are three different base image Classes which inherit Bitmap:

PaletteBitmap  - 1 2D Matrix, 1 1D Color Map
HSVBitmap    - 3 2D Matrices, Color (H), Confidence (S), Strength (V)
RGBBitmap    - 3 2D Matrices, Red, Green, Blue Channels.

All maps are assumed to be on a nominal range of 0.0 to 1.0.  Matrices
are passed in as part of the constructor and the image is generaed.
For more information, see the documentation for each of the Bitmap
classes.

The encapsulated PIL Image is accessible through the .bitmap attribute.
"""

import os
import Image
import ImageDraw
import ImageFont
from colorsys import hsv_to_rgb
import numpy as np

import param
from param import resolve_path


# CEBALERT: can we just use load_default()? Do we even need TITLE_FONT
# at all?
try:
   import matplotlib
   _vera_path = resolve_path(os.path.join(matplotlib.__file__,'matplotlib/mpl-data/fonts/ttf/Vera.ttf'))
   TITLE_FONT = ImageFont.truetype(_vera_path,20)
except:
   TITLE_FONT = ImageFont.load_default()


### JCALERT: To do:
###        - Update the test file.
###        - Write PaletteBitmap when the Palette class is fixed
###        - Get rid of accessing function (copy, show...) (should we really?)


[docs]class Bitmap(param.Parameterized): """ Wrapper class for the PIL Image class. The main purpose for this base class is to provide a consistent interface for defining bitmaps constructed in various different ways. The resulting bitmap is a PIL Image object that can be accessed using the normal PIL interface. If subclasses use the _arrayToImage() function provided, any pixels larger than the maximum that can be displayed will be counted before they are clipped; these are stored in the clipped_pixels attribute. """ clipped_pixels = 0 def __init__(self,image): self.image = image def __copy__(self): # avoid calling __getstate__ for copy (not required) image = self.image.copy() return Bitmap(image) # CB: could define a __deepcopy__ too, but we don't need # deepcopy to be fast. def __getstate__(self): """ Return the object's state (as in the superclass), but replace the 'image' attribute's Image with a string representation. """ state = super(Bitmap,self).__getstate__() import StringIO f = StringIO.StringIO() image = state['image'] image.save(f,format=image.format or 'TIFF') # format could be None (we should probably just not save in that case) state['image'] = f.getvalue() f.close() return state def __setstate__(self,state): """ Load the object's state (as in the superclass), but replace the 'image' string with an actual Image object. """ import StringIO state['image'] = Image.open(StringIO.StringIO(state['image'])) super(Bitmap,self).__setstate__(state)
[docs] def show(self): """ Renaming of Image.show() for the Bitmap.bitmap attribute. """ self.image.show()
def width(self): return self.image.size[0] def height(self): return self.image.size[1]
[docs] def zoom(self, factor): """ Return a resized Image object, given the input 'factor' parameter. 1.0 is the same size, 2.0 is doubling the height and width, 0.5 is 1/2 the original size. The original Image is not changed. """ if factor%1==0: # CEBALERT: work around PIL bug (see SF #2820821) so that # integer scaling works in the typical case (where an # image is being enlarged). a = np.array(self.image).repeat(int(factor),axis=0).repeat(int(factor),axis=1) zoomed = Image.fromarray(a,mode=self.image.mode) else: x,y = self.image.size zx, zy = int(x*factor), int(y*factor) zoomed = self.image.resize((zx,zy)) return zoomed # CEBALERT: Might be worthwhile simplifying this # method. E.g. Image.fromarray() now exists (PIL>=1.1.6), and # probably various numpy operations can now be written more # clearly, too.
def _arrayToImage(self, inArray): """ Generate a 1-channel PIL Image from an array of values from 0 to 1.0. Values larger than 1.0 are clipped, after adding them to the total clipped_pixels. Returns a one-channel (monochrome) Image. """ # PIL 'L' Images use a range of 0 to 255, so we scale the # input array to match. The pixels are scaled by 255, not # 256, so that 1.0 maps to fully white. max_pixel_value=255 inArray = (np.floor(inArray * max_pixel_value)).astype(np.int) # Clip any values that are still larger than max_pixel_value to_clip = (np.greater(inArray.ravel(),max_pixel_value)).sum() if (to_clip>0): # CEBALERT: no explanation of why clipped pixel count is # being accumulated. self.clipped_pixels = self.clipped_pixels + to_clip inArray.clip(0,max_pixel_value,out=inArray) self.verbose("Bitmap: clipped %s image pixels that were out of range",to_clip) r,c = inArray.shape # The size is (width,height), so we swap r and c: newImage = Image.new('L',(c,r),None) newImage.putdata(inArray.ravel()) return newImage
[docs]class PaletteBitmap(Bitmap): """ Bitmap constructed using a single 2D array. The image is monochrome by default, but more colorful images can be constructed by specifying a Palette. """ def __init__(self,inArray,palette=None): """ inArray should have values in the range from 0.0 to 1.0. Palette can be any color scale depending on the type of ColorMap desired. Examples: [0,0,0 ... 255,255,255] = grayscale [0,0,0 ... 255,0,0] = grayscale but through a Red filter. The default palette is grayscale, with 0.0 mapping to black and 1.0 mapping to white. """ ### JABALERT: Should accept a Palette class, not a data ### structure, unless for some reason we want to get rid of ### the Palette classes and always use data structures ### instead. ### JC: not yet properly implemented anyway. max_pixel_value=255 newImage = self._arrayToImage(inArray) if palette == None: palette = [i for i in range(max_pixel_value+1) for j in range(3)] newImage.putpalette(palette) newImage = newImage.convert('P') super(PaletteBitmap,self).__init__(newImage)
[docs]class HSVBitmap(Bitmap): """ Bitmap constructed from 3 2D arrays, for hue, saturation, and value. The hue matrix determines the pixel colors. The saturation matrix determines how strongly the pixels are saturated for each hue, i.e. how colorful the pixels appear. The value matrix determines how bright each pixel is. An RGB image is constructed from the HSV matrices using hsv_to_rgb; the resulting image is of the same type that is constructed by RGBBitmap, and can be used in the same way. """ def __init__(self,hue,sat,val): """Each matrix must be the same size, with values in the range 0.0 to 1.0.""" shape = hue.shape # Assumed same as sat.shape and val.shape rmat = np.zeros(shape, dtype=np.float) gmat = np.zeros(shape, dtype=np.float) bmat = np.zeros(shape, dtype=np.float) # Note: should someday file a feature request for PIL for them # to accept an image of type 'HSV', so that they will do this # conversion themselves, without us needing an explicit loop # here. That should speed this up. ch = hue.clip(0.0,1.0) cs = sat.clip(0.0,1.0) cv = val.clip(0.0,1.0) for i in range(shape[0]): for j in range(shape[1]): r,g,b = hsv_to_rgb(ch[i,j],cs[i,j],cv[i,j]) rmat[i,j] = r gmat[i,j] = g bmat[i,j] = b rImage = self._arrayToImage(rmat) gImage = self._arrayToImage(gmat) bImage = self._arrayToImage(bmat) super(HSVBitmap,self).__init__(Image.merge('RGB',(rImage,gImage,bImage)))
[docs]class RGBBitmap(Bitmap): """ Bitmap constructed from three 2D arrays, for red, green, and blue. Each matrix is used as the corresponding channel of an RGB image. """ def __init__(self,rMapArray,gMapArray,bMapArray): """Each matrix must be the same size, with values in the range 0.0 to 1.0.""" rImage = self._arrayToImage(rMapArray) gImage = self._arrayToImage(gMapArray) bImage = self._arrayToImage(bMapArray) super(RGBBitmap,self).__init__(Image.merge('RGB',(rImage,gImage,bImage)))
[docs]class MontageBitmap(Bitmap): """ A bitmap composed of tiles containing other bitmaps. Bitmaps are scaled to fit in the given tile size, and tiled right-to-left, top-to-bottom into the given number of rows and columns. """ bitmaps = param.List(class_=Bitmap,doc=""" The list of bitmaps to compose.""") rows = param.Integer(default=2, doc=""" The number of rows in the montage.""") cols = param.Integer(default=2, doc=""" The number of columns in the montage.""") shape = param.Composite(attribs=['rows','cols'], doc=""" The shape of the montage. Same as (self.rows,self.cols).""") margin = param.Integer(default=5,doc=""" The size in pixels of the margin to put around each tile in the montage.""") tile_size = param.NumericTuple(default=(100,100), doc=""" The size in pixels of a tile in the montage.""") titles = param.List(class_=str, default=[], doc=""" A list of titles to overlay on the tiles.""") title_pos = param.NumericTuple(default=(10,10), doc=""" The position of the upper left corner of the title in each tile.""") title_options = param.Dict(default={}, doc=""" Dictionary of options for drawing the titles. Dict should contain keyword options for the PIL draw.text method. Possible options include 'fill' (fill color), 'outline' (outline color), and 'font' (an ImageFont font instance). The PIL defaults will be used for any omitted options.""", instantiate=False) hooks = param.List(default=[], doc=""" A list of functions, one per tile, that take a PIL image as input and return a PIL image as output. The hooks are applied to the tile images before resizing. The value None can be inserted as a placeholder where no hook function is needed.""") resize_filter = param.Integer(default=Image.NEAREST,doc=""" The filter used for resizing the images. Defaults to NEAREST. See PIL Image module documentation for other options and their meanings.""") bg_color = param.NumericTuple(default=(0,0,0), doc=""" The background color for the montage, as (r,g,b).""") def __init__(self,**params): ## JPALERT: The Bitmap class is a Parameterized object,but its ## __init__ doesn't take **params and doesn't call super.__init__, ## so we have to skip it. ## JAB: Good point; Bitmap should be modified to be more like ## other PO classes. param.Parameterized.__init__(self,**params) rows,cols = self.shape tilew,tileh = self.tile_size bgr,bgg,bgb = self.bg_color width = tilew*cols + self.margin*(cols*2) height = tileh*rows + self.margin*(rows*2) self.image = Image.new('RGB',(width,height), (bgr*255,bgg*255,bgb*255)) self.title_options.setdefault('font',TITLE_FONT) for r in xrange(rows): for c in xrange(cols): i = r*self.cols+c if i < len(self.bitmaps): bm = self.bitmaps[i] bmw,bmh = bm.image.size if bmw > bmh: bmh = int( float(tilew)/bmw * bmh ) bmw = tilew else: bmw = int( float(tileh)/bmh * bmw ) bmh = tileh if self.hooks and self.hooks[i]: f = self.hooks[i] else: f = lambda x:x new_bm = Bitmap(f(bm.image).resize((bmw,bmh))) if self.titles: draw = ImageDraw.Draw(new_bm.image) draw.text(self.title_pos,self.titles[i],**self.title_options) self.image.paste( new_bm.image, (c * width/cols + tilew/2 - bmw/2 + self.margin, r * height/rows + tileh/2 - bmh/2 + self.margin) ) else: break
[docs]class DrawBitmap(Bitmap): """ Bitmap with primitives drawn for each unit The input matrix has a list of primitives and relative arguments for each unit. """ draw_options = { "fill": "DarkGray", # note that is lighter than Gray! "width": 1 } def __init__(self, primitive_matrix, box_size ): """The overall shape is derived by the sheet shape and the desired magnification""" border = 1 shape = primitive_matrix.shape width = box_size * shape[ 0 ] height = box_size * shape[ 1 ] # seg_len = int( ( box_size - border ) / 2 ) - 1 self.image = Image.new( 'RGB', ( width, height ), 'white' ) dr_img = ImageDraw.Draw( self.image ) for x in range( shape[ 0 ] ): bx = x * box_size for y in range( shape[ 1 ] ): by = y * box_size b0 = ( bx + border, by + border ) b1 = ( bx + box_size - border, by + box_size - border ) dr_img.rectangle( [ b0, b1 ], fill = self.draw_options[ 'fill' ] ) for p in primitive_matrix[ y, x ]: p_name = p.keys()[ 0 ] if not p_name in dir( dr_img ): raise NotImplementedError( p_name + ' is not a valid draw directive' ) val = p[ p_name ] arg = self.__in_box( val[ 0 ], b0, box_size - 2 * border ) opts = val[ 1 ] getattr( dr_img, p_name )( arg, **opts ) def __in_box( self, coordinates, box_corner, seg_len ): """convert normalized coordinates into image coordinates in the given unit box""" in_box_coords = [] for xy in coordinates: in_box_coords.append( ( box_corner[ 0 ] + seg_len * xy[ 0 ], box_corner[ 1 ] + seg_len * xy[ 1 ] ) ) return in_box_coords
__all__ = [ "Bitmap", "PaletteBitmap", "HSVBitmap", "RGBBitmap", "MontageBitmap", "DrawBitmap", ]