#!/usr/bin/env python3 # Copyright (c) 2008-11 Qtrac Ltd. All rights reserved. # This program or module is free software: you can redistribute it and/or # modify it under the terms of the GNU General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. It is provided for educational # purposes and is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. """ This module provides the Image class which holds (x, y, color) triples and a background color to provide a kind of sparse-array representation of an image. A method to export the image in XPM format is also provided. >>> import os >>> import tempfile >>> red = "#FF0000" >>> blue = "#0000FF" >>> img = os.path.join(tempfile.gettempdir(), "test.img") >>> xpm = os.path.join(tempfile.gettempdir(), "test.xpm") >>> image = Image(10, 8, img) >>> for x, y in ((0, 0), (0, 7), (1, 0), (1, 1), (1, 6), (1, 7), (2, 1), ... (2, 2), (2, 5), (2, 6), (2, 7), (3, 2), (3, 3), (3, 4), ... (3, 5), (3, 6), (4, 3), (4, 4), (4, 5), (5, 3), (5, 4), ... (5, 5), (6, 2), (6, 3), (6, 4), (6, 5), (6, 6), (7, 1), ... (7, 2), (7, 5), (7, 6), (7, 7), (8, 0), (8, 1), (8, 6), ... (8, 7), (9, 0), (9, 7)): ... image[x, y] = blue >>> for x, y in ((3, 1), (4, 0), (4, 1), (4, 2), (5, 0), (5, 1), (5, 2), ... (6, 1)): ... image[(x, y)] = red >>> print(image.width, image.height, len(image.colors), image.background) 10 8 3 #FFFFFF >>> border_color = "#FF0000" # red >>> square_color = "#0000FF" # blue >>> width, height = 240, 60 >>> midx, midy = width // 2, height // 2 >>> image = Image(width, height, img, "#F0F0F0") >>> for x in range(width): ... for y in range(height): ... if x < 5 or x >= width - 5 or y < 5 or y >= height -5: ... image[x, y] = border_color ... elif midx - 20 < x < midx + 20 and midy - 20 < y < midy + 20: ... image[x, y] = square_color >>> print(image.width, image.height, len(image.colors), image.background) 240 60 3 #F0F0F0 >>> image.save() >>> newimage = Image(1, 1, img) >>> newimage.load() >>> print(newimage.width, newimage.height, len(newimage.colors), newimage.background) 240 60 3 #F0F0F0 >>> image.export(xpm) >>> image.thing Traceback (most recent call last): ... AttributeError: 'Image' object has no attribute 'thing' >>> for name in (img, xpm): ... try: ... os.remove(name) ... except EnvironmentError: ... pass """ import os import pickle USE_GETATTR = False class ImageError(Exception): pass class CoordinateError(ImageError): pass class LoadError(ImageError): pass class SaveError(ImageError): pass class ExportError(ImageError): pass class NoFilenameError(ImageError): pass class Image: def __init__(self, width, height, filename="", background="#FFFFFF"): """An image represented as HTML-style color values (color names or hex strings) at (x, y) coordinates with any unspecified points assumed to be the background """ self.filename = filename self.__background = background self.__data = {} self.__width = width self.__height = height self.__colors = {self.__background} if USE_GETATTR: def __getattr__(self, name): """ >>> image = Image(10, 10) >>> len(image.colors) == 1 True >>> image.width == image.height == 10 True >>> image.thing Traceback (most recent call last): ... AttributeError: 'Image' object has no attribute 'thing' """ if name == "colors": return set(self.__colors) classname = self.__class__.__name__ if name in frozenset({"background", "width", "height"}): return self.__dict__["_{classname}__{name}".format( **locals())] raise AttributeError("'{classname}' object has no " "attribute '{name}'".format(**locals())) else: @property def background(self): return self.__background @property def width(self): return self.__width @property def height(self): return self.__height @property def colors(self): return set(self.__colors) def __getitem__(self, coordinate): """Returns the color at the given (x, y) coordinate; this will be the background color if the color has never been set """ assert len(coordinate) == 2, "coordinate should be a 2-tuple" if (not (0 <= coordinate[0] < self.width) or not (0 <= coordinate[1] < self.height)): raise CoordinateError(str(coordinate)) return self.__data.get(tuple(coordinate), self.__background) def __setitem__(self, coordinate, color): """Sets the color at the given (x, y), coordinate """ assert len(coordinate) == 2, "coordinate should be a 2-tuple" if (not (0 <= coordinate[0] < self.width) or not (0 <= coordinate[1] < self.height)): raise CoordinateError(str(coordinate)) if color == self.__background: self.__data.pop(tuple(coordinate), None) else: self.__data[tuple(coordinate)] = color self.__colors.add(color) def __delitem__(self, coordinate): """Deletes the color at the given (x, y) coordinate In effect this makes the coordinate's color the background color. """ assert len(coordinate) == 2, "coordinate should be a 2-tuple" if (not (0 <= coordinate[0] < self.width) or not (0 <= coordinate[1] < self.height)): raise CoordinateError(str(coordinate)) self.__data.pop(tuple(coordinate), None) def save(self, filename=None): """Saves the current image, or the one specified by filename If filename is specified the internal filename is set to it. """ if filename is not None: self.filename = filename if not self.filename: raise NoFilenameError() fh = None try: data = [self.width, self.height, self.__background, self.__data] fh = open(self.filename, "wb") pickle.dump(data, fh, pickle.HIGHEST_PROTOCOL) except (EnvironmentError, pickle.PicklingError) as err: raise SaveError(str(err)) finally: if fh is not None: fh.close() def load(self, filename=None): """Loads the current image, or the one specified by filename If filename is specified the internal filename is set to it. """ if filename is not None: self.filename = filename if not self.filename: raise NoFilenameError() fh = None try: fh = open(self.filename, "rb") data = pickle.load(fh) (self.__width, self.__height, self.__background, self.__data) = data self.__colors = (set(self.__data.values()) | {self.__background}) except (EnvironmentError, pickle.UnpicklingError) as err: raise LoadError(str(err)) finally: if fh is not None: fh.close() def export(self, filename): """Exports the image to the specified filename """ if filename.lower().endswith(".xpm"): self.__export_xpm(filename) else: raise ExportError("unsupported export format: " + os.path.splitext(filename)[1]) def __export_xpm(self, filename): """Exports the image as an XPM file if less than 8930 colors are used """ name = os.path.splitext(os.path.basename(filename))[0] count = len(self.__colors) chars = [chr(x) for x in range(32, 127) if chr(x) != '"'] if count > len(chars): chars = [] for x in range(32, 127): if chr(x) == '"': continue for y in range(32, 127): if chr(y) == '"': continue chars.append(chr(x) + chr(y)) chars.reverse() if count > len(chars): raise ExportError("cannot export XPM: too many colors") fh = None try: fh = open(filename, "w", encoding="ascii") fh.write("/* XPM */\n") fh.write("static char *{0}[] = {{\n".format(name)) fh.write("/* columns rows colors chars-per-pixel */\n") fh.write('"{0.width} {0.height} {1} {2}",\n'.format( self, count, len(chars[0]))) char_for_colour = {} for color in self.__colors: char = chars.pop() fh.write('"{char} c {color}",\n'.format(**locals())) char_for_colour[color] = char fh.write("/* pixels */\n") for y in range(self.height): row = [] for x in range(self.width): color = self.__data.get((x, y), self.__background) row.append(char_for_colour[color]) fh.write('"{0}",\n'.format("".join(row))) fh.write("};\n") except EnvironmentError as err: raise ExportError(str(err)) finally: if fh is not None: fh.close() if __name__ == "__main__": import doctest doctest.testmod()