import os
from PIL import Image, ImageDraw, ImageOps, ImageFont, ImageColor
import numpy as np
from zpui_lib.ui.utils import is_sequence_not_string as issequence, Rect
from zpui_lib.helpers import setup_logger, local_path_gen
local_path = local_path_gen(__name__)
logger = setup_logger(__name__, "warning")
fonts_dir = local_path("fonts/")
font_cache = {}
global_default_color = None
default_font = None
def get_default_font():
global default_font
if not default_font:
logger.debug("Loading default font from the font storage directory")
path = os.path.join(fonts_dir, 'courB08.pil')
default_font = ImageFont.load(path)
return default_font
[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
def __init__(self, o, base_image=None, name="", interactive=False):
global global_default_color
self.o = o
if "b&w" not in o.type:
raise ValueError("The output device supplied doesn't support pixel graphics! o.type: {}".format(o.type))
if "color" in o.type: # on color displays:
if global_default_color: # global color has been set to something else
self.default_color = global_default_color
elif self.default_color == "white": # global UI color has not been set, and local color is still set to white
self.default_color = "lightseagreen" # default color on color screens, as an experiment
global_default_color = self.default_color # also setting the global color
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)
self.fonts_dir = fonts_dir
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
[docs] 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, rearrange_coords=False)
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
_, t, _, b = self.draw.textbbox((0, 0), "H", font=font)
charheight = b-t
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 rectangle_wh(self, coords, **kwargs):
"""
Draw a rectangle on the canvas. Coordinates are expected in
``(x1, y1, w, h)`` format, where ``x1`` & ``y1`` are coordinates
of the top left corner, and ``w`` & ``h`` are width and height
of the rectangle.
Keyword arguments:
* ``outline``: outline color (default: white, as default canvas color)
* ``fill``: fill color (default: None, as in, transparent)
This function calls ``rectangle`` internally.
"""
c = list(coords[:2])
c.append(coords[0]+coords[2])
c.append(coords[1]+coords[3])
return self.rectangle(c, **kwargs)
[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()
[docs] 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*.
Arc angle alignment reference:
# 270
# 225 315
# 180 0
# 135 45
# 90
Keyword arguments:
* ``fill``: text color (default: white, as default canvas color)
"""
coords = self.check_coordinates(coords, rearrange_coords=False)
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, coords=None):
"""
Get the current ``PIL.Image`` object.
If ``coords`` are supplied, reeturns a rectangular region
of the image, as defined by ``coords``.
"""
if coords == None:
return self.image
return self.get_rect(coords)
[docs] def get_center(self, x=None, y=None):
"""
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).
You can substitute width and height of your choice as ``x`` and ``y``,
purely so you can quickly center things in arbitrary areas.
"""
cx = self.width // 2 if x == None else x // 2
cy = self.height // 2 if y == None else y // 2
return cx, cy
[docs] def center_box(self, wb, hb, w=None, h=None, return_four=False):
"""
Get coordinates to center a (``wb``, ``wh``) box inside an area of (``w``, ``h``).
Basically, returns coordinates for the top left corner of a centered object.
More or less, is a shorthand to get rid of some annoying math in app code.
"""
if w == None: w = self.width
if h == None: h = self.height
cpx = (w - wb) // 2
cpy = (h - hb) // 2
if return_four:
return (cpx, cpy, cpx+wb, cpy+hb)
else:
return cpx, cpy
[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, rearrange_coords=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.info("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:
# sanity checks for coordinates
if rearrange_coords:
x1, y1, x2, y2 = coords
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
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(i, (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, ox=0, oy=0, font=None):
# type: str -> None
"""
Draws centered text on the canvas. This is mostly a convenience function,
used in some UI elements. You can pass alternate
screen center values (``cw``, ``ch``) so that text is centered related to those,
as opposed to the actual screen center.
You can also pass offsets (``ox`` and ``oy``) - for instance, pass ``oy=-32``
to bring text 32 pixels upwards.
"""
font = self.decypher_font_reference(font)
coords = self.get_centered_text_bounds(text, font=font, ch=ch, cw=cw)
offset_coords = (coords.left + ox, coords.top + oy)
self.text(text, offset_coords, font=font)
self.display_if_interactive()
[docs] def get_text_bounds(self, text, font=None):
# type: str -> Rect
"""
Returns the uncompensated dimensions for a given text. If you use a
non-default font, pass it as ``font``.
"""
l, t, w, h = self.get_text_bounds_compensated(text, font=font)
#print(l, t, r, b)
#w, h = r-l, b-t
return w, h
[docs] def get_text_bounds_compensated(self, text, font=None):
# type: str -> Rect
"""
Returns the compensated dimensions for a given text. If you use a
non-default font, pass it as ``font``.
"""
if text == "":
return (0, 0, 0, 0)
font = self.decypher_font_reference(font)
l, t, r, b = self.draw.textbbox((0, 0), text, font=font)
w, h = r-l, b-t
#print(l, t, w, h, w, h)
return l, t, w, h
[docs] def get_centered_text_bounds(self, text, cw=None, ch=None, x=None, y=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.
"""
l, t, w, h = self.get_text_bounds_compensated(text, font=font)
# Text center width and height
tcw = (w // 2)
tch = (h // 2)
# Real center width and height
rcw, rch = self.get_center(x=x, y=y)
# 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 - l, ch - tch - t, cw + tcw - l, ch + tch - t)
[docs] def get_rect(self, coords):
# type: tuple -> Image
"""
Returns a rectangular region of the image, as defined by ``coords``.
"""
# maybe fold into get_image?
coords = self.check_coordinates(coords)
image_subset = self.image.crop(coords)
return image_subset
[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) # results in an unfun bug??
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)
[docs] def paste(self, image_or_path, coords=None, invert=False, mask="auto"):
"""
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
# mask parameter for alpha channel cognizant pasting
if mask == "auto":
mask = image.mode == "RGBA"
if mask == True:
self.image.paste(image, box=coords, mask=image)
else:
self.image.paste(image, box=coords)
# inverted only after drawing
if invert:
if not coords: coords = (0, 0)
coords = coords+(coords[0]+image.width, coords[1]+image.height)
self.invert_rect(coords)
self.display_if_interactive()
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] def show_grid(self, step_x=20, step_y=20):
"""
Helper function for checking your coordinate placement
"""
for i in range((self.width//step_x)-1):
self.line(((i+1)*step_x, 0, (i+1)*step_x, self.height), fill=self.default_color)
for j in range((self.height//step_y)-1):
self.line((0, (j+1)*step_y, self.width, (j+1)*step_y), fill=self.default_color)
[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'``)
"""
default_width = 128
default_height = 128
default_type = ["b&w"]
default_device_mode = '1'
def __init__(self, width=None, height=None, type=None, device_mode=None, o=None, warn_on_display=True, hook=None):
# now overriding parameters
# first supplied arguments, then o. parameters, then defaults
if isinstance(width, str):
raise ValueError("MockOutput width received as a string {}. Any chance you're doing *params instead of **params?".format(width))
self.width = width if width else (o.width if o else self.default_width)
self.height = height if height else (o.height if o else self.default_height)
self.type = type if type else (o.type if o else self.default_type)
self.device_mode = device_mode if device_mode else (o.device_mode if o else self.default_device_mode)
self.hook = hook
self.warn_on_display = warn_on_display
def display_image(self, image):
if self.warn_on_display:
logger.warning("Trying to call display_image() of MockOutput!")
self.current_image = image
# executing on-display hook if appropriate
if self.hook:
self.hook(self)
return True
[docs]def open_image(path, *args, **kwargs):
"""
Simple wrapper around PIL.Image.open, that simplifies imports somewhat,
and will allow us to improve upon it in the future.
"""
return Image.open(path, *args, **kwargs)
[docs]def invert_image(path, *args, **kwargs):
"""
Simple wrapper around PIL.ImageOps.invert, that simplifies imports somewhat,
and will allow us to improve upon it in the future.
"""
return ImageOps.invert(path, *args, **kwargs)
[docs]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])
[docs]def crop(image, min_width=None, min_height=None, align=None):
"""
Default crop alignment: top left.
You can pass an argument to ``align=`` to align it differently.
You can pass a string like ``"right"``/``"bottom"``/``"hcenter"``/``"vcenter"``,
or pass a list of strings, like ``["right", "vcenter"]``.
``"right"`` and ``"hcenter"`` arguments require you to specify ``min_width``,
and ``"bottom"`` and ``"vcenter"`` arguments require you to specify ``min_height``.
"""
bbox = image.getbbox()
if bbox is None: # empty image
return Image.new(image.mode, (0, 0))
image = image.crop(bbox)
border = [0, 0, 0, 0]
# we process alignment attributes here
if min_width and image.width<min_width:
if "right" in align:
border[0] = min_width - image.width
elif "hcenter" in align:
half = (min_width - image.width)//2
border[0] = half; border[2] = half
else: # default left
border[2] = min_width - image.width
if min_height and image.height<min_height:
if "bottom" in align:
border[1] = min_height - image.height
elif "vcenter" in align:
half = (min_height-image.height)//2
border[1] = half; border[3] = half
else: # default top
border[3] = min_height - image.height
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
[docs]def replace_color(icon, fromc, toc):
icon = icon.convert("RGBA")
# from https://stackoverflow.com/questions/3752476/python-pil-replace-a-single-rgba-color
if isinstance(fromc, str):
fromc = ImageColor.getrgb(fromc)
data = np.array(icon)
r, g, b, a = data.T
areas = (r == fromc[0]) & (g == fromc[1]) & (b == fromc[2])
if isinstance(toc, str):
toc = ImageColor.getrgb(toc)
data[..., :-1][areas.T] = toc
return Image.fromarray(data)
[docs]def swap_colors(icon, fromc1, toc1, fromc2, toc2):
icon = icon.convert("RGBA")
# from https://stackoverflow.com/questions/3752476/python-pil-replace-a-single-rgba-color
if isinstance(fromc1, str):
fromc1 = ImageColor.getrgb(fromc1)
if isinstance(fromc2, str):
fromc2 = ImageColor.getrgb(fromc2)
data = np.array(icon)
r, g, b, a = data.T
areas1 = (r == fromc1[0]) & (g == fromc1[1]) & (b == fromc1[2])
areas2 = (r == fromc2[0]) & (g == fromc2[2]) & (b == fromc2[2])
if isinstance(toc1, str):
toc1 = ImageColor.getrgb(toc1)
if isinstance(toc2, str):
toc2 = ImageColor.getrgb(toc2)
data[..., :-1][areas1.T] = toc1
data[..., :-1][areas2.T] = toc2
return Image.fromarray(data)