from __future__ import annotations
import functools
import logging
from typing import Union
import numpy as np
import arlunio as ar
[docs]class Mask(np.ndarray):
"""A mask is just a boolean numpy array.
They are typically used to represent 'selections' for various operations such as
when coloring a region of an image.
"""
def __new__(cls, arr):
return np.asarray(arr).view(cls)
def __mul__(self, other):
try:
return np.logical_and(self, other)
except ValueError:
mask = self.copy()
size = np.prod(other.shape)
if mask[mask].shape == size:
mask[mask] = other.reshape(size)
return mask
raise
def __neg__(self):
return np.logical_not(self)
def __sub__(self, other):
return np.logical_and(self, np.logical_not(other))
def __rsub__(self, other):
return np.logical_and(other, np.logical_not(self))
[docs] @classmethod
def empty(cls, *shape):
"""Return an empty mask with the given shape.
Example
-------
>>> from arlunio.mask import Mask
>>> Mask.empty(3, 4)
Mask([[False, False, False, False],
[False, False, False, False],
[False, False, False, False]])
"""
if len(shape) == 1 and isinstance(shape[0], tuple):
return cls(np.full(shape[0], False))
return cls(np.full(shape, False))
[docs] @classmethod
def full(cls, *shape):
"""Return a full mask with the given shape.
Example
-------
>>> from arlunio.mask import Mask
>>> Mask.full(3, 4)
Mask([[ True, True, True, True],
[ True, True, True, True],
[ True, True, True, True]])
"""
if len(shape) == 1 and isinstance(shape[0], tuple):
return cls(np.full(shape[0], True))
return cls(np.full(shape, True))
[docs]@ar.definition
def Empty(width: int, height: int) -> Mask:
"""An empty mask.
Example
-------
>>> from arlunio.mask import Empty
>>> empty = Empty()
>>> empty(width=4, height=3)
Mask([[False, False, False, False],
[False, False, False, False],
[False, False, False, False]])
"""
return Mask.empty(height, width)
[docs]@ar.definition
def Full(width: int, height: int) -> Mask:
"""A full mask.
Example
-------
>>> from arlunio.mask import Full
>>> full = Full()
>>> full(width=4, height=3)
Mask([[ True, True, True, True],
[ True, True, True, True],
[ True, True, True, True]])
"""
return Mask.full(height, width)
@ar.definition(operation=ar.Defn.OP_ADD)
def MaskAdd(
width: int, height: int, *, a: ar.Defn[Mask] = Empty(), b: ar.Defn[Mask] = Empty()
) -> Mask:
"""Add any two mask producing definitions together.
The resulting defintion will return a mask that is :code:`True` if a given point
is :code:`True` in either :code:`a` or :code:`b`.
Attributes
----------
a:
The first mask
b:
The second mask
"""
return a(width=width, height=height) + b(width=width, height=height)
@ar.definition(operation=ar.Defn.OP_SUB)
def MaskSub(
width: int, height: int, *, a: ar.Defn[Mask] = Full(), b: ar.Defn[Mask] = Empty()
) -> Mask:
"""Subtract one mask away from another mask.
The resulting definition will return a mask that is :code:`True` only if a given
point is in :code:`a` **and not** in :code:`b`.
.. note::
You will get very different results depending on which way around you set
:code:`a` and :code:`b`!
Attributes
----------
a:
The first "base" mask
b:
The second mask that defines the region to remove from :code:`a`
"""
return a(width=width, height=height) - b(width=width, height=height)
@ar.definition(operation=ar.Defn.OP_MUL)
def MaskMul(
width: int, height: int, *, a: ar.Defn[Mask] = Full(), b: ar.Defn[Mask] = Full()
) -> Mask:
"""Muliply any two mask producing definitions together.
The resulting definition will return a mask that is :code:`True` only if a given
point is in both :code:`a` and :code:`b`.
Attributes
----------
a:
The first mask
b:
The second mask
"""
return a(width=width, height=height) * b(width=width, height=height)
[docs]def any_(*args: Union[bool, np.ndarray, Mask]) -> Mask:
"""Given a number of conditions, return :code:`True` if any of the conditions
are true.
This function is implemented as a thin wrapper around numpy's
:data:`numpy:numpy.logical_or` function so that it can take an arbitrary number of
inputs. This also means that this function will accept arrays of differing sizes,
assuming that they can be broadcasted to a common shape.
Parameters
----------
args:
A number of boolean conditions, a condition can either be a single boolean value
or a numpy array of boolean values.
Examples
--------
>>> import arlunio.mask as mask
>>> mask.any_(True, False, False)
Mask(True)
>>> mask.any_(False, False, False, False)
Mask(False)
If the arguments are boolean numpy arrays, then the any condition is applied
element-wise
>>> import numpy as np
>>> x1 = np.array([True, False, True])
>>> x2 = np.array([False, False, True])
>>> x3 = np.array([False, True, False])
>>> mask.any_(x1, x2, x3)
Mask([ True, True, True])
The arguments can be any mixture of booleans, arrays and masks.
>>> mask.any_(
... False,
... mask.Mask([True, False]),
... np.array([[False, True], [True, False]])
... )
Mask([[ True, True],
[ True, False]])
See Also
--------
:doc:`numpy:user/basics.broadcasting`
Numpy documentation on broadcasting.
:doc:`numpy:user/theory.broadcasting`
Further background on broadcasting.
:data:`numpy:numpy.logical_or`
Reference documentation on the :code:`numpy.logical_or` function
"""
return Mask(functools.reduce(np.logical_or, args))
[docs]def all_(*args: Union[bool, np.ndarray, Mask]) -> Mask:
"""Given a number of conditions, return :code:`True` only if **all**
of the given conditions are true.
This function is implemented as a thin wrapper around numpy's
:data:`numpy:numpy.logical_and` function so that it can take an arbitrary number of
inputs. This also means that this function will accept arrays of differing sizes,
assuming that they can be broadcasted to a common shape.
Parameters
----------
args:
A number of boolean conditions, a conditon can either be a single boolean value,
or a numpy array of boolean values.
Examples
--------
>>> import arlunio.mask as mask
>>> mask.all_(True, True, True)
Mask(True)
>>> mask.all_(True, False, True, True)
Mask(False)
If the arguments are boolean numpy arrays, then the any condition is applied
element-wise
>>> import numpy as np
>>> x1 = np.array([True, False, True])
>>> x2 = np.array([False, False, True])
>>> x3 = np.array([False, True, True])
>>> mask.all_(x1, x2, x3)
Mask([False, False, True])
Arugments can be any mixture of booleans, masks and numpy arrays.
>>> mask.all_(
... True,
... mask.Mask([True, False]),
... np.array([[False, True], [True, False]])
... )
Mask([[False, False],
[ True, False]])
See Also
--------
:doc:`numpy:user/basics.broadcasting`
Numpy documentation on broadcasting.
:doc:`numpy:user/theory.broadcasting`
Further background on broadcasting.
:data:`numpy:numpy.logical_and`
Reference documentation on the :code:`logical_and` function.
"""
return Mask(functools.reduce(np.logical_and, args))
[docs]@ar.definition
def Repeat(width: int, height: int, *, n=4, m=None, defn=None) -> Mask:
"""Given a mask producing definition, replicate the resulting mask in a grid.
.. arlunio-image:: Simple Grid
::
import arlunio.image as image
import arlunio.mask as mask
import arlunio.shape as shape
pattern = mask.Repeat(defn=shape.Circle())
img = image.fill(pattern(width=256, height=256))
When evaluated this will create a mask with the given :code:`width` and
:code:`height`. It will then subdivide it into an :math:`n \\times m` grid where
each cell contains a copy of the mask as produced by the definition specified with
the :code:`defn` attribute.
It's important to note that the given definition must only take :code:`width` and
:code:`height` as inputs.
.. note::
Due to a limitation in the current implementation, you will get the best results
if your :math:`n \\times m` grid divides cleanly into the resolution of the final
mask. Otherwise you will find that the generated grid won't completly fill it.
Attributes
----------
n:
The number of times to repeat the given definition horizontally
m:
The number of times to repeat the given definition vertically. If :code:`None`
this defaults to the value of :code:`n`
defn:
The instance of the definition to replicate.
Examples
--------
.. arlunio-image:: Circular Pattern
:gallery: examples
:include-code:
:width: 50%
A pattern generated from circles::
import arlunio as ar
import numpy as np
import arlunio.image as image
import arlunio.math as math
import arlunio.mask as mask
import arlunio.shape as shape
@ar.definition
def Template(x: math.X, y: math.Y) -> mask.Mask:
c = shape.Circle(xc=0.4, yc=0.4, pt=0.02)
return c(x=np.abs(x), y=np.abs(y))
pattern = mask.Repeat(defn=Template(scale=1.))
img = image.fill(
pattern(width=1080, height=1080), background="#000", foreground="#ff0"
)
.. arlunio-image:: Checkerboard
:gallery: examples
:include-code:
:width: 50%
A checkerboard::
import arlunio.image as image
import arlunio.mask as mask
import arlunio.pattern as pattern
grid = mask.Repeat(defn=pattern.Checker())
img = image.fill(grid(width=1080, height=1080), background="white")
"""
if m is None:
m = n
bg = np.full((height, width), False)
# Draw the shape at a size determined by the size of the grid
s_height, s_width = height // m, width // n
mask = defn(width=s_width, height=s_height)
# Let numpy handle the repeating of the shape across the image.
pattern = np.tile(mask, (m, n))
# Apply the pattern to the background, depending on the grid size and
# image dimensions align, the generated grid may not perfectly fill the
# image.
p_height, p_width = pattern.shape
bg[:p_height, :p_width] = pattern
return bg
[docs]@ar.definition
def Map(width: int, height: int, *, layout=None, legend=None, fill=None) -> Mask:
"""Build a mask composed out of smaller, simpler masks.
When evaluated this will produce a mask with the given :code:`width` and
:code:`height` and divide it into a grid. The dimensions of this grid are determined
by shape of the :code:`layout` array.
The :code:`layout` attribute should be set to a 2D array the elements of which can
be anything. While the :code:`legend` attribute is set to a dictionary whose keys
correspond to values in the :code:`layout` array. These keys should then map to mask
producing definitions. It's important to note that these definitions can only take
:code:`width` and :code:`height` as inputs.
The cells in the grid will then be set to the mask produced by the definition
corresponding to the value in the :code:`layout`. If however the :code:`legend` does
not contain a matching key then the :code:`fill` definition will be used instead.
.. note::
Due to a limitation in the current implementation, you will get best results
if the dimensions of the :code:`layout` grid divide cleanly into the dimensions
of the final mask.
Attributes
----------
fill:
The definition to use in any cell where a corresponding definition cannot be
found in the legend. If :code:`None` this will default to
:class:`arlunio.mask.Empty`
layout:
A 2D numpy array of values representing keys from the :code:`legend` detailing
which mask should be used in which cell.
legend:
A dictionary with keys corresponding to values in the :code:`layout` that map to
mask producing definitions that should be used.
Example
-------
.. arlunio-image:: Simple Map
:include-code:
:width: 50%
:gallery: examples
::
import arlunio.image as image
import arlunio.mask as mask
import arlunio.shape as shape
import numpy as np
top = shape.Rectangle(size=0.2, yc=1, ratio=50)
left = shape.Rectangle(size=0.2, xc=-1, ratio=1/50)
right = shape.Rectangle(size=0.2, xc=1, ratio=1/50)
bottom = shape.Rectangle(size=0.2, yc=-1, ratio=50)
legend = {
"tt": top,
"bb": bottom,
"ll": left,
"rr": right,
"tl": top + left,
"tr": top + right,
"bl": bottom + left,
"br": bottom + right
}
layout = np.array([
["tt", "tt", "tt", "tt", "tr"],
[ "", "tl", "tt", "tr", "rr"],
[ "", "ll", "bl", "br", "rr"],
[ "", "bl", "bb", "bb", "br"],
[ "", "", "", "", ""]
])
map_ = mask.Map(legend=legend, layout=layout)
img = image.fill(
map_(width=1080, height=1080), foreground="blue", background="white"
)
"""
fill = fill if fill is not None else Empty()
# TODO: Handle divisions with rounding errors
nx, ny = len(layout), len(layout[0])
size = {"height": height // ny, "width": width // nx}
# Build a new dict with the values being the shapes drawn at the appropriate res
# to ensure we only draw them once.
items = {k: v(**size) for k, v in legend.items()}
default = fill(**size)
return np.block([[items.get(key, default) for key in row] for row in layout])
[docs]@ar.definition
def Pixelize(width: int, height: int, *, mask=None, defn=None, scale=16) -> Mask:
"""Produce a pixelated version of the given mask.
.. arlunio-image:: Pixelise
:align: center
::
import arlunio.image as image
import arlunio.mask as mask
import arlunio.shape as shape
pix = mask.Pixelize(defn=shape.Circle())
img = image.fill(pix(width=256, height=256))
This definition can either be given an existing :code:`mask` or a mask producing
definition which can be given with the :code:`defn` attribute. Note that this
definition can only take :code:`width` and :code:`height` as inputs.
.. note::
There is a limitation in the current implementation where the resulting mask may
be smaller than expected due to rounding errors. For best results
- Ensure that the shape of the mask given with the :code:`mask` attribute cleanly
divides your desired :code:`width` and :code:`height`.
- When using the :code:`defn` attribute ensure that the :code:`scale` attribute
cleanly divides your desired :code:`width` and :code:`height`.
Attributes
----------
mask:
The mask to pixelise. If given then :code:`defn` must be :code:`None`.
defn:
The mask producing definition to use. If given then :code:`mask` must be
:code:`None`
scale:
When providing the :code:`defn` attribute this controls the resolution the
definition is rendered at. Has no effect when providing a :code:`mask`
Examples
--------
This definition can be used to render a mask at a higher resolution
.. arlunio-image:: Creeper
:gallery: examples
:include-code:
:width: 50%
::
import numpy as np
import arlunio.image as image
import arlunio.mask as mask
face = np.array([
[False, False, False, False, False, False, False, False],
[False, False, False, False, False, False, False, False],
[False, True, True, False, False, True, True, False],
[False, True, True, False, False, True, True, False],
[False, False, False, True, True, False, False, False],
[False, False, True, True, True, True, False, False],
[False, False, True, True, True, True, False, False],
[False, False, True, False, False, True, False, False],
])
defn = mask.Pixelize(mask=face)
img = image.fill(defn(width=512, height=512), background="white")
We can also generate the mask directly from another definition.
.. arlunio-image:: Ghost
:include-code:
:width: 50%
:gallery: examples
::
import arlunio as ar
import arlunio.image as image
import arlunio.mask as mask
import arlunio.math as math
import arlunio.shape as shape
import numpy as np
@ar.definition
def Ghost(x: math.X, y: math.Y) -> mask.Mask:
head = shape.Circle(yc=0.5, r=0.7)
eyes = shape.Circle(xc=0.2, yc=0.6, r=0.3)
body = mask.all_(
y < 0.5,
np.abs(x) < 0.49,
0.1 * np.cos(5 * np.pi * x) - 0.3 < y
)
return (head(x=x, y=y) - eyes(x=np.abs(x), y=y)) + body
ghost = mask.Pixelize(defn=Ghost(y0=-0.3), scale=32)
img = image.fill(
ghost(width=1080, height=1080), foreground="#f00", background="white"
)
"""
logger = logging.getLogger(__name__)
if defn is None and mask is None:
raise ValueError("You must provide a mask or a mask producing definition.")
if mask is not None:
w, h = len(mask), len(mask[0])
if defn is not None:
# Based on the given resolution, calculate the size of each enlarged element.
ratio = width / height
w, h = int(scale * ratio), scale
mask = defn(width=w, height=h)
n, m = int(width // w), int(height // h)
logger.debug("Mask size: (%s, %s)", w, h)
logger.debug("Pixel size: (%s, %s)", n, m)
fill = Mask.full(m, n)
empty = Mask.empty(m, n)
return Mask(np.block([[fill if col else empty for col in row] for row in mask]))