import os
from PIL import Image, ImageDraw, ImageOps, ImageFont
from ui.utils import is_sequence_not_string as issequence, Rect
fonts_dir = "ui/fonts/"
font_cache = {}
default_font = None
def get_default_font():
global default_font
if not default_font:
default_font = ImageFont.load_default()
return default_font
from helpers import setup_logger
logger = setup_logger(__name__, "warning")
[docs]class Canvas(object):
"""
This object allows you to work with graphics on the display quicker and easier.
You can draw text, graphical primitives, insert bitmaps and do other things
that the ``PIL`` library allows, with a bunch of useful helper functions.
Args:
* ``o``: output device
* ``base_image``: a `PIL.Image` to use as a base, if needed
* ``name``: a name, for internal usage
* ``interactive``: whether the canvas updates the display after each drawing
"""
height = 0 #: height of canvas in pixels.
width = 0 #: width of canvas in pixels.
image = None #: ``PIL.Image`` object the ``Canvas`` is currently operating on.
size = (0, 0) #: a tuple of (width, height).
background_color = "black" #: default background color to use for drawing
default_color = "white" #: default color to use for drawing
default_font = None #: default font, referenced here to avoid loading it every time
fonts_dir = fonts_dir
def __init__(self, o, base_image=None, name="", interactive=False):
self.o = o
if "b&w" not in o.type:
raise ValueError("The output device supplied doesn't support pixel graphics!")
self.width = o.width
self.height = o.height
self.name = name
self.size = (self.width, self.height)
if base_image:
assert(base_image.size == self.size)
self.image = base_image.copy()
else:
self.image = Image.new(o.device_mode, self.size)
self.draw = ImageDraw.Draw(self.image)
if not self.default_font:
self.default_font = get_default_font()
self.interactive = interactive
def load_image(self, image):
assert(image.size == self.size)
self.image = image.copy()
self.draw = ImageDraw.Draw(self.image)
[docs] def load_font(self, path, size, alias=None, type="truetype"):
"""
Loads a font by its path for the given size, then returns it.
Also, stores the font in the ``canvas.py`` ``font_cache``
dictionary, so that it doesn't have to be re-loaded later on.
Supports both absolute paths, paths relative to root ZPUI
directory and paths to fonts in the ZPUI font directory
(``ui/fonts`` by default).
"""
# For fonts in the font directory, can use the filename as a shorthand
if path in os.listdir(self.fonts_dir):
logger.debug("Loading font from the font storage directory")
path = os.path.join(self.fonts_dir, path)
# If an alias was not specified, using font filename as the alias (for caching)
if alias is None:
alias = os.path.basename(path)
# Adding size to the alias and using it for caching
font_name = "{}:{}".format(alias, size)
logger.debug("Font alias: {}".format(font_name))
# Font already loaded, returning the instance we have
if font_name in font_cache:
logger.debug("Font {} already loaded, returning".format(font_name))
return font_cache[font_name]
# We don't have it cached - let's see the type requested
# We only support loading TrueType fonts, though
elif type == "truetype":
logger.debug("Loading a TT font from {}".format(font_name))
font = ImageFont.truetype(path, size)
else:
raise ValueError("Font type not yet supported: {}".format(type))
# We loaded a font, now let's cache it and return
logger.debug("Caching and returning")
font_cache[font_name] = font
return font
def decypher_font_reference(self, reference):
"""
Is designed to detect the various ways that a ``font`` argument
can be passed into a function, then return an ``ImageFont`` instance.
"""
if reference is None:
return self.default_font
if reference in font_cache:
# Got a font alias
font = font_cache[reference]
elif isinstance(reference, (tuple, list)):
# Got a font path with the size parameter
font = self.load_font(*reference)
elif isinstance(reference, (ImageFont.ImageFont, ImageFont.FreeTypeFont)):
font = reference
else:
return ValueError("Unknown font reference/object, type: {}".format(type(reference)))
return font
[docs] def point(self, coord_pairs, **kwargs):
"""
Draw a point, or multiple points on the canvas. Coordinates are expected in
``((x1, y1), (x2, y2), ...)`` format, where ``x*`` & ``y*`` are coordinates
of each point you want to draw.
Keyword arguments:
* ``fill``: point color (default: white, as default canvas color)
"""
coord_pairs = self.check_coordinate_pairs(coord_pairs)
fill = kwargs.pop("fill", self.default_color)
self.draw.point(coord_pairs, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def line(self, coords, **kwargs):
"""
Draw a line on the canvas. Coordinates are expected in
``(x1, y1, x2, y2)`` format, where ``x1`` & ``y1`` are coordinates
of the start, and ``x2`` & ``y2`` are coordinates of the end.
Keyword arguments:
* ``fill``: line color (default: white, as default canvas color)
* ``width``: line width (default: 0, which results in a single-pixel-wide line)
"""
fill = kwargs.pop("fill", self.default_color)
coords = self.check_coordinates(coords)
self.draw.line(coords, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def text(self, text, coords, **kwargs):
"""
Draw text on the canvas. Coordinates are expected in (x, y)
format, where ``x`` & ``y`` are coordinates of the top left corner.
You can pass a ``font`` keyword argument to it - it accepts either a
``PIL.ImageFont`` object or a tuple of ``(path, size)``, which are
then supplied to ``Canvas.load_font()``.
Do notice that order of first two arguments is reversed compared
to the corresponding ``PIL.ImageDraw`` method.
Keyword arguments:
* ``fill``: text color (default: white, as default canvas color)
"""
assert(isinstance(text, basestring))
fill = kwargs.pop("fill", self.default_color)
font = kwargs.pop("font", self.default_font)
font = self.decypher_font_reference(font)
coords = self.check_coordinates(coords)
if text: # Errors out on empty text
self.draw.text(coords, text, fill=fill, font=font, **kwargs)
self.display_if_interactive()
[docs] def vertical_text(self, text, coords, **kwargs):
"""
Draw vertical text on the canvas. Coordinates are expected in (x, y)
format, where ``x`` & ``y`` are coordinates of the top left corner.
You can pass a ``font`` keyword argument to it - it accepts either a
``PIL.ImageFont`` object or a tuple of ``(path, size)``, which are
then supplied to ``Canvas.load_font()``.
Do notice that order of first two arguments is reversed compared
to the corresponding ``PIL.ImageDraw`` method.
Keyword arguments:
* ``fill``: text color (default: white, as default canvas color)
"""
assert(isinstance(text, basestring))
fill = kwargs.pop("fill", self.default_color)
font = kwargs.pop("font", self.default_font)
charheight = kwargs.pop("charheight", None)
font = self.decypher_font_reference(font)
coords = self.check_coordinates(coords)
char_coords = list(coords)
if not charheight: # Auto-determining charheight if not available
_, charheight = self.draw.textsize("H", font=font)
for char in text:
self.draw.text(char_coords, char, fill=fill, font=font, **kwargs)
char_coords[1] += charheight
self.display_if_interactive()
[docs] def custom_shape_text(self, text, coords_cb, **kwargs):
"""
Draw text on the canvas, getting the position for each character
from a supplied function. Coordinates are expected in (x, y)
format, where ``x`` & ``y`` are coordinates of the top left corner
of the character.
You can pass a ``font`` keyword argument to it - it accepts either a
``PIL.ImageFont`` object or a tuple of ``(path, size)``, which are
then supplied to ``Canvas.load_font()``.
Do notice that order of first two arguments is reversed compared
to the corresponding ``PIL.ImageDraw`` method.
Keyword arguments:
* ``fill``: text color (default: white, as default canvas color)
"""
assert(isinstance(text, basestring))
fill = kwargs.pop("fill", self.default_color)
font = kwargs.pop("font", self.default_font)
charheight = kwargs.pop("charheight", None)
font = self.decypher_font_reference(font)
for i, char in enumerate(text):
coords = coords_cb(i, char)
coords = self.check_coordinates(coords)
self.draw.text(coords, char, fill=fill, font=font, **kwargs)
self.display_if_interactive()
[docs] def rectangle(self, coords, **kwargs):
"""
Draw a rectangle on the canvas. Coordinates are expected in
``(x1, y1, x2, y2)`` format, where ``x1`` & ``y1`` are coordinates
of the top left corner, and ``x2`` & ``y2`` are coordinates
of the bottom right corner.
Keyword arguments:
* ``outline``: outline color (default: white, as default canvas color)
* ``fill``: fill color (default: None, as in, transparent)
"""
coords = self.check_coordinates(coords)
outline = kwargs.pop("outline", self.default_color)
fill = kwargs.pop("fill", None)
self.draw.rectangle(coords, outline=outline, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def polygon(self, coord_pairs, **kwargs):
"""
Draw a polygon on the canvas. Coordinates are expected in
``((x1, y1), (x2, y2), (x3, y3), [...])`` format, where ``xX`` and ``yX``
are points that construct a polygon.
Keyword arguments:
* ``outline``: outline color (default: white, as default canvas color)
* ``fill``: fill color (default: None, as in, transparent)
"""
coord_pairs = self.check_coordinate_pairs(coord_pairs)
outline = kwargs.pop("outline", self.default_color)
fill = kwargs.pop("fill", None)
self.draw.polygon(coord_pairs, outline=outline, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def circle(self, coords, **kwargs):
"""
Draw a circle on the canvas. Coordinates are expected in
``(xc, yx, r)`` format, where ``xc`` & ``yc`` are coordinates
of the circle center and ``r`` is the radius.
Keyword arguments:
* ``outline``: outline color (default: white, as default canvas color)
* ``fill``: fill color (default: None, as in, transparent)
"""
assert(len(coords) == 3), "Expects three arguments - x center, y center and radius!"
radius = coords[2]
coords = coords[:2]
coords = self.check_coordinates(coords)
outline = kwargs.pop("outline", self.default_color)
fill = kwargs.pop("fill", None)
ellipse_coords = (coords[0]-radius, coords[1]-radius, coords[0]+radius, coords[1]+radius)
self.draw.ellipse(ellipse_coords, outline=outline, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def ellipse(self, coords, **kwargs):
"""
Draw a ellipse on the canvas. Coordinates are expected in
``(x1, y1, x2, y2)`` format, where ``x1`` & ``y1`` are coordinates
of the top left corner, and ``x2`` & ``y2`` are coordinates
of the bottom right corner.
Keyword arguments:
* ``outline``: outline color (default: white, as default canvas color)
* ``fill``: fill color (default: None, as in, transparent)
"""
coords = self.check_coordinates(coords)
outline = kwargs.pop("outline", self.default_color)
fill = kwargs.pop("fill", None)
self.draw.ellipse(coords, outline=outline, fill=fill, **kwargs)
self.display_if_interactive()
def arc(self, coords, start, end, **kwargs):
"""
Draw an arc on the canvas. Coordinates are expected in
``(x1, y1, x2, y2)`` format, where ``x1`` & ``y1`` are coordinates
of the top left corner, and ``x2`` & ``y2`` are coordinates
of the bottom right corner. ``start`` and ``end`` angles are
measured in degrees (360 is a full circle), start at 0 (3 o'clock)
and increase *clockwise*.
.. code_block:: python
270
225 315
180 0
135 45
90
Keyword arguments:
* ``fill``: text color (default: white, as default canvas color)
"""
coords = self.check_coordinates(coords)
fill = kwargs.pop("fill", self.default_color)
self.draw.arc(coords, start, end, fill=fill, **kwargs)
self.display_if_interactive()
[docs] def get_image(self):
"""
Get the current ``PIL.Image`` object.
"""
return self.image
[docs] def get_center(self):
"""
Get center coordinates. Will not represent the physical center -
especially with those displays having even numbers as width and height
in pixels (that is, the absolute majority of them).
"""
return self.width / 2, self.height / 2
[docs] def invert(self):
"""
Inverts the image that ``Canvas`` is currently operating on.
"""
image = self.image
# "1" won't invert, need "L"
if image.mode == "1":
image = image.convert("L")
image = ImageOps.invert(image)
# If was converted to "L", setting back to "1"
if image.mode == "L" and self.o.device_mode == "1":
image = image.convert("1")
self.image = image
self.display_if_interactive()
[docs] def display(self):
"""
Display the current image on the ``o`` object that was supplied to
``Canvas``.
"""
self.o.display_image(self.image)
[docs] def clear(self, coords=None, fill=None):
# type: tuple -> None
"""
Fill an area of the image with default background color. If coordinates are
not supplied, fills the whole canvas, effectively clearing it. Uses the
background color by default.
"""
if coords is None:
coords = (0, 0, self.width, self.height)
if fill is None:
fill = self.background_color
coords = self.check_coordinates(coords)
self.rectangle(coords, fill=fill, outline=fill) # paint the background black first
self.display_if_interactive()
[docs] def check_coordinates(self, coords, check_count=True):
# type: tuple -> tuple
"""
A helper function to check and reformat coordinates supplied to
functions. Currently, accepts integer coordinates, as well as strings
- denoting offsets from opposite sides of the screen.
"""
# Checking for string offset coordinates
# First, we need to make coords into a mutable sequence - thus, a list
coords = list(coords)
for i, c in enumerate(coords):
sign = "+"
if isinstance(c, basestring):
if c.startswith("-"):
sign = "-"
c = c[1:]
assert c.isdigit(), "A numeric string expected, received: {}".format(coords[i])
offset = int(c)
dim = self.size[i % 2]
if sign == "+":
coords[i] = dim + offset
elif sign == "-":
coords[i] = dim - offset
elif isinstance(c, float):
logger.warning("Received {} as a coordinate - pixel offsets can't be float, converting to int".format(c))
coords[i] = int(c)
# Restoring the status-quo
coords = tuple(coords)
# Now all the coordinates should be integers - if something slipped by the checks,
# it's of type we don't process and we should raise an exception now
for c in coords:
assert isinstance(c, int), "{} not an integer or 'x' string!".format(c)
if len(coords) == 2:
return coords
elif len(coords) == 4:
x1, y1, x2, y2 = coords
# sanity checks for coordinates
if (x1 >= x2):
x2, x1 = x1, x2
logger.info("x1 ({}) is smaller than x2 ({}), rearranging".format(x1, x2))
if (y1 >= y2):
y2, y1 = y1, y2
logger.info("y1 ({}) is smaller than y2 ({}), rearranging".format(y1, y2))
coords = x1, y1, x2, y2
#assert (y2 >= y1), "y2 ({}) is smaller than y1 ({}), rearrange?".format(y2, y1)
return coords
else:
if check_count:
raise ValueError("Invalid number of coordinates!")
else:
return coords
[docs] def check_coordinate_pairs(self, coord_pairs):
# type: tuple -> tuple
"""
A helper function to check and reformat coordinate pairs supplied to
functions. Each pair is checked by ``check_coordinates``.
"""
if not all([issequence(c) for c in coord_pairs]):
# Didn't get pairs of coordinates - converting into pairs
# But first, sanity checks
assert (len(coord_pairs) % 2 == 0), "Odd number of coordinates supplied! ({})".format(coord_pairs)
assert all([isinstance(c, (int, basestring)) for i in coord_pairs]), "Coordinates are non-uniform! ({})".format(coord_pairs)
coord_pairs = convert_flat_list_into_pairs(coord_pairs)
coord_pairs = list(coord_pairs)
for i, coord_pair in enumerate(coord_pairs):
coord_pairs[i] = self.check_coordinates(coord_pair)
return tuple(coord_pairs)
[docs] def centered_text(self, text, cw=None, ch=None, font=None):
# type: str -> None
"""
Draws centered text on the canvas. This is mostly a convenience function,
used in some UI elements. You can also pass alternate
screen center values so that text is centered related to those,
as opposed to the real screen center.
"""
font = self.decypher_font_reference(font)
coords = self.get_centered_text_bounds(text, font=font, ch=ch, cw=cw)
self.text(text, (coords.left, coords.top), font=font)
self.display_if_interactive()
[docs] def get_text_bounds(self, text, font=None):
# type: str -> Rect
"""
Returns the dimensions for a given text. If you use a
non-default font, pass it as ``font``.
"""
if text == "":
return (0, 0)
font = self.decypher_font_reference(font)
w, h = self.draw.textsize(text, font=font)
return w, h
[docs] def get_centered_text_bounds(self, text, cw=None, ch=None, font=None):
# type: str -> Rect
"""
Returns the coordinates for the text to be centered on the screen.
The coordinates come wrapped in a ``Rect`` object. If you use a
non-default font, pass it as ``font``. You can also pass alternate
screen center values so that text is centered related to those,
as opposed to the real screen center.
"""
w, h = self.get_text_bounds(text, font=font)
# Text center width and height
tcw = w / 2
tch = h / 2
# Real center width and height
rcw, rch = self.get_center()
# If no values supplied as arguments (likely), using the real ones
cw = cw if (cw is not None) else rcw
ch = ch if (ch is not None) else rch
return Rect(cw - tcw, ch - tch, cw + tcw, ch + tch)
[docs] def invert_rect(self, coords):
# type: tuple -> tuple
"""
Inverts the image in the given rectangle region. Is useful for
highlighting a part of the image, for example.
"""
coords = self.check_coordinates(coords)
image_subset = self.image.crop(coords)
if image_subset.mode == "1":
# PIL can't invert "1" mode - need to use "L"
image_subset = image_subset.convert("L")
image_subset = ImageOps.invert(image_subset)
image_subset = image_subset.convert(self.o.device_mode)
else: # Other mode - invert without workarounds
image_subset = ImageOps.invert(image_subset)
self.clear(coords)
self.image.paste(image_subset, (coords[0], coords[1]))
self.display_if_interactive()
# def rotate(self, degrees, expand=True):
# """
# Rotates the image clockwise by the given amount of degrees. If
# expand is set to False part of the original image may be cut
# off.
#
# TODO: define behaviour and goals of this function better.
# For now, doesn't recalculate the canvas size, regenerate the
# ``ImageDraw`` object or impose any restrictions.
# """
#
# self.image = self.image.rotate(degrees, expand=expand)
def paste(self, image_or_path, coords=None, invert=False):
"""
Pastes the supplied image onto the canvas, with optional
coordinates. Otherwise, you can supply a string path to an image
that will be opened and pasted.
If ``coords`` is not supplied, the image will be pasted in the top left
corner. ``coords`` can be a 2-tuple giving the upper left
corner or a 4-tuple defining the left, upper, right and lower
pixel coordinate. If a 4-tuple is given, the size of the pasted
image must match the size of the region.
"""
if coords is not None:
coords = self.check_coordinates(coords)
if isinstance(image_or_path, basestring):
image = Image.open(image_or_path)
else:
image = image_or_path
self.image.paste(image, box=coords)
if invert:
if not coords: coords = (0, 0)
coords = coords+(coords[0]+image.width, coords[1]+image.height)
self.invert_rect(coords)
def display_if_interactive(self):
if self.interactive:
self.display()
def __getattr__(self, name):
if hasattr(self.draw, name):
return getattr(self.draw, name)
raise AttributeError
[docs]class MockOutput(object):
"""
A mock output device that you can use to draw icons and other bitmaps using
``Canvas``.
Keyword arguments:
* ``width``
* ``height``
* ``type``: ZPUI output device type list (``["b&w"]`` by default)
* ``device_mode``: PIL device.mode attribute (by default, ``'1'``)
"""
def __init__(self, width=128, height=64, type=None, device_mode='1'):
self.width = width
self.height = height
self.type = type if type else ["b&w"]
self.device_mode = device_mode
def display_image(self, *args):
return True
def expand_coords(coords, expand_by):
"""
A simple method to expand 4 coordinates: x1, y1, x2, y2.
If expand_by is an integer/float, will do x1-v, y1-v, x2+v, y2+v.
If expand_by is a list of 4 values, will do x1-v1, y1-v1, x2+v2, y2+v2.
"""
if len(coords) != 4:
raise ValueError("expand_coords expects a tuple/list of 4 coordinates for 'coords', got {}".format(coords))
if not isinstance(expand_by, (int, float, list, tuple)):
raise ValueError("expand_coords expects an int/float/list/tuple as 'expand_by', got {} ({})".format(expand_by, type(expand_by)))
if isinstance(expand_by, (list, tuple)) and len(expand_by) != 4:
raise ValueError("expand_coords expects a 4-element list/tuple as 'expand_by', got {} ({} elements)".format(expand_by, len(expand_by)))
a, b, c, d = coords
e = expand_by
if isinstance(expand_by, (int, float)):
return (a-e, b-e, c+e, d+e)
else:
return (a-e[0], b-e[1], c+e[2], d+e[3])
def crop(image, min_width=None, min_height=None, align=None):
bbox = image.getbbox()
print(bbox)
if bbox is None:
return Image.new(image.mode, (0, 0))
image = image.crop(bbox)
border = [0, 0, 0, 0]
if min_width and image.width<min_width:
border[0 if align == "right" else 2]=min_width-image.width
if min_height and image.height<min_height:
border[1 if align == "bottom" else 3]=min_height-image.height
print(border)
if border != [0, 0, 0, 0]:
image = ImageOps.expand(image, border=tuple(border), fill=Canvas.background_color)
return image
def convert_flat_list_into_pairs(l):
pl = []
for i in range(len(l)/2):
pl.append((l[i*2], l[i*2+1]))
return pl