Commits

Anonymous committed a19cae3 Merge

Merge new 2.0.0 version.

Comments (0)

Files changed (4)

 ddcc8814c6abc20bae7e468499b5376183f845e8 1.1.0
 acae39ffa96107b5138502fe363e33576403689c 1.1.1
 3e7ff08b9bfd2951c332e5c2284f6c96f18d0650 1.2.1
+9873b5749cdf2fd5987b78939c0c39f6415d02f1 2.0.0
+9873b5749cdf2fd5987b78939c0c39f6415d02f1 2.0.0
+0000000000000000000000000000000000000000 2.0.0
 #-*- coding: utf-8 -*-
 
 """Create mosaics of input images.
+
+The module offers the possibility to create poster-alike images compound
+by small tiles representing input photos. Moreover, the module could be
+used both as a standalone application and as a standard python library.
+
+Given a list of input images, the first of them will be chosen as
+target, i.e. the final image. On the other hand, other images will be
+chosen in turn, modified, and finally placed in the right position of
+the final mosaic image.
+
+During the creation of a mosaic, we need to arrange the images used
+a tiles, inside a data structure which make it possible to extract
+images by color. Image, at least for early implementations, this
+structure to be a simple list of images.
+
+Moreover, in order to avoid long waits due to indexing of very large
+images, we could implement a sort of filter, or a chain of filters, that
+could eventually be used to scale down input images, or either quantize
+their colors.
+
+The next step is to analyze the target image and look for needed tiles
+for the final mosaic. Depending on the specified number of
+tiles-per-side, we are going to divide the original image in small
+tiles. Then, for each tile, we are going to compute its *fingerprint*,
+which in our case corresponds to its average color.
+
+At this point everything is ready to actually create the mosaic. For
+each tile extracted from the target image, look inside the efficient
+data structure and spot which of the available tiles has an average color
+the most similar to the current one. Then we have to paste such found
+tile in place of the original one.
+
+Once we are done with all the tiles of the target image, we will be able
+to either show the image on screen - watch out from large images ;-) - or
+save it to a disk.
+
+TODO:
+
+    - Use a KD-Tree for the internal logic of the ``ImageList`` object.
+
+    - Resize input if too large. Maybe we could implement an adaptive
+      way to compute the target size depending on the number of
+      specified tiles and zoom level.
+
+    - In order to reduce the amount of work load, we could quantize
+      target and source images.
+
+    - While iterating over the colors of very large images, we could
+      think of using the color histogram to reduce the length of output
+      array.
+
+    - While cropping an image to modify the ratio, we could probably use
+      the image barycenter in order to throw away useless parts of the
+      image.
+
 """
 
 from __future__ import division
+import operator
+from collections import namedtuple
+from itertools import izip
 from optparse import OptionParser
 from optparse import OptionGroup
 from random import randint
 
 
 
+"""Mode of quantization of color components."""
+QUANTIZATION_MODES = 'bottom middle top'.split()
 
-def random_element(seq):
-    """Return a random element from the given sequence."""
-    return seq[randint(0, len(seq) - 1)]
 
 
-def untileify(mosaic):
-    """Transform the given mosaic into an image.
+def dotproduct(vec1, vec2):
+    """Return dot product of given vectors."""
+    return sum(map(operator.mul, vec1, vec2))
 
-    Accepted keywords:
-        mosaic collection of tiles created using ``tileify``.
+
+def difference(vec1, vec2):
+    """Return difference between given vectors."""
+    return map(operator.sub, vec1, vec2)
+
+
+def squaredistance(vec1, vec2):
+    """Return the square distance between given vectors."""
+    return sum(v ** 2 for v in difference(vec1, vec2))
+
+
+def distance(vec1, vec2):
+    """Return the distance between given vectors."""
+    return squaredistance(vec1, vec2) ** 0.5
+
+
+def average_color(img):
+    """Return the average color of the given image.
+    
+    The calculus of the average color has been implemented by looking at
+    each pixel of the image and accumulate each rgb component inside
+    separate counters.
+    
     """
-    (tile_w, tile_h) = mosaic[0][0][0]
-    tiles = len(mosaic)
+    (width, height) = img.size
+    (n, r, g, b) = (0, 0, 0, 0)
+    for (color, many) in img:
+        n += many
+        r += many * color[0]
+        g += many * color[1]
+        b += many * color[2]
+    return (r // n, g // n, b // n)
 
-    image = Image.new("RGB", (tile_w * tiles, tile_h * tiles))
-    for i in xrange(len(mosaic)):
-        for j in xrange(len(mosaic[0])):
-            # pate the tile to the output surface
-            (x, y) = (j * tile_w, i * tile_h)
-            image.paste(mosaic[i][j][2].image, (x, y, x + tile_w, y + tile_h))
-    return image
 
+def quantize_color(color, levels=8, mode='middle'):
+    """Reduce the spectrum of the given color.
 
-def mosaicify(target, sources, tiles=32, zoom=1, output=None):
-    """XXX"""
-    mosaic = Mosaic(target, tiles, zoom)
+    Each color component is forced to assume only certain values instead
+    depending on the specified number of levels needed. If for example
+    instead of 256 different levels, we need only a couple of them, each
+    color component will be mapped into two ranges, namely [0, 128[ and
+    [128, 256[.
 
-    source_tiles = list()
-    for source in sources:
-        try:
-            tile = Tile(Image.open(source))
-            tile.ratio = mosaic.tileratio
-            tile.size = mosaic.tilesize
-            source_tiles.append(tile)
-        except IOError:
-            # Let's try to go on without the failing image: just
-            # print a message.
-            print "Unable to open: %s" % (source)
-    if not source_tiles:
-        raise ValueError("The tile list cannot be empty.")
+    This way, given that multiple colors are possibly mapped on the same
+    range of values and it is possible to decide to use as final output,
+    the bottom, the middle or the top value of those ranges. Carrying on
+    the example above, by default a color like (10, 10, 10), will match
+    the first range of each component; hence, depending on the chosen
+    mode, it will return (0, 0, 0), (64, 64, 64) or (127, 127, 127).
 
-    for (tilesize, color, tile) in mosaic:
-        new_tile = random_element(source_tiles).colorify(color)
-        tile.paste(new_tile)
-        
-    if output:
-        mosaic.save(output)
-    else:
-        mosaic.show()
 
+    """
+    if levels <= 0 or levels > 256:
+        raise ValueError("Number of levels should be in range ]0, 256].")
+    if mode not in QUANTIZATION_MODES:
+        raise ValueError("Mode should be one of %s." %
+                            (' '.join(QUANTIZATION_MODES)))
 
+    if levels == 256:
+        return color
 
-class InvalidInput(Exception):
-    """Raised when the input image can not be read.
+    if mode == 'top':
+        inc = 256 // levels - 1
+    elif mode == 'middle':
+        inc = 256 // levels // 2
+    else: # 'bottom'
+        inc = 0
+
+    # first map each component from the range [0, 256[ to [0, levels[:
+    #       v * (levels - 1) // 255
+    # then remap values to the range of default values [0, 256[, but
+    # this time instead of obtaining all the possible values, we get
+    # only discrete values:
+    #       .. * 256 // levels
+    # finally, depending on the specified mode, grab the bottom, middle
+    # or top value of the result range:
+    #       .. + inc
+    ret = [v * (levels - 1) // 255 * 256 // levels + inc for v in color]
+    return tuple(ret)
+
+
+
+"""Object passed between different functions."""
+ImageTuple = namedtuple('ImageTuple', 'filename color image'.split())
+
+
+class ImageWrapper(object):
+    """Wrapper around the ``Image`` object from the PIL library.
+
+    We need to create our own image api and abstract, to the whole
+    module layer, the underlaying image processing library.
+
     """
-    def __init__(self, input_):
-        super(InvalidInput, self).__init__()
-        self.input_ = input_
 
+    def __init__(self, **kwargs):
+        """Initialize a new image object.
 
-class InvalidOutput(Exception):
-    """Raised when the output image can not be written.
-    """
-    def __init__(self, output):
-        super(InvalidOutput, self).__init__()
-        self.output = output
+        It is possible both to open a new image from scratch, i.e. using
+        its filename, or import raw data from another in-memory object.
+        If both the ``filename`` and ``blob`` fields are specified, then
+        the in-memory data associated to the image, will be taken from
+        the blob.
 
+        """
+        self.filename = kwargs.pop('filename')
+        self._blob = kwargs.pop('blob', None)
+        if self.blob is None:
+            try:
+                self._blob = Image.open(self.filename)
+            except IOError:
+                raise
 
-class Tile(object):
-    """XXX"""
-    
-    def __init__(self, image):
-        """Initialize the underlaying image object.
-        
-        Accepted keywords:
-            image image object to use as tile.
+    def __iter__(self):
+        """Iterate over the colors of the image."""
+        (width, height) = self.size
+        self._iter = iter(self._blob.getcolors(width * height))
+        return self
+
+    def next(self):
+        """Return each color of the image.
+
+        Return consecutive tuples containing the occurrences of a given
+        color, a la ``itertools.groupby``: (color, occurrences).
+
         """
-        self.image = image
+        (many, color) = next(self._iter)
+        return (color, many)
 
     @property
-    def average_color(self):
-        """Return the average color of the tile."""
-        (width, height) = self.image.size
+    def blob(self):
+        """Get image object as implemented by image library."""
+        return self._blob
 
-        (N, R, G, B) = (0, 0, 0, 0)
-        for (n, (r, g, b)) in self.image.getcolors(width * height):
-            N += n
-            R += n * r
-            G += n * g
-            B += n * b
-        return (R // N, G // N, B // N)
+    @property
+    def size(self):
+        """Return a tuple representing the size of the image."""
+        return self._blob.size
+
+    def resize(self, size):
+        """Set the size of the image."""
+        if any(v < 0 for v in size):
+            raise ValueError("Size could not contain negative values.")
+
+        self._blob = self._blob.resize(size)
 
     @property
     def ratio(self):
-        """Return the ratio (width / height) of tile."""
-        (width, height) = self.size
+        """Get the ratio (width / height) of the image."""
+        (width, height) = self._blob.size
         return (width / height)
 
-    @ratio.setter
-    def ratio(self, ratio):
-        """Set the ratio (width / height) of the tile."""
+    def reratio(self, ratio):
+        """Set the ratio (width / height) of the image.
+
+        A consequence of the ratio modification, is image shrink; the
+        size of the result image need to be modified to match desired
+        ratio; consequently, part of the image will be thrown away.
+
+        """
+        if ratio < 0:
+            raise ValueError("Ratio could not assume negative values.")
+
         (width, height) = self.size
         if (width / height) > ratio:
-            (width, height) = (ratio * height, height)
+            (new_width, new_height) = (int(ratio * height), height)
         else:
-            (width, height) = (width, width / ratio)
-        self.image = self.image.crop((0, 0, int(width), int(height)))
+            (new_width, new_height) = (width, int(width / ratio))
+        (x, y) = ((width - new_width) / 2, (height - new_height) / 2)
+        rect = (x, y, x + new_width, y + new_height)
+        self._blob = self._blob.crop(map(int, rect))
 
-    @property
-    def size(self):
-        """Return a tuple representing the size of the tile."""
-        return self.image.size
+    def crop(self, rect):
+        """Crop the image matching the given rectangle.
 
-    @size.setter
-    def size(self, size):
-        """Set the size of the tile."""
-        self.image = self.image.resize(size)
+        The rectangle is a tuple containing top-left and bottom-right
+        points: (x1, y1, x2, y2)
 
-    def paste(self, tile):
-        """Substitute the content of the tile with the given one.
+        """
+        if any(v < 0 for v in rect):
+            raise ValueError("Rectangle could not contain negative values.")
+        return ImageWrapper(filename=self.filename, blob=self.blob.crop(rect))
 
-        Accepted keywords:
-            tile a Tile object.
-        """
-        (width, height) = tile.size
-        self.image.paste(tile.image, (0, 0, width, height))
+    def paste(self, image, rect):
+        """Paste given image over the current one."""
+        self._blob.paste(image._blob, rect)
 
-    def colorify(self, color):
-        """Apply a colored layer over the tile.
         
-        Accepted keywords:
-            color tuple containing the RGB values of the layer.
-
-        Return:
-            The new colored Tile.
-        """
-        overlay = Image.new("RGB", self.size, color)
-        return Tile(ImageChops.multiply(self.image, overlay))
-
-
-class Mosaic(object):
-    """XXX"""
-
-    def __init__(self, target, tiles=32, zoom=1):
-        """Initialize the rendering object.
-        
-        Accepted keywords:
-            target name of the file we which to *mosaicify*.
-            tiles number of tiles to use per dimention.
-            zoom zoom factor handy for resizing the mosaic.
-
-        Raise:
-            InvalidInput, ValueError.
-        """
-        if tiles <= 0:
-            raise ValueError("The number of tiles cannot be smaller than 0.")
-        if zoom <= 0:
-            raise ValueError("Zoom level cannot be smaller than 0.")
-
-        try:
-            image = Image.open(target)
-        except IOError:
-            raise InvalidInput(target)
-
-        (width, height) = image.size
-        (tile_w, tile_h) = (width // tiles, height // tiles)
-        (zoomed_tile_w, zoomed_tile_h) = \
-                (width * zoom // tiles, height * zoom// tiles)
-
-        self.mosaic = [[None for i in xrange(tiles)] for j in xrange(tiles)]
-        for i in xrange(tiles):
-            for j in xrange(tiles):
-                (x, y) = (j * tile_w, i * tile_h)
-                tile = Tile(image.crop((x, y, x + tile_w, y + tile_h)))
-                color = tile.average_color
-                tile.size = (zoomed_tile_w, zoomed_tile_h)
-                self.mosaic[i][j] = ((zoomed_tile_w, zoomed_tile_h),
-                                     color,
-                                     tile)
-
-        # internal state used while iterating over the tiles.
-        self.i = self.j = 0
-
-    def __iter__(self):
-        """Add iteration support."""
-        return self
-
-    def next(self):
-        """Return one by one the tiles used by the mosaic.
-
-        The internal state is held by ``i`` and ``j`` variables.
-
-        Return:
-            The next tile used.
-
-        Raise:
-            StopIteration.
-        """
-        if self.i == len(self.mosaic):
-            self.i == self.j == 0
-            raise StopIteration()
-
-        tile = self.mosaic[self.i][self.j]
-
-        self.j += 1
-        if self.j == len(self.mosaic):
-            self.i += 1
-            self.j = 0
-
-        return tile
-
-    @property
-    def tileratio(self):
-        """Return the ratio (width / height) of the used tiles."""
-        (width, height) = self.tilesize
-        return (width / height)
-
-    @property
-    def tilesize(self):
-        """Return the size of used tiles."""
-        return self.mosaic[0][0][0]
+    def show(self):
+        """Display the image on screen."""
+        self.blob.show()
 
     def save(self, filename):
-        """Save the mosaic on a file.
+        """Save the image onto the specified file."""
+        self.blob.save(filename)
 
-        Accepted keywords:
-            filename path of the destination file to create.
 
-        Raise:
-            InvalidOutput.
+class ImageList(object):
+    """List of images, optimized for color similarity searches.
+    
+    The class should be though as the implementation of a database of
+    images; in particular, its implementation will be optimized for
+    queries asking for similar images, where the similarity metric is
+    based on the average color.
+
+    """
+
+    def __init__(self, iterable=None, **kwargs):
+        """Initialize the internal list of images.
+
+        Other than the list of filenames representing the images to
+        index, it will come in handy to either pre-process or post-process
+        indexed images: hence users could specify ``prefunc`` and
+        ``postfunc`` functions while creating a new list of images. In
+        particular, in order to implement the possibility to pass
+        additional arguments to filter functions, everything, included
+        the functions, should be passed as *keyword* arguments.
+
         """
-        try:
-            untileify(self.mosaic).save(filename)
-        except IOError:
-            raise InvalidOutput(filename)
+        self._img_list = dict()
+        prefunc = kwargs.pop('prefunc', None)
+        postfunc = kwargs.pop('postfunc', None)
 
-    def show(self):
-        """show the mosaic on screen."""
-        untileify(self.mosaic).show()
+        if iterable is None:
+            raise ValueError("Empty image list.")
+
+        for name in iterable:
+            img = ImageWrapper(filename=name)
+
+            if prefunc is not None:
+                img = prefunc(img, **kwargs)
+
+            color = average_color(img)
+
+            if postfunc is not None:
+                img = postfunc(img, **kwargs)
+
+            self.insert(ImageTuple(name, color, img))
+
+    def __len__(self):
+        """Get the length of the list of images."""
+        return len(self._img_list)
+
+    def insert(self, image):
+        """Insert a new image in the list.
+        
+        Objects enqueued in the list are dictionaries containing the
+        minimal amount of meta-data required to handle images, namely the
+        name of the image, its average color (we cache the value), and
+        a blob object representing the raw processed image. Note that
+        after the application of the ``postfunc`` filter, it is possible
+        for the blob object to be None.
+
+        """
+        # create two levels of hierarchy by first indexing group of
+        # images having the same quantized average color.
+        qcolor = quantize_color(image.color)
+        self._img_list.setdefault(qcolor, list()).append(image)
+
+    def search(self, color):
+        """Search the most similar image in terms of average color."""
+        # first find the group of images having the same quantized
+        # average color.
+        qcolor = quantize_color(color)
+        best_img_list = None
+        best_dist = None
+        for (img_list_color, img_list) in self._img_list.iteritems():
+            dist = squaredistance(qcolor, img_list_color)
+            if best_dist is None or dist < best_dist:
+                best_dist = dist
+                best_img_list = img_list
+        # now spot which of the images in the list is equal to the
+        # target one.
+        best_img = None
+        best_dist = None
+        for img_wrapper in best_img_list:
+            dist = squaredistance(color, img_wrapper.color)
+            if best_dist is None or dist < best_dist:
+                best_dist = dist
+                best_img = img_wrapper
+        # finally return the best match.
+        return best_img
+
+
+def resizefunc(img, **kwargs):
+    """Adjust the size of the given image.
+
+    First, the ratio of the image is modified in order to match an
+    eventually specified one. Then the size of the image is modified
+    accordingly.
+
+    """
+    ratio = kwargs.pop('ratio', None)
+    size = kwargs.pop('size', None)
+
+    if ratio is not None:
+        img.reratio(ratio)
+
+    if size is not None:
+        img.resize(size)
+
+    return img
+
+
+def voidfunc(img, **kwargs):
+    """Do nothing special from returning the image as is."""
+    return img
+
+
+def deletefunc(img, **kwargs):
+    """Delete input image and return None.
+    
+    GC will do all the magic for us, hence we have nothing special to do
+    here: just return None, or *pass*.
+    
+    """
+    pass
+
+
+
+def tilefy(img, tiles):
+    """Convert input image into a matrix of tiles.
+
+    Return a matrix composed by tile-objects, i.e. dictionaries,
+    containing useful information for the final mosaic.
+    
+    In our particular case we are in need of the average color of the
+    region representing a specific tile. For compatibility with the
+    objects used for the ``ImageList`` we set the filename and blob
+    fields either.
+
+    """
+    matrix = [[None for i in xrange(tiles)] for j in xrange(tiles)]
+    (width, height) = img.size
+    (tile_width, tile_height) = (width // tiles, height // tiles)
+    (x, y) = (0, 0)
+    for (i, y) in enumerate(xrange(0, tile_height * tiles, tile_height)):
+        for (j, x) in enumerate(xrange(0, tile_width * tiles, tile_width)):
+            rect = (x, y, x + tile_width, y + tile_height)
+            tile = img.crop(rect)
+            matrix[i][j] = ImageTuple(img.filename, average_color(tile), None)
+    return matrix
+
+
+def mosaicify(target, sources, tiles=32, zoom=1, output=None):
+    """Create mosaic of photos.
+    
+    The function wraps all process of the creation of a mosaic, given
+    the target, the list of source images, the number of tiles to use
+    per side, the zoom level (a.k.a.  how large the mosaic will be), and
+    finally if we want to display the output on screen or dump it on
+    a file.
+
+    First, open the target image, divide it into the specified number of
+    tiles, and store information about the tiles average color. In
+    order to reduce the amount of used memory, we will free the *blobs*
+    associated to each processed image, as soon as possible, aka inside
+    the ``postfunc`` function.
+
+    Then, index all the source images by color. Given that we are aware
+    about the size and the ratio of the tiles of the target, we can use
+    the ``prefunc`` to reduce the dimension of the image; consequently
+    the amount of computations needed to compute the average color will
+    smaller. Moreover, as in the previous paragraph, there is no need to
+    keep into processed images, hence we are going to use the
+    ``postfunc`` method to delete them.
+
+    Finally, for each tile extracted from the target image, we need to
+    find the most similar contained inside the list of source images,
+    and paste it in the right position inside the mosaic image.
+
+    When done, show the result on screen or dump it on the disk.
+
+    """
+    # open target image, and divide it into tiles..
+    img = ImageWrapper(filename=target)
+    tile_matrix = tilefy(img, tiles)
+    # ..process and sort all source tiles..
+    tile_ratio = img.ratio
+    (width, height) = img.size
+    (tile_width, tile_height) = (zoom * width // tiles, zoom * height // tiles)
+    tile_size = (tile_width, tile_height)
+    source_list = ImageList(sources, prefunc=resizefunc, postfunc=voidfunc,
+                            ratio=tile_ratio, size=tile_size)
+    # ..prepare output image..
+    (mosaic_width, mosaic_height) = (tiles * tile_width, tiles * tile_height)
+    mosaic_size = (mosaic_width, mosaic_height)
+    img.resize(mosaic_size)
+    # ..and start to paste tiles
+    for (i, tile_row) in enumerate(tile_matrix):
+        for (j, tile) in enumerate(tile_row):
+            (x, y) = (tile_width * j, tile_height * i)
+            rect = (x, y, x + tile_width, y + tile_height)
+            closest = source_list.search(tile.color)
+            closest_img = closest.image
+            img.paste(closest_img, rect)
+    # finally show the result, or dump it on a file.
+    if output is None:
+        img.show()
+    else:
+        img.save(output)
 
 
 
         parser.print_help()
         exit(1)
 
-    try:
-        mosaicify(
-            target=args[0],
-            sources=set(args[1:] or args),
-            tiles=int(options.tiles),
-            zoom=int(options.zoom),
-            output=options.output,
-        )
-    except InvalidInput, e:
-        print "Input image '%s' can not be read." % e.input_
-    except InvalidOutput, e:
-        print "Output image '%s' can not be written." % e.output
+    mosaicify(
+        target=args[0],
+        sources=set(args[1:] or args),
+        tiles=int(options.tiles),
+        zoom=int(options.zoom),
+        output=options.output,
+    )
 
 
 if __name__ == '__main__':
 from setuptools import setup
 
 
-VERSION = '1.2.1'
+VERSION = '2.0.0'
 NAME = 'osaic'
 MODULES = [NAME]
 DESCRIPTION = 'Create mosaics from images with ``python -mosaic image``'

test/test_osaic.py

+#!/usr/bin/env python
+#-*- coding: utf-8 -*-
+
+import os
+import sys
+import unittest
+
+import Image
+
+from osaic import dotproduct
+from osaic import difference
+from osaic import squaredistance
+from osaic import distance
+from osaic import average_color
+from osaic import quantize_color
+from osaic import ImageWrapper
+from osaic import ImageList
+
+
+COLORS = 'red green blue'
+IMGCOLORS = None
+
+
+def imgcolors_init():
+    global IMGCOLORS
+    img_list = []
+    for color in COLORS.split():
+        filename = os.path.join(sys.path[0], color + '.png')
+        img = Image.new("RGB", (288, 288), color)
+        img.save(filename)
+        img_list.append(filename)
+    IMGCOLORS = img_list
+
+
+def imgcolors_fini():
+    for filename in IMGCOLORS:
+        os.unlink(filename)
+
+
+def setUpModule():
+    imgcolors_init()
+
+
+def tearDownModule():
+    imgcolors_fini()
+
+
+
+class TestFunctions(unittest.TestCase):
+
+    def test_vectors(self):
+        v1 = [1, 2, 3, 4]
+        v2 = [5, 6, 7, 8]
+        v3 = [0, 0, 0, 0]
+        # dot product
+        self.assertEquals(70, dotproduct(v1, v2))
+        self.assertEquals(0, dotproduct(v1, v3))
+        # vector difference
+        self.assertEquals([4, 4, 4, 4], difference(v2, v1))
+        self.assertEquals(v2, difference(v2, v3))
+        # squaredisance
+        self.assertEquals(30, squaredistance(v1, v3))
+        self.assertEquals(64, squaredistance(v2, v1))
+        # distance
+        self.assertEquals(30 ** .5, distance(v1, v3))
+        self.assertEquals(8, distance(v2, v1))
+
+    def test_average(self):
+        # XXX
+        pass
+
+    def test_quantize(self):
+        red = (255, 0, 0)
+        # sanity checks
+        self.assertRaises(ValueError, quantize_color, red, 0)
+        self.assertRaises(ValueError, quantize_color, red, 257)
+        self.assertRaises(ValueError, quantize_color, red, 128, 'asd')
+        # noop
+        self.assertEquals(red, quantize_color(red, 256))
+        # misc quantization
+        self.assertEquals((192, 0, 0), quantize_color(red, 4, 'bottom'))
+        self.assertEquals((224, 32, 32), quantize_color(red, 4, 'middle'))
+        self.assertEquals((255, 63, 63), quantize_color(red, 4, 'top'))
+
+
+
+class TestImageWrapper(unittest.TestCase):
+
+    def test_init(self):
+        # ``filename`` is mandatory.
+        self.assertRaises(KeyError, ImageWrapper)
+        # open a not existing image, hence raise IOError
+        self.assertRaises(IOError, ImageWrapper, filename='foo')
+        # open an existing file.
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        # open a not existing image but specify the blob field;
+        img1 = ImageWrapper(filename='foo', blob=img.blob)
+
+    def test_size(self):
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        # double initial size
+        (width, height) = img.size
+        (dwidth, dheight) = (2 * width, 2 * height)
+        img.resize((dwidth, dheight))
+        self.assertEqual((dwidth, dheight), img.size)
+        # check wrong values
+        img.resize((0, 0))
+        self.assertRaises(ValueError, img.resize, (-1, 0))
+        self.assertRaises(ValueError, img.resize, (0, -1))
+        self.assertRaises(ValueError, img.resize, (-100, -100))
+
+    def test_ratio(self):
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        # double initial size, keeping ratio constant
+        ratio = img.ratio
+        (width, height) = img.size
+        (dwidth, dheight) = (2 * width, 2 * height)
+        img.resize((dwidth, dheight))
+        self.assertEqual(ratio, img.ratio)
+        # invert width and height dimensions
+        img.resize((height, width))
+        self.assertEqual(1 / ratio, img.ratio)
+        # change the ratio
+        nratio = 3 / 1
+        img.reratio(nratio)
+        self.assertEqual(nratio, img.ratio)
+        # check wrong values
+        self.assertRaises(ValueError, img.reratio, -1)
+        self.assertRaises(ValueError, img.reratio, -1000)
+
+    def test_crop(self):
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        # crop half width
+        (width, height) = img.size
+        rect = (0, 0, width // 2, height)
+        img1 = img.crop(rect)
+        self.assertEqual((width // 2, height), img1.size)
+        # crop half height
+        (width, height) = img.size
+        rect = (0, 0, width, height // 2)
+        img1 = img.crop(rect)
+        self.assertEqual((width, height // 2), img1.size)
+        # finally crop both dimensions
+        (width, height) = img.size
+        rect = (0, 0, width // 2, height // 2)
+        img1 = img.crop(rect)
+        self.assertEqual((width // 2, height // 2), img1.size)
+
+    def test_paste(self):
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        img1 = ImageWrapper(filename=IMGCOLORS[1])
+        # paste ``img`` on the first on the left part of ``img1``
+        color = average_color(img)
+        color1 = average_color(img1)
+        (width, height) = img1.size
+        (new_width, new_height) = (width // 2, height)
+        img.resize((new_width, new_height))
+        img1.paste(img, (0, 0, new_width, new_height))
+        self.assertNotEquals(color1, average_color(img1))
+        # paste it again on the right.
+        img1.paste(img, (new_width, 0, width, height))
+        self.assertEquals(color, average_color(img1))
+
+    def test_show_and_save(self):
+        img = ImageWrapper(filename=IMGCOLORS[0])
+        # it is not easy to test such a feature: for the moment just for
+        # the method presence. XXX
+        self.assertEquals(True, hasattr(img, 'show'))
+        # same as before, but for the ``save`` method. Too lazy for
+        # these ;-) XXX
+        self.assertEquals(True, hasattr(img, 'save'))
+
+
+
+class TestImageList(unittest.TestCase):
+
+    def test_init(self):
+        def count(img, **kwargs):
+            kwargs['foo'][0] += 1
+            return img
+        # the list of source images is mandatory
+        self.assertRaises(ValueError, ImageList)
+        # create a list of images composed by a single element; in
+        # addiction verify that both ``prefunc`` and ``postfunc`` get
+        # invoked one time each.
+        sources = IMGCOLORS[0:1]
+        counter = [0] # we need to pass an integer by reference ;-)
+        il = ImageList(sources, prefunc=count, postfunc=count, foo=counter)
+        self.assertEqual(len(sources), len(il))
+        self.assertEqual(2 * len(sources), counter[0])
+        # now with the whole color array
+        sources = IMGCOLORS
+        counter = [0] # we need to pass an integer by reference ;-)
+        ImageList(sources, prefunc=count, postfunc=count, foo=counter)
+        self.assertEqual(2 * len(sources), counter[0])
+
+    def test_search(self):
+        def void(img, **kwargs):
+            return img
+        def skip(img, **kwargs):
+            pass
+        sources = IMGCOLORS
+        il = ImageList(sources, prefunc=void, postfunc=skip)
+        # search a red picture.
+        img_tuple = il.search((255, 0, 0))
+        self.assertEqual(IMGCOLORS[0], img_tuple.filename)
+        self.assertEqual((255, 0, 0), img_tuple.color)
+        self.assertEqual(None, img_tuple.image)
+        # now a green one..
+        img_tuple = il.search((0, 255, 0))
+        self.assertEqual(IMGCOLORS[1], img_tuple.filename)
+        # finally the blue one
+        img_tuple = il.search((0, 0, 255))
+        self.assertEqual(IMGCOLORS[2], img_tuple.filename)
+
+
+
+if __name__ == '__main__':
+    unittest.main()
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.