# Copyright 2020 Timothy M. Shead
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Functionality for manipulating images and related data structures.
"""
import collections
import enum
import fnmatch
import functools
import io
import os
import numpy
import imagecat.color
import imagecat.optional
import imagecat.require
PIL = imagecat.optional.module("PIL.Image")
# Warning! Moving this to another module will break *.icp file loading.
[docs]class Image(object):
"""Storage for a multi-layer bitmap image.
An Imagecat :class:`Image` is composed of zero-to-many layers, which are
instances of :class:`Layer`. Each layer is named, and all layer names must
be unique.
Parameters
----------
layers: :class:`dict`, optional
Dictionary mapping :class:`str` layer names to :class:`Layer` instances that
contain the data for each layer. If :any:`None` (the default), creates
an empty (no layers) image.
metadata: :class:`dict`, optional
Arbitrary user-defined metadata for the image. This could be populated
by image loaders, modified in operators, and subsets saved by writers.
An example of real-world metadata is Cryptomatte information loaded
from an EXR file.
See Also
--------
:ref:`images`
For an in-depth discussion of how images are stored in Imagecat.
"""
def __init__(self, layers=None, metadata=None):
if layers is None:
layers = {}
if metadata is None:
metadata = {}
first_layer = None
for key, layer in layers.items():
if not isinstance(key, str):
raise ValueError(f"{key} is not a valid layer name.") # pragma: no cover
if not isinstance(layer, Layer):
raise ValueError(f"{layer} is not a valid Layer instance.") # pragma: no cover
if first_layer is None:
first_layer = layer
else:
if layer.data.shape[:2] != first_layer.data.shape[:2]:
raise ValueError("All layers must have the same resolution.") # pragma: no cover
self._layers = layers
self._metadata = metadata
def __repr__(self):
layers = (f"{k}: {v!r}" for k, v in self._layers.items())
return f"Image({', '.join(layers)})"
[docs] def copy(self, layers=None, metadata=None):
"""return a shallow copy of the image, with optional modifications.
Returns a new instance of :class:`Image` that can be altered without
modifying the original. Note that the new image will references the
same `layers` as the original, unless a new set of layers are supplied
as an argument.
Parameters
----------
layers: :class:`dict`, optional
Dictionary mapping :class:`str` layer names to :class:`Layer` instances that
contain the data for each layer. Replaces the original layers if not :any:`None` (the default).
Returns
-------
image: :class:`Image`
The new image instance with optional modifications.
"""
layers = dict(self.layers) if layers is None else layers
metadata = dict(self.metadata) if metadata is None else metadata
return Image(layers=layers, metadata=metadata)
@property
def layers(self):
""":class:`dict` containing image layers.
Returns
-------
layers: :class:`dict`
Dictionary mapping :class:`str` layer names to :class:`Layer` instances that
contain the data for each layer.
"""
return self._layers
@property
def metadata(self):
""":class:`dict` containing image metadata.
Returns
-------
metadata: :class:`dict`
Dictionary containing arbitrary image metadata.
"""
return self._metadata
[docs] def match_layer_names(self, patterns):
"""Return layer names in this image that match the given patterns.
See :func:`match_layer_names` for a description of the pattern syntax.
Parameters
----------
patterns: :class:`str`, required
Patterns to match against this image's layer names.
Returns
-------
layers: sequence of :class:`str`
Layer names in this image that match `patterns`.
"""
return match_layer_names(self.layers.keys(), patterns)
def _repr_png_(self):
for key in self.layers.keys():
return self.layers[key]._repr_png_()
# Warning! Moving this to another module will break *.icp file loading.
[docs]class Layer(object):
"""Storage for one layer in a bitmap image.
An Imagecat :class:`Layer` contains the data and metadata that describe a
single layer in an Imagecat :class:`Image`. This includes the raw data
itself, plus an enumerated role that describes the semantic purpose of the
layer.
Parameters
----------
data: :class:`numpy.ndarray`, required
A three dimensional :math:`M \\times N \\times C` array containing the
layer data, organized into :math:`M` rows in top-to-bottom order,
:math:`N` columns in left-to-right order, and :math:`C` channels.
The array dtype should be `numpy.float16` for most data,
with `numpy.float32` and `numpy.int32` reserved for special cases such
as depth maps and object id maps, respectively.
role: :class:`Role`, optional
Semantic purpose of the layer. If :any:`None` (the default), the
role will default to ``Role.NONE``.
See Also
--------
:ref:`images`
For an in-depth discussion of how images are stored in Imagecat.
"""
def __init__(self, *, data, role=None):
if not isinstance(data, numpy.ndarray):
raise ValueError("Layer data must be an instance of numpy.ndarray.") # pragma: no cover
if data.ndim != 3:
raise ValueError("Layer data must have three dimensions.") # pragma: no cover
if role is None:
role = Role.NONE
if not isinstance(role, Role):
raise ValueError("Layer role must be an instance of imagecat.data.Role.") # pragma: no cover
role_depth = depth(role)
if role_depth is not None and data.shape[2] != role_depth:
raise ValueError(f"Expected {role_depth} channels, received {data.shape[2]}.") # pragma: no cover
self.data = data
self.role = role
def __repr__(self):
return f"Layer({self.role} {self.data.shape[1]}x{self.data.shape[0]}x{self.data.shape[2]} {self.data.dtype})"
[docs] def copy(self, data=None, role=None):
"""Return a shallow copy of the layer, with optional modifications.
This method returns a new instance of :class:`Layer` that can be altered
without modifying the original. Note that the new layer still references
the same `data` as the original, unless a new data array is supplied as
an argument.
Parameters
----------
data: :class:`numpy.ndarray`, optional
Replaces the existing data array in the new layer, if not :any:`None` (the default).
role: :class:`Role`, optional
Replaces the existing role in the new layer, if not :any:`None` (the default).
Returns
-------
layer: :class:`Layer`
The new layer instance with modifications.
"""
data = self.data if data is None else data
role = self.role if role is None else role
return Layer(data=data, role=role)
@property
def dtype(self):
"""Numpy :class:`dtype<numpy.dtype>` of the underlying data array.
Returns
-------
dtype: :class:`numpy.dtype`
"""
return self.data.dtype
@property
def res(self):
"""Layer resolution in x and y.
Returns
-------
res: (width, height) :any:`tuple`
The resolution of the layer, ignoring the number of channels.
"""
return self.data.shape[1], self.data.shape[0]
@property
def shape(self):
"""Shape of the underlying data array.
Returns
-------
shape: (rows, columns, channels) :any:`tuple`
"""
return self.data.shape
def _repr_png_(self):
stream = io.BytesIO()
to_pil(self).save(stream, "PNG")
return stream.getvalue()
# Warning! Moving this to another module will break *.icp file loading.
[docs]class Role(enum.Enum):
"""Semantic description of how :class:`Layer` data should be interpreted.
Because Imagecat allows an image to contain an arbitrary number of layers
with arbitray names, and a layer can contain one of many different types of
data - not just color information - :class:`Role` is used to indicate how
the data in a given layer will be used. The layer role can influence many
operations in Imagecat, including visualization and file IO.
See Also
--------
:ref:`images`
For an in-depth discussion of how images are stored in Imagecat.
"""
NONE = 0
"""General purpose layer with an unknown role and any number of channels."""
RGB = 1
"""Layer with three channels of red-green-blue color information."""
REDGREEN = 2
"""Layer with two channels of red-green color information."""
GREENBLUE = 3
"""Layer with two channels of green-blue color information."""
REDBLUE = 4
"""Layer with two channels of red-blue color information."""
RED = 5
"""Layer with one channel of red color information."""
GREEN = 6
"""Layer with one channel of green color information."""
BLUE = 7
"""Layer with one channel of blue color information."""
ALPHA = 8
"""Layer with one channel of alpha (opacity) information."""
MATTE = 9
"""Layer with one channel of matte (selection / mask) information."""
LUMINANCE = 10
"""Layer with one channel of luminance (intensity) information."""
DEPTH = 11
"""Layer with one channel of depth (distance from viewer) information."""
RGBA = 12
"""Layer with three channels of red-green-blue color and one channel of alpha (opacity) information."""
UV = 13
"""Layer with two channels of texture coordinate information."""
XYZ = 14
"""Layer with three channels of position information."""
VELOCITY = 15
"""Layer with three channels of velocity information."""
NORMAL = 16
"""Layer with three channels of normal vector information."""
[docs]def channels(role):
"""Return a set of standard channel names for a given role.
Parameters
----------
role: :class:`Role`, required
Returns
-------
channels: list of :class:`str`
"""
if role == Role.RGBA:
return ["R", "G", "B", "A"]
elif role == Role.RGB:
return ["R", "G", "B"]
elif role == Role.REDGREEN:
return ["R", "G"]
elif role == Role.GREENBLUE:
return ["G", "B"]
elif role == Role.REDBLUE:
return ["R", "B"]
elif role == Role.RED:
return ["R"]
elif role == Role.GREEN:
return ["G"]
elif role == Role.BLUE:
return ["B"]
elif role == Role.ALPHA:
return ["A"]
elif role == Role.MATTE:
return ["M"]
elif role == Role.LUMINANCE:
return ["Y"]
elif role == Role.DEPTH:
return ["Z"]
elif role == Role.UV:
return ["U", "V"]
elif role == Role.XYZ:
return ["X", "Y", "Z"]
elif role == Role.VELOCITY:
return ["X", "Y", "Z"]
elif role == Role.NORMAL:
return ["X", "Y", "Z"]
elif role == Role.NONE:
return [str(index) for index in range(self.data.shape[2])]
raise RuntimeError(f"Unknown role: {role}")
[docs]def depth(role):
"""Returns the number of channels (depth) for the given role.
Parameters
----------
role: :class:`Role`, required
Returns
-------
depth: :class:`int`
"""
if role in [Role.RGBA]:
return 4
elif role in [Role.RGB, Role.XYZ, Role.VELOCITY, Role.NORMAL]:
return 3
elif role in [Role.REDGREEN, Role.GREENBLUE, Role.REDBLUE, Role.UV]:
return 2
elif role in [Role.RED, Role.GREEN, Role.BLUE, Role.ALPHA, Role.MATTE, Role.LUMINANCE, Role.DEPTH]:
return 1
return None
[docs]def default_font():
"""Path to a default font file included with Imagecat.
Returns
-------
path: :class:`str`
Absolute path to the default Imagecat font.
"""
data_dir = os.path.abspath(os.path.dirname(__file__))
return os.path.join(data_dir, "LeagueSpartan-SemiBold.ttf")
[docs]def from_array(data, role=None, name=None):
data = numpy.atleast_3d(data)
if role is None:
if data.shape[2] == 1:
role = Role.LUMINANCE
elif data.shape[2] == 2:
role = Role.UV
elif data.shape[2] == 3:
role = Role.RGB
elif data.shape[2] == 4:
role = Role.RGBA
else:
role = Role.NONE
if name is None:
if role == Role.NONE:
name = ""
else:
name = "".join(channels(role))
return Image(layers={name: Layer(data=data, role=role)})
[docs]def match_layer_names(names, patterns):
"""Match image layer names against a pattern.
Use this function to implement operators that operate on multiple image
layers. `patterns` is a :class:`str` that can contain multiple whitespace
delimited patterns. Patterns can include ``"*"`` which matches everything, ``"?"``
to match a single character, ``"[seq]"`` to match any character in seq, and ``"[!seq]"``
to match any character not in seq.
Parameters
----------
names: Sequence of :class:`str`, required
The :ref:`image<images>` layer names to be matched.
pattern: :class:`str`, required
Whitespace delimited collection of patterns to match against layer names.
Returns
-------
names: sequence of :class:`str` layer names that match `patterns`.
"""
output = []
for name in names:
for pattern in patterns.split():
if fnmatch.fnmatchcase(name, pattern):
output.append(name)
break
return output
[docs]@imagecat.require.loaded_module("PIL.Image")
def to_pil(layer):
"""Convert a :class:`Layer` to a :class:`PIL.Image`.
This is useful for visualization and disk IO.
Parameters
----------
layer: :class:`Layer`, required
The layer to be converted
Returns
-------
pil_image: :class:`PIL.Image`
PIL image containing the layer data.
"""
if not isinstance(layer, Layer):
raise ValueError("Input must be an instance of imagecat.layer.Layer.") # pragma: no cover
data = layer.data
if layer.role in [Role.RGB, Role.REDGREEN, Role.GREENBLUE, Role.REDBLUE, Role.RED, Role.GREEN, Role.BLUE]:
data = imagecat.color.linear_to_srgb(data)
if layer.role != Role.RGB:
black = numpy.zeros(data.shape[:2] + (1,))
if layer.role == Role.REDGREEN:
data = numpy.dstack((data[:,:,0], data[:,:,1], black))
elif layer.role == Role.GREENBLUE:
data = numpy.dstack((black, data[:,:,0], data[:,:,1]))
elif layer.role == Role.REDBLUE:
data = numpy.dstack((data[:,:,0], black, data[:,:,1]))
elif layer.role == Role.RED:
data = numpy.dstack((data[:,:,0], black, black))
elif layer.role == Role.GREEN:
data = numpy.dstack((black, data[:,:,0], black))
elif layer.role == Role.BLUE:
data = numpy.dstack((black, black, data[:,:,0]))
data = (numpy.clip(data, 0, 1) * 255.0).astype(numpy.ubyte)
return PIL.Image.fromarray(data)
elif layer.role in [Role.ALPHA, Role.MATTE]:
data = data[:,:,0]
data = (numpy.clip(data, 0, 1) * 255.0).astype(numpy.ubyte)
return PIL.Image.fromarray(data)
elif layer.role in [Role.LUMINANCE, Role.DEPTH]:
data = data[:,:,0]
data = (numpy.clip(data, 0, 1) * 255.0).astype(numpy.ubyte)
return PIL.Image.fromarray(data)
elif layer.role in [Role.NONE] and layer.data.shape[2] == 1:
data = data[:,:,0]
data = (numpy.clip(data, 0, 1) * 255.0).astype(numpy.ubyte)
return PIL.Image.fromarray(data)