#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# File : pixels.py
# License: MIT
# Author : Andrei Leonard Nicusan <a.l.nicusan@bham.ac.uk>
# Date : 20.05.2021
import pickle
import textwrap
import numpy as np
# Plotting is optional
try:
import plotly.graph_objects as go
except ImportError:
pass
try:
import matplotlib.pyplot as plt
except ImportError:
pass
[docs]class Pixels:
'''A class managing a 2D pixel space with physical dimensions, including
tools for pixel manipulation and visualisation.
The `.pixels` attribute is simply a `numpy.ndarray[ndim=2, dtype=float64]`.
If you think of `Pixels` as an image, the origin is the top left corner,
the X-dimension is the left edge and the Y-dimension is the top edge, so
that it can be indexed as `.pixels[ix, iy]`.
The `.attrs` dictionary can be used to store extra information.
Attributes
----------
pixels : (M, N) np.ndarray[ndim=2, dtype=float64]
The 2D numpy array containing the pixel values. This class assumes a
uniform grid of pixels - that is, the pixel size in each dimension is
constant, but can vary from one dimension to another.
xlim : (2,) np.ndarray[ndim=1, dtype=float64]
The lower and upper boundaries of the pixellised volume in the
x-dimension, formatted as [x_min, x_max].
ylim : (2,) np.ndarray[ndim=1, dtype=float64]
The lower and upper boundaries of the pixellised volume in the
y-dimension, formatted as [y_min, y_max].
pixel_size : (2,) np.ndarray[ndim=1, dtype=float64]
The lengths of a pixel in the x- and y-dimensions, respectively.
pixel_grids : list[(M+1,) np.ndarray, (N+1,) np.ndarray]
A list containing the pixel gridlines in the x- and y-dimensions.
Each dimension's gridlines are stored as a numpy of the pixel
delimitations, such that it has length (M + 1), where M is the number
of pixels in a given dimension.
lower : (2,) np.ndarray[ndim=1, dtype=float64]
The lower left corner of the pixel rectangle; corresponds to
[xlim[0], ylim[0]].
upper : (2,) np.ndarray[ndim=1, dtype=float64]
The upper right corner of the pixel rectangle; corresponds to
[xlim[1], ylim[1]].
attrs : dict[Any, Any]
A dictionary storing any other user-defined information.
See Also
--------
konigcell.Voxels : A class managing a physical 3D voxel space.
konigcell.dynamic2d : Rasterize moving particles' trajectories.
konigcell.static2d : Rasterize static particles' positions.
konigcell.dynamic_prob2d : 2D probability distribution of a quantity.
Notes
-----
The class saves `pixels` as a **contiguous** numpy array for efficient
access in C / Cython functions. The inner data can be mutated, but do not
change the shape of the array after instantiating the class.
Examples
--------
Create a zeroed 4x4 Pixels grid:
>>> import konigcell as kc
>>> pixels = kc.Pixels.zeros((4, 4), xlim = [0, 10], ylim = [0, 5])
>>> pixels
Pixels
------
xlim = [ 0. 10.]
ylim = [0. 5.]
pixels =
(shape: (4, 4))
[[0. 0. 0. 0.]
[0. 0. 0. 0.]
[0. 0. 0. 0.]
[0. 0. 0. 0.]]
attrs = {}
Or create a Pixels instance from another array (e.g. an image or matrix):
>>> import numpy as np
>>> matrix = np.ones((3, 3))
>>> pixels = kc.Pixels(matrix, xlim = [0, 10], ylim = [-5, 5])
>>> pixels
Pixels
------
xlim = [ 0. 10.]
ylim = [-5. 5.]
pixels =
(shape: (3, 3))
[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]
attrs = {}
Access pixels' properties directly:
>>> pixels.xlim # ndarray[xmin, xmax]
>>> pixels.ylim # ndarray[ymin, ymax]
>>> pixels.pixel_size # ndarray[xsize, ysize]
>>> pixels.pixels.shape # pixels resolution - tuple[nx, ny]
You can save extra attributes about the pixels instance in the `attrs`
dictionary:
>>> pixels.attrs["dpi"] = 300
>>> pixels
Pixels
------
xlim = [ 0. 10.]
ylim = [-5. 5.]
pixels =
(shape: (3, 3))
[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]
attrs = {
'dpi': 300
}
The lower left and upper right corners of the pixel grid, in physical
coordinates (the ones given by xlim and ylim):
>>> pixels.lower
array([ 0., -5.])
>>> pixels.upper
array([10., 5.])
You can access the underlying NumPy array directly:
>>> pixels.pixels
array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
Indexing is forwarded to the NumPy array:
>>> pixels[:, :]
array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
Transform physical units into pixel indices:
>>> pixels.from_physical([5, 0]) # pixel centres
array([1., 1.])
>>> pixels.from_physical([5, 0], corner = True) # lower left corners
array([1.5, 1.5])
Transform pixel indices into physical units:
>>> pixels.to_physical([0, 0]) # pixels centres
array([ 1.66666667, -3.33333333])
>>> pixels.to_physical([0, 0], corner = True) # lower left corners
array([ 0., -5.])
Save Pixels instance to disk, as a binary archive:
>>> pixels.save("pixels.pickle")
>>> pixels = kc.load("pixels.pickle")
Create deep copy of a Pixels instance:
>>> Pixels.copy()
Matplotlib plotting (optional, if Matplotlib is installed):
>>> fig, ax = pixels.plot()
>>> fig.show()
Plotly trace (optional, if Plotly is installed):
>>> import plotly.graph_objs as go
>>> fig = go.Figure()
>>> fig.add_trace(pixels.heatmap_trace())
>>> fig.show()
'''
__slots__ = ("_pixels", "_xlim", "_ylim", "_attrs", "_pixel_size",
"_pixel_grids", "_lower", "_upper")
[docs] def __init__(self, pixels_array, xlim, ylim, **kwargs):
'''`Pixels` class constructor.
Parameters
----------
pixels_array : 3D numpy.ndarray
A 3D numpy array, corresponding to a pre-defined pixel space.
xlim : (2,) numpy.ndarray
The lower and upper boundaries of the pixellised volume in the
x-dimension, formatted as [x_min, x_max].
ylim : (2,) numpy.ndarray
The lower and upper boundaries of the pixellised volume in the
y-dimension, formatted as [y_min, y_max].
**kwargs : extra keyword arguments
Extra user-defined attributes to be saved in `.attrs`.
Raises
------
ValueError
If `pixels_array` does not have exactly 2 dimensions or if
`xlim` or `ylim` do not have exactly 2 values each.
Notes
-----
No copies are made if `pixels_array`, `xlim` and `ylim` are contiguous
NumPy arrays with dtype=float64.
'''
# Type-checking inputs
pixels_array = np.asarray(
pixels_array,
order = "C",
dtype = np.float64,
)
if pixels_array.ndim != 2:
raise ValueError(textwrap.fill((
"The input `pixels_array` must contain an array-like with "
"exactly three dimensions (i.e. pre-made pixels array). "
f"Received an array with {pixels_array.ndim} dimensions."
)))
xlim = np.asarray(xlim, dtype = np.float64)
if xlim.ndim != 1 or len(xlim) != 2 or xlim[0] >= xlim[1]:
raise ValueError(textwrap.fill((
"The input `xlim` parameter must be a list with exactly "
"two values, corresponding to the minimum and maximum "
"coordinates of the pixel space in the x-dimension. "
f"Received parameter with shape {xlim.shape}."
)))
ylim = np.asarray(ylim, dtype = np.float64)
if ylim.ndim != 1 or len(ylim) != 2 or ylim[0] >= ylim[1]:
raise ValueError(textwrap.fill((
"The input `ylim` parameter must be a list with exactly "
"two values, corresponding to the minimum and maximum "
"coordinates of the pixel space in the y-dimension. "
f"Received parameter with shape {ylim.shape}."
)))
# Setting class attributes
self._pixels = pixels_array
self._xlim = xlim
self._ylim = ylim
self._attrs = dict(kwargs)
@property
def pixels(self):
return self._pixels
@property
def xlim(self):
return self._xlim
@property
def ylim(self):
return self._ylim
@property
def attrs(self):
return self._attrs
@property
def pixel_size(self):
# Compute once upon the first access and cache
if not hasattr(self, "_pixel_size"):
self._pixel_size = np.array([
(self._xlim[1] - self._xlim[0]) / self._pixels.shape[0],
(self._ylim[1] - self._ylim[0]) / self._pixels.shape[1],
])
return self._pixel_size
@property
def pixel_grids(self):
# Compute once upon the first access and cache
if not hasattr(self, "_pixel_grids"):
self._pixel_grids = [
np.linspace(lim[0], lim[1], self._pixels.shape[i] + 1)
for i, lim in enumerate((self._xlim, self._ylim))
]
return self._pixel_grids
@property
def lower(self):
# Compute once upon the first access and cache
if not hasattr(self, "_lower"):
self._lower = np.array([self._xlim[0], self._ylim[0]])
return self._lower
@property
def upper(self):
# Compute once upon the first access and cache
if not hasattr(self, "_upper"):
self._upper = np.array([self._xlim[1], self._ylim[1]])
return self._upper
[docs] @staticmethod
def zeros(shape, xlim, ylim, **kwargs):
return Pixels(np.zeros(shape, dtype = float), xlim, ylim, **kwargs)
[docs] def save(self, filepath):
'''Save a `Pixels` instance as a binary `pickle` object.
Saves the full object state, including the inner `.pixels` NumPy array,
`xlim`, etc. in a fast, portable binary format. Load back the object
using the `load` method.
Parameters
----------
filepath : filename or file handle
If filepath is a path (rather than file handle), it is relative
to where python is called.
Examples
--------
Save a `Pixels` instance, then load it back:
>>> import numpy as np
>>> import konigcell as kc
>>>
>>> grid = np.zeros((640, 480))
>>> pixels = kc.Pixels(grid, [0, 20], [0, 10])
>>> pixels.save("pixels.pickle")
>>> pixels_reloaded = kc.Pixels.load("pixels.pickle")
'''
with open(filepath, "wb") as f:
pickle.dump(self, f)
[docs] @staticmethod
def load(filepath):
'''Load a saved / pickled `Pixels` object from `filepath`.
Most often the full object state was saved using the `.save` method.
Parameters
----------
filepath : filename or file handle
If filepath is a path (rather than file handle), it is relative
to where python is called.
Returns
-------
pept.Pixels
The loaded `pept.Pixels` instance.
Examples
--------
Save a `Pixels` instance, then load it back:
>>> import numpy as np
>>> import konigcell as kc
>>>
>>> grid = np.zeros((640, 480))
>>> pixels = kc.Pixels(grid, [0, 20], [0, 10])
>>> pixels.save("pixels.pickle")
>>> pixels_reloaded = kc.Pixels.load("pixels.pickle")
'''
with open(filepath, "rb") as f:
obj = pickle.load(f)
return obj
[docs] def copy(self, pixels_array = None, xlim = None, ylim = None, **kwargs):
'''Create a copy of the current `Pixels` instance, optionally with new
`pixels_array`, `xlim` and / or `ylim`.
The extra attributes in `.attrs` are propagated too. Pass new
attributes as extra keyword arguments.
'''
if pixels_array is None:
pixels_array = self.pixels.copy()
if xlim is None:
xlim = self.xlim.copy()
if ylim is None:
ylim = self.ylim.copy()
# Propagate attributes
kwargs.update(self.attrs)
return Pixels(pixels_array, xlim, ylim, **kwargs)
[docs] def from_physical(self, locations, corner = False):
'''Transform `locations` from physical dimensions to pixel indices. If
`corner = True`, return the index of the bottom left corner of each
pixel; otherwise, use the pixel centres.
Examples
--------
Create a simple `konigcell.Pixels` grid, spanning [-5, 5] mm in the
X-dimension and [10, 20] mm in the Y-dimension:
>>> import konigcell as kc
>>> pixels = kc.Pixels.zeros((5, 5), xlim=[-5, 5], ylim=[10, 20])
>>> pixels
Pixels
------
xlim = [-5. 5.]
ylim = [10. 20.]
pixels =
(shape: (5, 5))
[[0. 0. ... 0. 0.]
[0. 0. ... 0. 0.]
...
[0. 0. ... 0. 0.]
[0. 0. ... 0. 0.]]
attrs = {}
>>> pixels.pixel_size
array([2., 2.])
Transform physical coordinates to pixel coordinates:
>>> pixels.from_physical([-5, 10], corner = True)
array([0., 0.])
>>> pixels.from_physical([-5, 10])
array([-0.5, -0.5])
The pixel coordinates are returned exactly, as real numbers. For pixel
indices, round them into values:
>>> pixels.from_physical([0, 15]).astype(int)
array([2, 2])
Multiple coordinates can be given as a 2D array / list of lists:
>>> pixels.from_physical([[0, 15], [5, 20]])
array([[2. , 2. ],
[4.5, 4.5]])
'''
offset = 0. if corner else self.pixel_size / 2
return (locations - self.lower - offset) / self.pixel_size
[docs] def to_physical(self, indices, corner = False):
'''Transform `indices` from pixel indices to physical dimensions. If
`corner = True`, return the coordinates of the bottom left corner of
each pixel; otherwise, use the pixel centres.
Examples
--------
Create a simple `konigcell.Pixels` grid, spanning [-5, 5] mm in the
X-dimension and [10, 20] mm in the Y-dimension:
>>> import konigcell as kc
>>> pixels = kc.Pixels.zeros((5, 5), xlim=[-5, 5], ylim=[10, 20])
>>> pixels
Pixels
------
xlim = [-5. 5.]
ylim = [10. 20.]
pixels =
(shape: (5, 5))
[[0. 0. ... 0. 0.]
[0. 0. ... 0. 0.]
...
[0. 0. ... 0. 0.]
[0. 0. ... 0. 0.]]
attrs = {}
>>> pixels.pixel_size
array([2., 2.])
Transform physical coordinates to pixel coordinates:
>>> pixels.to_physical([0, 0], corner = True)
array([-5., 10.])
>>> pixels.to_physical([0, 0])
array([-4., 11.])
Multiple coordinates can be given as a 2D array / list of lists:
>>> pixels.to_physical([[0, 0], [4, 3]])
array([[-4., 11.],
[ 4., 17.]])
'''
offset = 0. if corner else self.pixel_size / 2
return self.lower + indices * self.pixel_size + offset
[docs] def heatmap_trace(
self,
colorscale = "Magma",
transpose = True,
xgap = 0.,
ygap = 0.,
):
'''Create and return a Plotly `Heatmap` trace of the pixels.
Parameters
----------
colorscale : str, default "Magma"
The Plotly scheme for color-coding the pixel values in the input
data. Typical ones include "Cividis", "Viridis" and "Magma".
A full list is given at `plotly.com/python/builtin-colorscales/`.
Only has an effect if `colorbar = True` and `color` is not set.
transpose : bool, default True
Transpose the heatmap (i.e. flip it across its diagonal).
Examples
--------
Create a Pixels array and plot it as a heatmap using Plotly:
>>> import konigcell as kc
>>> import numpy as np
>>> import plotly.graph_objs as go
>>> pixels_raw = np.arange(150).reshape(10, 15)
>>> pixels = kc.Pixels(pixels_raw, [-5, 5], [-5, 10])
>>> fig = go.Figure()
>>> fig.add_trace(pixels.heatmap_trace())
>>> fig.show()
'''
# Compute the pixel centres
x = self.pixel_grids[0]
x = (x[1:] + x[:-1]) / 2
y = self.pixel_grids[1]
y = (y[1:] + y[:-1]) / 2
heatmap = dict(
x = x,
y = y,
z = self.pixels,
colorscale = colorscale,
transpose = transpose,
xgap = xgap,
ygap = ygap,
)
# If you see this error, it means you don't have Plotly; install it
# with `pip install plotly`
return go.Heatmap(heatmap)
[docs] def plot(self, ax = None):
'''Plot pixels as a heatmap using Matplotlib.
Returns matplotlib figure and axes objects containing the pixel values
colour-coded in a Matplotlib image (i.e. heatmap).
Parameters
----------
ax : mpl_toolkits.mplot3D.Axes3D object, optional
The 3D matplotlib-based axis for plotting. If undefined, new
Matplotlib figure and axis objects are created.
Returns
-------
fig, ax
Matplotlib figure and axes objects.
Examples
--------
Pixellise an array of lines and plot them with Matplotlib:
>>> lines = np.array(...) # shape (N, M >= 7)
>>> lines2d = lines[:, [0, 1, 2, 4, 5]] # select x, y of lines
>>> number_of_pixels = [10, 10]
>>> pixels = pept.Pixels.from_lines(lines2d, number_of_pixels)
>>> fig, ax = pixels.plot()
>>> fig.show()
'''
# If you see this error, it means you don't have Matplotlib; install it
# with `pip install matplotlib`
if ax is None:
fig = plt.figure()
ax = fig.add_subplot(111)
else:
fig = plt.gcf()
# Plot the values in pixels
ax.imshow(np.rot90(self.pixels))
# Compute the pixel centres and set them in the Matplotlib image
# x = self.pixel_grids[0]
# x = (x[1:] + x[:-1]) / 2
# y = self.pixel_grids[1]
# y = (y[1:] + y[:-1]) / 2
# Matplotlib shows numbers in a long format ("102.000032411"), so round
# them to two decimals before plotting
# ax.set_xticklabels(np.round(x, 2))
# ax.set_yticklabels(np.round(y, 2))
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
return fig, ax
def __repr__(self):
# String representation of the class
name = "Pixels"
underline = "-" * len(name)
# Custom printing of the .lines and .samples_indices arrays
with np.printoptions(threshold = 5, edgeitems = 2):
pixels_str = f"{textwrap.indent(str(self.pixels), ' ')}"
# Pretty-printing extra attributes
attrs_str = ""
if self.attrs:
items = []
for k, v in self.attrs.items():
s = f" {k.__repr__()}: {v}"
if len(s) > 75:
s = s[:72] + "..."
items.append(s)
attrs_str = "\n" + "\n".join(items) + "\n"
# Return constructed string
return (
f"{name}\n{underline}\n"
f"xlim = {self.xlim}\n"
f"ylim = {self.ylim}\n"
f"pixels = \n"
f" (shape: {self.pixels.shape})\n"
f"{pixels_str}\n"
"attrs = {"
f"{attrs_str}"
"}\n"
)