#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
icon object which can be plotted into an axis
"""
import os
import matplotlib.pyplot as plt
import matplotlib
from matplotlib.offsetbox import OffsetImage, AnnotationBbox, DrawingArea
import numpy as np
import PIL
import PIL.ImageOps
import PIL.ImageFilter
from PIL import UnidentifiedImageError
from rsatoolbox.rdm import RDMs
from rsatoolbox.util.pooling import pool_rdm
if hasattr(matplotlib.colormaps, 'get_cmap'):
mpl_get_cmap = matplotlib.colormaps.get_cmap
else:
mpl_get_cmap = matplotlib.cm.get_cmap # drop:py37
[docs]class Icon:
"""
Icon object, i.e. an object which can be plotted into an axis or as
an axis label.
Args:
image (np.ndarray or PIL.Image or RDMs or Icon)
the image to use as an icon
arrays and images should give the image directly
RDMs takes the average RDM from the object
If an Icon is passed its image property is used
string (String)
string to place on the icon
color (color definition)
background / border color
default: None -> no border or background
marker (matplotlib markertype)
sets what kind of symbol to plot
cmap (color map)
color map applied to the image
border_type (String)
- None : default, puts the color as a background
where the alpha of the image is not 0
- 'pad' : pads the image with the border color -> square border
- 'conv' : extends the area by convolving with a circle
border_width (integer)
width of the border
make_square (bool)
if set to true the image is first reshaped into a square
circ_cut (flag)
sets how the icon is cut into circular shape:
- None : default, no cutting
- 'cut' : sets alpha to 0 out of a circular aperture
- 'cosine' : sets alpha to a raised cosine window
- a number between 0 and 1 : a tukey window with the flat proportion
of the aperture given by the number. For 0 this corresponds
to the cosine window, for 1 it corresponds to 'cut'.
resolution (one or two numbers):
sets a resolution for the icon to which the image is resized
prior to all processing. If only one number is provided,
the image is resized to a square with that size
marker_front (bool):
switches whether the marker is plotted in front or behind the
image. If True the marker is plotted unfilled in front
If False the marker is plotted behind the image filled.
default = True
font_size (float)
size of any annotation text
font_name (str):
annotation font
font_color (np.ndarray)
font color for annotations
"""
def __init__(
self, image=None, string=None, color=None, marker=None,
cmap=None, border_type=None, border_width=2, make_square=False,
circ_cut=None, resolution=None, marker_front=True,
markeredgewidth=2, font_size=None, font_name=None,
font_color=None):
self.final_image = None
self.font_size = font_size
self.font_name = font_name
self.string = string
self.font_color = font_color
self.marker = marker
self.marker_front = marker_front
self.markeredgewidth = markeredgewidth
self._make_square = make_square
self._border_width = border_width
self._border_type = border_type
self._cmap = cmap
self._color = color
self._circ_cut = None
self._resolution = None
self.image = image
if resolution is not None:
self.resolution = resolution
self.circ_cut = circ_cut
@property
def image(self):
return self._image
@image.setter
def image(self, image):
""" interprets image/converts it into an image"""
if isinstance(image, Icon):
self._image = image.image
elif isinstance(image, RDMs):
avg_rdm = pool_rdm(image)
image = avg_rdm.get_matrices()[0]
self._image = image / np.max(image)
if self.resolution is None:
self._resolution = np.array(100)
elif image is not None:
self._image = image
else:
self._image = None
self.recompute_final_image()
@property
def string(self):
return self._string
@string.setter
def string(self, string):
if string is None or isinstance(string, str):
self._string = string
else:
raise ValueError("String must be a string")
@property
def color(self):
return self._color
@color.setter
def color(self, color):
self._color = color
self.recompute_final_image()
@property
def cmap(self):
return self._cmap
@cmap.setter
def cmap(self, cmap):
self._cmap = cmap
self.recompute_final_image()
@property
def make_square(self):
return self._make_square
@make_square.setter
def make_square(self, make_square):
self._make_square = make_square
self.recompute_final_image()
@property
def border_width(self):
return self._border_width
@border_width.setter
def border_width(self, border_width):
self._border_width = border_width
self.recompute_final_image()
@property
def border_type(self):
return self._border_type
@border_type.setter
def border_type(self, border_type):
self._border_type = border_type
self.recompute_final_image()
@property
def resolution(self):
return self._resolution
@resolution.setter
def resolution(self, resolution):
if resolution is not None:
self._resolution = np.array(resolution)
else:
self._resolution = None
self.recompute_final_image()
@property
def circ_cut(self):
return self._circ_cut
@circ_cut.setter
def circ_cut(self, circ_cut):
if circ_cut is None:
self._circ_cut = None
elif circ_cut == "cut":
self._circ_cut = 1
elif circ_cut == "cosine":
self._circ_cut = 0
elif 0 <= circ_cut <= 1:
self._circ_cut = circ_cut
else:
raise ValueError("circ_cut must be in [0,1]")
self.recompute_final_image()
[docs] def recompute_final_image(self):
""" computes the icon image from the parameters
This function handles most of the image processing and must be run
again if any properties are changed. If you use set to change
properties this is automatically run.
"""
if self._image is None:
self.final_image = None
return
if isinstance(self._image, np.ndarray):
if self._image.dtype == np.uint8 or np.any(self._image > 1):
# assume image is in uint8 0-255 range
im = self._image / 255
else:
im = self._image
if self.cmap is not None:
im = mpl_get_cmap(self.cmap)(im)
im = PIL.Image.fromarray((im * 255).astype(np.uint8))
else: # we hope it is a PIL image or equivalent
im = self._image
im = im.convert("RGBA")
if self.make_square:
new_size = max(im.width, im.height)
if int(PIL.__version__[0]) >= 9:
im = im.resize((new_size, new_size), PIL.Image.Resampling.NEAREST)
else:
im = im.resize((new_size, new_size), PIL.Image.NEAREST)
if self.resolution is not None:
if self.resolution.size == 1:
if int(PIL.__version__[0]) >= 9:
im = im.resize((self.resolution, self.resolution), PIL.Image.Resampling.NEAREST)
else:
im = im.resize((self.resolution, self.resolution), PIL.Image.NEAREST)
else:
if int(PIL.__version__[0]) >= 9:
im = im.resize(self.resolution, PIL.Image.Resampling.NEAREST)
else:
im = im.resize(self.resolution, PIL.Image.NEAREST)
if self.circ_cut is not None:
middle = np.array(im.size) / 2
x = np.arange(im.size[0]) - middle[0] + 0.5
x = x / np.max(np.abs(x))
y = np.arange(im.size[1]) - middle[1] + 0.5
y = y / np.max(np.abs(y))
yy, xx = np.meshgrid(y, x)
r = np.sqrt(xx ** 2 + yy ** 2)
alpha = np.empty(r.shape)
alpha[r > 1] = 0
alpha[r <= self.circ_cut] = 1
val = (r > self.circ_cut) & (r <= 1)
alpha[val] = 0.5 + 0.5 * np.cos(
np.pi * (r[val] - self.circ_cut) / (1 - self.circ_cut)
)
alpha = alpha.T * np.array(im.getchannel("A"))
alpha = PIL.Image.fromarray(np.uint8(alpha))
im.putalpha(alpha)
if self.color is not None:
if self.border_type is None:
pass
elif self.border_type == "alpha":
bg_alpha = np.array(im.getchannel("A"))
bg_alpha = bg_alpha > 0
bg_alpha = PIL.Image.fromarray(255 * np.uint8(bg_alpha))
bg = PIL.Image.new(
"RGBA", im.size, color=tuple(np.uint8(255 * self.color))
)
bg.putalpha(bg_alpha)
im = PIL.Image.alpha_composite(bg, im)
elif self.border_type == "pad":
im = PIL.ImageOps.expand(im, border=self.border_width, fill=self.color)
elif self.border_type == "conv":
im = PIL.ImageOps.expand(
im, border=self.border_width, fill=(0, 0, 0, 0)
)
bg_alpha = im.getchannel("A")
bg_alpha = bg_alpha.filter(PIL.ImageFilter.BoxBlur(self.border_width))
bg_alpha = np.array(bg_alpha)
bg_alpha = 255 * np.uint8(bg_alpha > 0)
bg_alpha = PIL.Image.fromarray(bg_alpha)
bg = PIL.Image.new(
"RGBA", im.size, color=tuple(np.uint8(255 * self.color))
)
bg.putalpha(bg_alpha)
im = PIL.Image.alpha_composite(bg, im)
self.final_image = im
[docs] def plot(self, x, y, ax=None, size=None):
""" plots the icon into an axis
Args:
x (float)
x-position
y (float)
y-position
ax (matplotlib axis)
the axis to plot in
size : float
size of the icon scaling the image
"""
if ax is None:
ax = plt.gca()
if size is None:
size = 1
if self.final_image is not None:
imagebox = OffsetImage(self.final_image, zoom=size)
ab = AnnotationBbox(imagebox, (x, y), frameon=False, pad=0)
ax.add_artist(ab)
zorder = ab.zorder
else:
zorder = 0
if self.marker:
if self.final_image is not None:
markersize = max(self.final_image.size)
else:
markersize = 50
markersize = markersize * size
if self.marker_front:
plt.plot(
x,
y,
marker=self.marker,
markeredgecolor=self.color,
markerfacecolor=(0, 0, 0, 0),
markersize=markersize,
zorder=zorder + 0.1,
markeredgewidth=self.markeredgewidth,
)
else:
plt.plot(
x,
y,
marker=self.marker,
markeredgecolor=self.color,
markerfacecolor=self.color,
markersize=markersize,
zorder=zorder - 0.1,
markeredgewidth=self.markeredgewidth,
)
if self.string is not None:
ax.annotate(
self.string,
(x, y),
horizontalalignment="center",
verticalalignment="center",
zorder=zorder + 0.2,
fontsize=self.font_size,
fontname=self.font_name,
color=self.font_color,
)
def _tick_label(
self,
x,
y,
size,
ax=None,
linewidth=None,
xybox=None,
xycoords=None,
box_alignment=None,
horizontalalignment=None,
verticalalignment=None,
rotation=None,
):
"""
uses the icon as a ticklabel at location x
Args:
x (float)
the horizontal position of the tick
y (float)
the vertical position of the tick
size (float)
scaling the size of the icon
ax (matplotlib axis)
the axis to put the label on
"""
ret_val = {}
if ax is None:
ax = plt.gca()
tickline_color = self.color
# np.any chokes on str input so need to test for this first
if not (isinstance(tickline_color, str) or np.any(tickline_color)):
tickline_color = [0.8, 0.8, 0.8]
if self.final_image is not None:
imagebox = OffsetImage(self.final_image, zoom=size, dpi_cor=True)
ret_val['image'] = AnnotationBbox(
imagebox,
(x, y),
xybox=xybox,
xycoords=xycoords,
box_alignment=box_alignment,
boxcoords="offset points",
bboxprops={"edgecolor": "none", "facecolor": "none"},
arrowprops={
"linewidth": linewidth,
"color": tickline_color,
"arrowstyle": "-",
"shrinkA": 0,
"shrinkB": 1,
},
pad=0.0,
annotation_clip=False,
)
zorder = ret_val['image'].zorder
ax.add_artist(ret_val['image'])
else:
zorder = 0
if self.marker:
if self.final_image is not None:
markersize = max(self.final_image.size)
else:
markersize = 50
markersize = markersize * size
d = DrawingArea(markersize, markersize)
if self.marker_front:
zorder_marker = zorder + 0.1
else:
zorder_marker = zorder - 0.1
d.set_zorder(zorder_marker)
d.set_alpha(0)
if self.marker_front:
d.add_artist(
plt.Line2D(
[markersize / 2],
[markersize / 2],
marker=self.marker,
markeredgecolor=self.color,
markerfacecolor=(0, 0, 0, 0),
markersize=markersize,
markeredgewidth=self.markeredgewidth,
transform=d.get_transform(),
zorder=zorder_marker,
)
)
else:
d.add_artist(
plt.Line2D(
[markersize / 2],
[markersize / 2],
marker=self.marker,
markeredgecolor=self.color,
markerfacecolor=self.color,
markersize=markersize,
markeredgewidth=self.markeredgewidth,
transform=d.get_transform(),
zorder=zorder_marker,
)
)
ret_val['marker'] = AnnotationBbox(
d,
(x, y),
xybox=xybox,
xycoords=xycoords,
box_alignment=box_alignment,
boxcoords="offset points",
bboxprops={"edgecolor": "none", "facecolor": "none"},
arrowprops={
"linewidth": linewidth,
"color": tickline_color,
"arrowstyle": "-",
"shrinkA": 0,
"shrinkB": 1,
},
pad=0.0,
annotation_clip=False,
)
ret_val['marker'].set_zorder(zorder_marker)
ret_val['marker'].set_alpha(0)
ax.add_artist(ret_val['marker'])
if self.string is not None:
ret_val['string'] = ax.annotate(
self.string,
(x, y),
xytext=xybox,
xycoords=xycoords,
textcoords="offset points",
horizontalalignment=horizontalalignment,
verticalalignment=verticalalignment,
arrowprops={
"linewidth": linewidth,
"color": tickline_color,
"arrowstyle": "-",
"shrinkA": 0,
"shrinkB": 1,
},
zorder=zorder + 0.2,
fontsize=self.font_size,
fontname=self.font_name,
color=self.font_color,
rotation=rotation,
)
return ret_val
[docs] def x_tick_label(self, x, size, offset, **kwarg):
"""
uses the icon as a ticklabel at location x
Args:
x (float)
the position of the tick
size (float)
scaling the size of the icon
offset (integer)
how far the icon should be from the axis in axis units
ax (matplotlib axis)
the axis to put the label on
"""
return self._tick_label(
x=x,
y=0,
size=size,
xybox=(0, -offset),
xycoords=("data", "axes fraction"),
box_alignment=(0.5, 1),
horizontalalignment="center",
verticalalignment="bottom",
rotation=90,
**kwarg
)
[docs] def y_tick_label(self, y, size, offset, **kwarg):
"""
uses the icon as a ticklabel at location x
Args:
y (float)
the position of the tick
size (float)
scaling the size of the icon
offset (integer)
how far the icon should be from the axis in axis units
ax (matplotlib axis)
the axis to put the label on
"""
return self._tick_label(
x=0,
y=y,
size=size,
xybox=(-offset, 0),
xycoords=("axes fraction", "data"),
box_alignment=(1, 0.5),
horizontalalignment="right",
verticalalignment="center",
rotation=0,
**kwarg
)
[docs]def icons_from_folder(
folder,
resolution=None,
color=None,
cmap=None,
border_type=None,
border_width=2,
make_square=False,
circ_cut=None,
):
""" generates a dictionary of Icons for all images in a folder
"""
icons = dict()
for filename in os.listdir(folder):
try:
im = PIL.Image.open(filename)
icons[filename] = Icon(
image=im,
color=color,
resolution=resolution,
cmap=cmap,
border_type=border_type,
border_width=border_width,
make_square=make_square,
circ_cut=circ_cut,
)
except (
FileNotFoundError,
UnidentifiedImageError,
IsADirectoryError,
PermissionError,
):
pass
return icons