"""A camera converts the mobjects contained in a Scene into an array of pixels."""
from __future__ import annotations
__all__ = ["Camera", "BackgroundColoredVMobjectDisplayer"]
import copy
import itertools as it
import operator as op
import pathlib
from collections.abc import Iterable
from functools import reduce
from typing import Any, Callable
import cairo
import numpy as np
from PIL import Image
from scipy.spatial.distance import pdist
from .. import config, logger
from ..constants import *
from ..mobject.mobject import Mobject
from ..mobject.types.image_mobject import AbstractImageMobject
from ..mobject.types.point_cloud_mobject import PMobject
from ..mobject.types.vectorized_mobject import VMobject
from ..utils.color import ManimColor, ParsableManimColor, color_to_int_rgba
from ..utils.family import extract_mobject_family_members
from ..utils.images import get_full_raster_image_path
from ..utils.iterables import list_difference_update
from ..utils.space_ops import angle_of_vector
LINE_JOIN_MAP = {
LineJointType.AUTO: None, # TODO: this could be improved
LineJointType.ROUND: cairo.LineJoin.ROUND,
LineJointType.BEVEL: cairo.LineJoin.BEVEL,
LineJointType.MITER: cairo.LineJoin.MITER,
}
CAP_STYLE_MAP = {
CapStyleType.AUTO: None, # TODO: this could be improved
CapStyleType.ROUND: cairo.LineCap.ROUND,
CapStyleType.BUTT: cairo.LineCap.BUTT,
CapStyleType.SQUARE: cairo.LineCap.SQUARE,
}
[docs]
class Camera:
"""Base camera class.
This is the object which takes care of what exactly is displayed
on screen at any given moment.
Parameters
----------
background_image
The path to an image that should be the background image.
If not set, the background is filled with :attr:`self.background_color`
background
What :attr:`background` is set to. By default, ``None``.
pixel_height
The height of the scene in pixels.
pixel_width
The width of the scene in pixels.
kwargs
Additional arguments (``background_color``, ``background_opacity``)
to be set.
"""
def __init__(
self,
background_image: str | None = None,
frame_center: np.ndarray = ORIGIN,
image_mode: str = "RGBA",
n_channels: int = 4,
pixel_array_dtype: str = "uint8",
cairo_line_width_multiple: float = 0.01,
use_z_index: bool = True,
background: np.ndarray | None = None,
pixel_height: int | None = None,
pixel_width: int | None = None,
frame_height: float | None = None,
frame_width: float | None = None,
frame_rate: float | None = None,
background_color: ParsableManimColor | None = None,
background_opacity: float | None = None,
**kwargs,
):
self.background_image = background_image
self.frame_center = frame_center
self.image_mode = image_mode
self.n_channels = n_channels
self.pixel_array_dtype = pixel_array_dtype
self.cairo_line_width_multiple = cairo_line_width_multiple
self.use_z_index = use_z_index
self.background = background
if pixel_height is None:
pixel_height = config["pixel_height"]
self.pixel_height = pixel_height
if pixel_width is None:
pixel_width = config["pixel_width"]
self.pixel_width = pixel_width
if frame_height is None:
frame_height = config["frame_height"]
self.frame_height = frame_height
if frame_width is None:
frame_width = config["frame_width"]
self.frame_width = frame_width
if frame_rate is None:
frame_rate = config["frame_rate"]
self.frame_rate = frame_rate
if background_color is None:
self._background_color = ManimColor.parse(config["background_color"])
else:
self._background_color = ManimColor.parse(background_color)
if background_opacity is None:
self._background_opacity = config["background_opacity"]
else:
self._background_opacity = background_opacity
# This one is in the same boat as the above, but it doesn't have the
# same name as the corresponding key so it has to be handled on its own
self.max_allowable_norm = config["frame_width"]
self.rgb_max_val = np.iinfo(self.pixel_array_dtype).max
self.pixel_array_to_cairo_context = {}
# Contains the correct method to process a list of Mobjects of the
# corresponding class. If a Mobject is not an instance of a class in
# this dict (or an instance of a class that inherits from a class in
# this dict), then it cannot be rendered.
self.init_background()
self.resize_frame_shape()
self.reset()
def __deepcopy__(self, memo):
# This is to address a strange bug where deepcopying
# will result in a segfault, which is somehow related
# to the aggdraw library
self.canvas = None
return copy.copy(self)
@property
def background_color(self):
return self._background_color
@background_color.setter
def background_color(self, color):
self._background_color = color
self.init_background()
@property
def background_opacity(self):
return self._background_opacity
@background_opacity.setter
def background_opacity(self, alpha):
self._background_opacity = alpha
self.init_background()
[docs]
def type_or_raise(self, mobject: Mobject):
"""Return the type of mobject, if it is a type that can be rendered.
If `mobject` is an instance of a class that inherits from a class that
can be rendered, return the super class. For example, an instance of a
Square is also an instance of VMobject, and these can be rendered.
Therefore, `type_or_raise(Square())` returns True.
Parameters
----------
mobject
The object to take the type of.
Notes
-----
For a list of classes that can currently be rendered, see :meth:`display_funcs`.
Returns
-------
Type[:class:`~.Mobject`]
The type of mobjects, if it can be rendered.
Raises
------
:exc:`TypeError`
When mobject is not an instance of a class that can be rendered.
"""
self.display_funcs = {
VMobject: self.display_multiple_vectorized_mobjects,
PMobject: self.display_multiple_point_cloud_mobjects,
AbstractImageMobject: self.display_multiple_image_mobjects,
Mobject: lambda batch, pa: batch, # Do nothing
}
# We have to check each type in turn because we are dealing with
# super classes. For example, if square = Square(), then
# type(square) != VMobject, but isinstance(square, VMobject) == True.
for _type in self.display_funcs:
if isinstance(mobject, _type):
return _type
raise TypeError(f"Displaying an object of class {_type} is not supported")
[docs]
def reset_pixel_shape(self, new_height: float, new_width: float):
"""This method resets the height and width
of a single pixel to the passed new_height and new_width.
Parameters
----------
new_height
The new height of the entire scene in pixels
new_width
The new width of the entire scene in pixels
"""
self.pixel_width = new_width
self.pixel_height = new_height
self.init_background()
self.resize_frame_shape()
self.reset()
[docs]
def resize_frame_shape(self, fixed_dimension: int = 0):
"""
Changes frame_shape to match the aspect ratio
of the pixels, where fixed_dimension determines
whether frame_height or frame_width
remains fixed while the other changes accordingly.
Parameters
----------
fixed_dimension
If 0, height is scaled with respect to width
else, width is scaled with respect to height.
"""
pixel_height = self.pixel_height
pixel_width = self.pixel_width
frame_height = self.frame_height
frame_width = self.frame_width
aspect_ratio = pixel_width / pixel_height
if fixed_dimension == 0:
frame_height = frame_width / aspect_ratio
else:
frame_width = aspect_ratio * frame_height
self.frame_height = frame_height
self.frame_width = frame_width
[docs]
def init_background(self):
"""Initialize the background.
If self.background_image is the path of an image
the image is set as background; else, the default
background color fills the background.
"""
height = self.pixel_height
width = self.pixel_width
if self.background_image is not None:
path = get_full_raster_image_path(self.background_image)
image = Image.open(path).convert(self.image_mode)
# TODO, how to gracefully handle backgrounds
# with different sizes?
self.background = np.array(image)[:height, :width]
self.background = self.background.astype(self.pixel_array_dtype)
else:
background_rgba = color_to_int_rgba(
self.background_color,
self.background_opacity,
)
self.background = np.zeros(
(height, width, self.n_channels),
dtype=self.pixel_array_dtype,
)
self.background[:, :] = background_rgba
[docs]
def get_image(self, pixel_array: np.ndarray | list | tuple | None = None):
"""Returns an image from the passed
pixel array, or from the current frame
if the passed pixel array is none.
Parameters
----------
pixel_array
The pixel array from which to get an image, by default None
Returns
-------
PIL.Image
The PIL image of the array.
"""
if pixel_array is None:
pixel_array = self.pixel_array
return Image.fromarray(pixel_array, mode=self.image_mode)
[docs]
def convert_pixel_array(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
"""Converts a pixel array from values that have floats in then
to proper RGB values.
Parameters
----------
pixel_array
Pixel array to convert.
convert_from_floats
Whether or not to convert float values to ints, by default False
Returns
-------
np.array
The new, converted pixel array.
"""
retval = np.array(pixel_array)
if convert_from_floats:
retval = np.apply_along_axis(
lambda f: (f * self.rgb_max_val).astype(self.pixel_array_dtype),
2,
retval,
)
return retval
[docs]
def set_pixel_array(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
"""Sets the pixel array of the camera to the passed pixel array.
Parameters
----------
pixel_array
The pixel array to convert and then set as the camera's pixel array.
convert_from_floats
Whether or not to convert float values to proper RGB values, by default False
"""
converted_array = self.convert_pixel_array(pixel_array, convert_from_floats)
if not (
hasattr(self, "pixel_array")
and self.pixel_array.shape == converted_array.shape
):
self.pixel_array = converted_array
else:
# Set in place
self.pixel_array[:, :, :] = converted_array[:, :, :]
[docs]
def set_background(
self, pixel_array: np.ndarray | list | tuple, convert_from_floats: bool = False
):
"""Sets the background to the passed pixel_array after converting
to valid RGB values.
Parameters
----------
pixel_array
The pixel array to set the background to.
convert_from_floats
Whether or not to convert floats values to proper RGB valid ones, by default False
"""
self.background = self.convert_pixel_array(pixel_array, convert_from_floats)
# TODO, this should live in utils, not as a method of Camera
[docs]
def make_background_from_func(
self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray]
):
"""
Makes a pixel array for the background by using coords_to_colors_func to determine each pixel's color. Each input
pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not
pixel coordinates), and each output is expected to be an RGBA array of 4 floats.
Parameters
----------
coords_to_colors_func
The function whose input is an (x,y) pair of coordinates and
whose return values must be the colors for that point
Returns
-------
np.array
The pixel array which can then be passed to set_background.
"""
logger.info("Starting set_background")
coords = self.get_coords_of_all_pixels()
new_background = np.apply_along_axis(coords_to_colors_func, 2, coords)
logger.info("Ending set_background")
return self.convert_pixel_array(new_background, convert_from_floats=True)
[docs]
def set_background_from_func(
self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray]
):
"""
Sets the background to a pixel array using coords_to_colors_func to determine each pixel's color. Each input
pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not
pixel coordinates), and each output is expected to be an RGBA array of 4 floats.
Parameters
----------
coords_to_colors_func
The function whose input is an (x,y) pair of coordinates and
whose return values must be the colors for that point
"""
self.set_background(self.make_background_from_func(coords_to_colors_func))
[docs]
def reset(self):
"""Resets the camera's pixel array
to that of the background
Returns
-------
Camera
The camera object after setting the pixel array.
"""
self.set_pixel_array(self.background)
return self
def set_frame_to_background(self, background):
self.set_pixel_array(background)
####
[docs]
def get_mobjects_to_display(
self,
mobjects: Iterable[Mobject],
include_submobjects: bool = True,
excluded_mobjects: list | None = None,
):
"""Used to get the list of mobjects to display
with the camera.
Parameters
----------
mobjects
The Mobjects
include_submobjects
Whether or not to include the submobjects of mobjects, by default True
excluded_mobjects
Any mobjects to exclude, by default None
Returns
-------
list
list of mobjects
"""
if include_submobjects:
mobjects = extract_mobject_family_members(
mobjects,
use_z_index=self.use_z_index,
only_those_with_points=True,
)
if excluded_mobjects:
all_excluded = extract_mobject_family_members(
excluded_mobjects,
use_z_index=self.use_z_index,
)
mobjects = list_difference_update(mobjects, all_excluded)
return list(mobjects)
[docs]
def is_in_frame(self, mobject: Mobject):
"""Checks whether the passed mobject is in
frame or not.
Parameters
----------
mobject
The mobject for which the checking needs to be done.
Returns
-------
bool
True if in frame, False otherwise.
"""
fc = self.frame_center
fh = self.frame_height
fw = self.frame_width
return not reduce(
op.or_,
[
mobject.get_right()[0] < fc[0] - fw / 2,
mobject.get_bottom()[1] > fc[1] + fh / 2,
mobject.get_left()[0] > fc[0] + fw / 2,
mobject.get_top()[1] < fc[1] - fh / 2,
],
)
[docs]
def capture_mobject(self, mobject: Mobject, **kwargs: Any):
"""Capture mobjects by storing it in :attr:`pixel_array`.
This is a single-mobject version of :meth:`capture_mobjects`.
Parameters
----------
mobject
Mobject to capture.
kwargs
Keyword arguments to be passed to :meth:`get_mobjects_to_display`.
"""
return self.capture_mobjects([mobject], **kwargs)
[docs]
def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs):
"""Capture mobjects by printing them on :attr:`pixel_array`.
This is the essential function that converts the contents of a Scene
into an array, which is then converted to an image or video.
Parameters
----------
mobjects
Mobjects to capture.
kwargs
Keyword arguments to be passed to :meth:`get_mobjects_to_display`.
Notes
-----
For a list of classes that can currently be rendered, see :meth:`display_funcs`.
"""
# The mobjects will be processed in batches (or runs) of mobjects of
# the same type. That is, if the list mobjects contains objects of
# types [VMobject, VMobject, VMobject, PMobject, PMobject, VMobject],
# then they will be captured in three batches: [VMobject, VMobject,
# VMobject], [PMobject, PMobject], and [VMobject]. This must be done
# without altering their order. it.groupby computes exactly this
# partition while at the same time preserving order.
mobjects = self.get_mobjects_to_display(mobjects, **kwargs)
for group_type, group in it.groupby(mobjects, self.type_or_raise):
self.display_funcs[group_type](list(group), self.pixel_array)
# Methods associated with svg rendering
# NOTE: None of the methods below have been mentioned outside of their definitions. Their DocStrings are not as
# detailed as possible.
[docs]
def get_cached_cairo_context(self, pixel_array: np.ndarray):
"""Returns the cached cairo context of the passed
pixel array if it exists, and None if it doesn't.
Parameters
----------
pixel_array
The pixel array to check.
Returns
-------
cairo.Context
The cached cairo context.
"""
return self.pixel_array_to_cairo_context.get(id(pixel_array), None)
[docs]
def cache_cairo_context(self, pixel_array: np.ndarray, ctx: cairo.Context):
"""Caches the passed Pixel array into a Cairo Context
Parameters
----------
pixel_array
The pixel array to cache
ctx
The context to cache it into.
"""
self.pixel_array_to_cairo_context[id(pixel_array)] = ctx
[docs]
def get_cairo_context(self, pixel_array: np.ndarray):
"""Returns the cairo context for a pixel array after
caching it to self.pixel_array_to_cairo_context
If that array has already been cached, it returns the
cached version instead.
Parameters
----------
pixel_array
The Pixel array to get the cairo context of.
Returns
-------
cairo.Context
The cairo context of the pixel array.
"""
cached_ctx = self.get_cached_cairo_context(pixel_array)
if cached_ctx:
return cached_ctx
pw = self.pixel_width
ph = self.pixel_height
fw = self.frame_width
fh = self.frame_height
fc = self.frame_center
surface = cairo.ImageSurface.create_for_data(
pixel_array,
cairo.FORMAT_ARGB32,
pw,
ph,
)
ctx = cairo.Context(surface)
ctx.scale(pw, ph)
ctx.set_matrix(
cairo.Matrix(
(pw / fw),
0,
0,
-(ph / fh),
(pw / 2) - fc[0] * (pw / fw),
(ph / 2) + fc[1] * (ph / fh),
),
)
self.cache_cairo_context(pixel_array, ctx)
return ctx
[docs]
def display_multiple_vectorized_mobjects(
self, vmobjects: list, pixel_array: np.ndarray
):
"""Displays multiple VMobjects in the pixel_array
Parameters
----------
vmobjects
list of VMobjects to display
pixel_array
The pixel array
"""
if len(vmobjects) == 0:
return
batch_image_pairs = it.groupby(vmobjects, lambda vm: vm.get_background_image())
for image, batch in batch_image_pairs:
if image:
self.display_multiple_background_colored_vmobjects(batch, pixel_array)
else:
self.display_multiple_non_background_colored_vmobjects(
batch,
pixel_array,
)
[docs]
def display_multiple_non_background_colored_vmobjects(
self, vmobjects: list, pixel_array: np.ndarray
):
"""Displays multiple VMobjects in the cairo context, as long as they don't have
background colors.
Parameters
----------
vmobjects
list of the VMobjects
pixel_array
The Pixel array to add the VMobjects to.
"""
ctx = self.get_cairo_context(pixel_array)
for vmobject in vmobjects:
self.display_vectorized(vmobject, ctx)
[docs]
def display_vectorized(self, vmobject: VMobject, ctx: cairo.Context):
"""Displays a VMobject in the cairo context
Parameters
----------
vmobject
The Vectorized Mobject to display
ctx
The cairo context to use.
Returns
-------
Camera
The camera object
"""
self.set_cairo_context_path(ctx, vmobject)
self.apply_stroke(ctx, vmobject, background=True)
self.apply_fill(ctx, vmobject)
self.apply_stroke(ctx, vmobject)
return self
[docs]
def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject):
"""Sets a path for the cairo context with the vmobject passed
Parameters
----------
ctx
The cairo context
vmobject
The VMobject
Returns
-------
Camera
Camera object after setting cairo_context_path
"""
points = self.transform_points_pre_display(vmobject, vmobject.points)
# TODO, shouldn't this be handled in transform_points_pre_display?
# points = points - self.get_frame_center()
if len(points) == 0:
return
ctx.new_path()
subpaths = vmobject.gen_subpaths_from_points_2d(points)
for subpath in subpaths:
quads = vmobject.gen_cubic_bezier_tuples_from_points(subpath)
ctx.new_sub_path()
start = subpath[0]
ctx.move_to(*start[:2])
for _p0, p1, p2, p3 in quads:
ctx.curve_to(*p1[:2], *p2[:2], *p3[:2])
if vmobject.consider_points_equals_2d(subpath[0], subpath[-1]):
ctx.close_path()
return self
[docs]
def set_cairo_context_color(
self, ctx: cairo.Context, rgbas: np.ndarray, vmobject: VMobject
):
"""Sets the color of the cairo context
Parameters
----------
ctx
The cairo context
rgbas
The RGBA array with which to color the context.
vmobject
The VMobject with which to set the color.
Returns
-------
Camera
The camera object
"""
if len(rgbas) == 1:
# Use reversed rgb because cairo surface is
# encodes it in reverse order
ctx.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3])
else:
points = vmobject.get_gradient_start_and_end_points()
points = self.transform_points_pre_display(vmobject, points)
pat = cairo.LinearGradient(*it.chain(*(point[:2] for point in points)))
step = 1.0 / (len(rgbas) - 1)
offsets = np.arange(0, 1 + step, step)
for rgba, offset in zip(rgbas, offsets):
pat.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3])
ctx.set_source(pat)
return self
[docs]
def apply_fill(self, ctx: cairo.Context, vmobject: VMobject):
"""Fills the cairo context
Parameters
----------
ctx
The cairo context
vmobject
The VMobject
Returns
-------
Camera
The camera object.
"""
self.set_cairo_context_color(ctx, self.get_fill_rgbas(vmobject), vmobject)
ctx.fill_preserve()
return self
[docs]
def apply_stroke(
self, ctx: cairo.Context, vmobject: VMobject, background: bool = False
):
"""Applies a stroke to the VMobject in the cairo context.
Parameters
----------
ctx
The cairo context
vmobject
The VMobject
background
Whether or not to consider the background when applying this
stroke width, by default False
Returns
-------
Camera
The camera object with the stroke applied.
"""
width = vmobject.get_stroke_width(background)
if width == 0:
return self
self.set_cairo_context_color(
ctx,
self.get_stroke_rgbas(vmobject, background=background),
vmobject,
)
ctx.set_line_width(
width
* self.cairo_line_width_multiple
* (self.frame_width / self.frame_width),
# This ensures lines have constant width as you zoom in on them.
)
if vmobject.joint_type != LineJointType.AUTO:
ctx.set_line_join(LINE_JOIN_MAP[vmobject.joint_type])
if vmobject.cap_style != CapStyleType.AUTO:
ctx.set_line_cap(CAP_STYLE_MAP[vmobject.cap_style])
ctx.stroke_preserve()
return self
[docs]
def get_stroke_rgbas(self, vmobject: VMobject, background: bool = False):
"""Gets the RGBA array for the stroke of the passed
VMobject.
Parameters
----------
vmobject
The VMobject
background
Whether or not to consider the background when getting the stroke
RGBAs, by default False
Returns
-------
np.ndarray
The RGBA array of the stroke.
"""
return vmobject.get_stroke_rgbas(background)
[docs]
def get_fill_rgbas(self, vmobject: VMobject):
"""Returns the RGBA array of the fill of the passed VMobject
Parameters
----------
vmobject
The VMobject
Returns
-------
np.array
The RGBA Array of the fill of the VMobject
"""
return vmobject.get_fill_rgbas()
[docs]
def get_background_colored_vmobject_displayer(self):
"""Returns the background_colored_vmobject_displayer
if it exists or makes one and returns it if not.
Returns
-------
BackGroundColoredVMobjectDisplayer
Object that displays VMobjects that have the same color
as the background.
"""
# Quite wordy to type out a bunch
bcvd = "background_colored_vmobject_displayer"
if not hasattr(self, bcvd):
setattr(self, bcvd, BackgroundColoredVMobjectDisplayer(self))
return getattr(self, bcvd)
[docs]
def display_multiple_background_colored_vmobjects(
self, cvmobjects: list, pixel_array: np.ndarray
):
"""Displays multiple vmobjects that have the same color as the background.
Parameters
----------
cvmobjects
List of Colored VMobjects
pixel_array
The pixel array.
Returns
-------
Camera
The camera object.
"""
displayer = self.get_background_colored_vmobject_displayer()
cvmobject_pixel_array = displayer.display(*cvmobjects)
self.overlay_rgba_array(pixel_array, cvmobject_pixel_array)
return self
# Methods for other rendering
# NOTE: Out of the following methods, only `transform_points_pre_display` and `points_to_pixel_coords` have been mentioned outside of their definitions.
# As a result, the other methods do not have as detailed docstrings as would be preferred.
[docs]
def display_multiple_point_cloud_mobjects(
self, pmobjects: list, pixel_array: np.ndarray
):
"""Displays multiple PMobjects by modifying the passed pixel array.
Parameters
----------
pmobjects
List of PMobjects
pixel_array
The pixel array to modify.
"""
for pmobject in pmobjects:
self.display_point_cloud(
pmobject,
pmobject.points,
pmobject.rgbas,
self.adjusted_thickness(pmobject.stroke_width),
pixel_array,
)
[docs]
def display_point_cloud(
self,
pmobject: PMobject,
points: list,
rgbas: np.ndarray,
thickness: float,
pixel_array: np.ndarray,
):
"""Displays a PMobject by modifying the pixel array suitably.
TODO: Write a description for the rgbas argument.
Parameters
----------
pmobject
Point Cloud Mobject
points
The points to display in the point cloud mobject
rgbas
thickness
The thickness of each point of the PMobject
pixel_array
The pixel array to modify.
"""
if len(points) == 0:
return
pixel_coords = self.points_to_pixel_coords(pmobject, points)
pixel_coords = self.thickened_coordinates(pixel_coords, thickness)
rgba_len = pixel_array.shape[2]
rgbas = (self.rgb_max_val * rgbas).astype(self.pixel_array_dtype)
target_len = len(pixel_coords)
factor = target_len // len(rgbas)
rgbas = np.array([rgbas] * factor).reshape((target_len, rgba_len))
on_screen_indices = self.on_screen_pixels(pixel_coords)
pixel_coords = pixel_coords[on_screen_indices]
rgbas = rgbas[on_screen_indices]
ph = self.pixel_height
pw = self.pixel_width
flattener = np.array([1, pw], dtype="int")
flattener = flattener.reshape((2, 1))
indices = np.dot(pixel_coords, flattener)[:, 0]
indices = indices.astype("int")
new_pa = pixel_array.reshape((ph * pw, rgba_len))
new_pa[indices] = rgbas
pixel_array[:, :] = new_pa.reshape((ph, pw, rgba_len))
[docs]
def display_multiple_image_mobjects(
self, image_mobjects: list, pixel_array: np.ndarray
):
"""Displays multiple image mobjects by modifying the passed pixel_array.
Parameters
----------
image_mobjects
list of ImageMobjects
pixel_array
The pixel array to modify.
"""
for image_mobject in image_mobjects:
self.display_image_mobject(image_mobject, pixel_array)
[docs]
def display_image_mobject(
self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray
):
"""Displays an ImageMobject by changing the pixel_array suitably.
Parameters
----------
image_mobject
The imageMobject to display
pixel_array
The Pixel array to put the imagemobject in.
"""
corner_coords = self.points_to_pixel_coords(image_mobject, image_mobject.points)
ul_coords, ur_coords, dl_coords, _ = corner_coords
right_vect = ur_coords - ul_coords
down_vect = dl_coords - ul_coords
center_coords = ul_coords + (right_vect + down_vect) / 2
sub_image = Image.fromarray(image_mobject.get_pixel_array(), mode="RGBA")
# Reshape
pixel_width = max(int(pdist([ul_coords, ur_coords]).item()), 1)
pixel_height = max(int(pdist([ul_coords, dl_coords]).item()), 1)
sub_image = sub_image.resize(
(pixel_width, pixel_height),
resample=image_mobject.resampling_algorithm,
)
# Rotate
angle = angle_of_vector(right_vect)
adjusted_angle = -int(360 * angle / TAU)
if adjusted_angle != 0:
sub_image = sub_image.rotate(
adjusted_angle,
resample=image_mobject.resampling_algorithm,
expand=1,
)
# TODO, there is no accounting for a shear...
# Paste into an image as large as the camera's pixel array
full_image = Image.fromarray(
np.zeros((self.pixel_height, self.pixel_width)),
mode="RGBA",
)
new_ul_coords = center_coords - np.array(sub_image.size) / 2
new_ul_coords = new_ul_coords.astype(int)
full_image.paste(
sub_image,
box=(
new_ul_coords[0],
new_ul_coords[1],
new_ul_coords[0] + sub_image.size[0],
new_ul_coords[1] + sub_image.size[1],
),
)
# Paint on top of existing pixel array
self.overlay_PIL_image(pixel_array, full_image)
[docs]
def overlay_rgba_array(self, pixel_array: np.ndarray, new_array: np.ndarray):
"""Overlays an RGBA array on top of the given Pixel array.
Parameters
----------
pixel_array
The original pixel array to modify.
new_array
The new pixel array to overlay.
"""
self.overlay_PIL_image(pixel_array, self.get_image(new_array))
[docs]
def overlay_PIL_image(self, pixel_array: np.ndarray, image: Image):
"""Overlays a PIL image on the passed pixel array.
Parameters
----------
pixel_array
The Pixel array
image
The Image to overlay.
"""
pixel_array[:, :] = np.array(
Image.alpha_composite(self.get_image(pixel_array), image),
dtype="uint8",
)
[docs]
def adjust_out_of_range_points(self, points: np.ndarray):
"""If any of the points in the passed array are out of
the viable range, they are adjusted suitably.
Parameters
----------
points
The points to adjust
Returns
-------
np.array
The adjusted points.
"""
if not np.any(points > self.max_allowable_norm):
return points
norms = np.apply_along_axis(np.linalg.norm, 1, points)
violator_indices = norms > self.max_allowable_norm
violators = points[violator_indices, :]
violator_norms = norms[violator_indices]
reshaped_norms = np.repeat(
violator_norms.reshape((len(violator_norms), 1)),
points.shape[1],
1,
)
rescaled = self.max_allowable_norm * violators / reshaped_norms
points[violator_indices] = rescaled
return points
def transform_points_pre_display(
self,
mobject,
points,
): # TODO: Write more detailed docstrings for this method.
# NOTE: There seems to be an unused argument `mobject`.
# Subclasses (like ThreeDCamera) may want to
# adjust points further before they're shown
if not np.all(np.isfinite(points)):
# TODO, print some kind of warning about
# mobject having invalid points?
points = np.zeros((1, 3))
return points
def points_to_pixel_coords(
self,
mobject,
points,
): # TODO: Write more detailed docstrings for this method.
points = self.transform_points_pre_display(mobject, points)
shifted_points = points - self.frame_center
result = np.zeros((len(points), 2))
pixel_height = self.pixel_height
pixel_width = self.pixel_width
frame_height = self.frame_height
frame_width = self.frame_width
width_mult = pixel_width / frame_width
width_add = pixel_width / 2
height_mult = pixel_height / frame_height
height_add = pixel_height / 2
# Flip on y-axis as you go
height_mult *= -1
result[:, 0] = shifted_points[:, 0] * width_mult + width_add
result[:, 1] = shifted_points[:, 1] * height_mult + height_add
return result.astype("int")
[docs]
def on_screen_pixels(self, pixel_coords: np.ndarray):
"""Returns array of pixels that are on the screen from a given
array of pixel_coordinates
Parameters
----------
pixel_coords
The pixel coords to check.
Returns
-------
np.array
The pixel coords on screen.
"""
return reduce(
op.and_,
[
pixel_coords[:, 0] >= 0,
pixel_coords[:, 0] < self.pixel_width,
pixel_coords[:, 1] >= 0,
pixel_coords[:, 1] < self.pixel_height,
],
)
[docs]
def adjusted_thickness(self, thickness: float) -> float:
"""Computes the adjusted stroke width for a zoomed camera.
Parameters
----------
thickness
The stroke width of a mobject.
Returns
-------
float
The adjusted stroke width that reflects zooming in with
the camera.
"""
# TODO: This seems...unsystematic
big_sum = op.add(config["pixel_height"], config["pixel_width"])
this_sum = op.add(self.pixel_height, self.pixel_width)
factor = big_sum / this_sum
return 1 + (thickness - 1) * factor
[docs]
def get_thickening_nudges(self, thickness: float):
"""Determine a list of vectors used to nudge
two-dimensional pixel coordinates.
Parameters
----------
thickness
Returns
-------
np.array
"""
thickness = int(thickness)
_range = list(range(-thickness // 2 + 1, thickness // 2 + 1))
return np.array(list(it.product(_range, _range)))
[docs]
def thickened_coordinates(self, pixel_coords: np.ndarray, thickness: float):
"""Returns thickened coordinates for a passed array of pixel coords and
a thickness to thicken by.
Parameters
----------
pixel_coords
Pixel coordinates
thickness
Thickness
Returns
-------
np.array
Array of thickened pixel coords.
"""
nudges = self.get_thickening_nudges(thickness)
pixel_coords = np.array([pixel_coords + nudge for nudge in nudges])
size = pixel_coords.size
return pixel_coords.reshape((size // 2, 2))
# TODO, reimplement using cairo matrix
[docs]
def get_coords_of_all_pixels(self):
"""Returns the cartesian coordinates of each pixel.
Returns
-------
np.ndarray
The array of cartesian coordinates.
"""
# These are in x, y order, to help me keep things straight
full_space_dims = np.array([self.frame_width, self.frame_height])
full_pixel_dims = np.array([self.pixel_width, self.pixel_height])
# These are addressed in the same y, x order as in pixel_array, but the values in them
# are listed in x, y order
uncentered_pixel_coords = np.indices([self.pixel_height, self.pixel_width])[
::-1
].transpose(1, 2, 0)
uncentered_space_coords = (
uncentered_pixel_coords * full_space_dims
) / full_pixel_dims
# Could structure above line's computation slightly differently, but figured (without much
# thought) multiplying by frame_shape first, THEN dividing by pixel_shape, is probably
# better than the other order, for avoiding underflow quantization in the division (whereas
# overflow is unlikely to be a problem)
centered_space_coords = uncentered_space_coords - (full_space_dims / 2)
# Have to also flip the y coordinates to account for pixel array being listed in
# top-to-bottom order, opposite of screen coordinate convention
centered_space_coords = centered_space_coords * (1, -1)
return centered_space_coords
# NOTE: The methods of the following class have not been mentioned outside of their definitions.
# Their DocStrings are not as detailed as preferred.
[docs]
class BackgroundColoredVMobjectDisplayer:
"""Auxiliary class that handles displaying vectorized mobjects with
a set background image.
Parameters
----------
camera
Camera object to use.
"""
def __init__(self, camera: Camera):
self.camera = camera
self.file_name_to_pixel_array_map = {}
self.pixel_array = np.array(camera.pixel_array)
self.reset_pixel_array()
def reset_pixel_array(self):
self.pixel_array[:, :] = 0
[docs]
def resize_background_array(
self,
background_array: np.ndarray,
new_width: float,
new_height: float,
mode: str = "RGBA",
):
"""Resizes the pixel array representing the background.
Parameters
----------
background_array
The pixel
new_width
The new width of the background
new_height
The new height of the background
mode
The PIL image mode, by default "RGBA"
Returns
-------
np.array
The numpy pixel array of the resized background.
"""
image = Image.fromarray(background_array)
image = image.convert(mode)
resized_image = image.resize((new_width, new_height))
return np.array(resized_image)
[docs]
def resize_background_array_to_match(
self, background_array: np.ndarray, pixel_array: np.ndarray
):
"""Resizes the background array to match the passed pixel array.
Parameters
----------
background_array
The prospective pixel array.
pixel_array
The pixel array whose width and height should be matched.
Returns
-------
np.array
The resized background array.
"""
height, width = pixel_array.shape[:2]
mode = "RGBA" if pixel_array.shape[2] == 4 else "RGB"
return self.resize_background_array(background_array, width, height, mode)
[docs]
def get_background_array(self, image: Image.Image | pathlib.Path | str):
"""Gets the background array that has the passed file_name.
Parameters
----------
image
The background image or its file name.
Returns
-------
np.ndarray
The pixel array of the image.
"""
image_key = str(image)
if image_key in self.file_name_to_pixel_array_map:
return self.file_name_to_pixel_array_map[image_key]
if isinstance(image, str):
full_path = get_full_raster_image_path(image)
image = Image.open(full_path)
back_array = np.array(image)
pixel_array = self.pixel_array
if not np.all(pixel_array.shape == back_array.shape):
back_array = self.resize_background_array_to_match(back_array, pixel_array)
self.file_name_to_pixel_array_map[image_key] = back_array
return back_array
[docs]
def display(self, *cvmobjects: VMobject):
"""Displays the colored VMobjects.
Parameters
----------
*cvmobjects
The VMobjects
Returns
-------
np.array
The pixel array with the `cvmobjects` displayed.
"""
batch_image_pairs = it.groupby(cvmobjects, lambda cv: cv.get_background_image())
curr_array = None
for image, batch in batch_image_pairs:
background_array = self.get_background_array(image)
pixel_array = self.pixel_array
self.camera.display_multiple_non_background_colored_vmobjects(
batch,
pixel_array,
)
new_array = np.array(
(background_array * pixel_array.astype("float") / 255),
dtype=self.camera.pixel_array_dtype,
)
if curr_array is None:
curr_array = new_array
else:
curr_array = np.maximum(curr_array, new_array)
self.reset_pixel_array()
return curr_array