"""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 Callable, Iterable
from functools import reduce
from typing import TYPE_CHECKING, Any, Self
import cairo
import numpy as np
from PIL import Image
from manim._config import config, logger
from manim.constants import *
from manim.mobject.mobject import Mobject
from manim.mobject.types.point_cloud_mobject import PMobject
from manim.mobject.types.vectorized_mobject import VMobject
from manim.utils.color import ManimColor, ParsableManimColor, color_to_int_rgba
from manim.utils.family import extract_mobject_family_members
from manim.utils.images import get_full_raster_image_path
from manim.utils.iterables import list_difference_update
from manim.utils.space_ops import cross2d
if TYPE_CHECKING:
import numpy.typing as npt
from manim.mobject.types.image_mobject import AbstractImageMobject
from manim.typing import (
FloatRGBA_Array,
FloatRGBALike_Array,
ManimFloat,
ManimInt,
PixelArray,
Point3D,
Point3D_Array,
)
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: Point3D = 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: PixelArray | 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: Any,
) -> None:
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
self.background_colored_vmobject_displayer: (
BackgroundColoredVMobjectDisplayer | None
) = None
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 = ManimColor.parse(
config["background_color"]
)
else:
self._background_color = ManimColor.parse(background_color)
if background_opacity is None:
self._background_opacity: float = 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: dict[int, 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: Any) -> Camera:
# 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) -> ManimColor:
return self._background_color
@background_color.setter
def background_color(self, color: ManimColor) -> None:
self._background_color = color
self.init_background()
@property
def background_opacity(self) -> float:
return self._background_opacity
@background_opacity.setter
def background_opacity(self, alpha: float) -> None:
self._background_opacity = alpha
self.init_background()
[docs]
def type_or_raise(
self, mobject: Mobject
) -> type[VMobject] | type[PMobject] | type[AbstractImageMobject] | type[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.
"""
from ..mobject.types.image_mobject import AbstractImageMobject
self.display_funcs: dict[
type[Mobject], Callable[[list[Mobject], PixelArray], Any]
] = {
VMobject: self.display_multiple_vectorized_mobjects, # type: ignore[dict-item]
PMobject: self.display_multiple_point_cloud_mobjects, # type: ignore[dict-item]
AbstractImageMobject: self.display_multiple_image_mobjects, # type: ignore[dict-item]
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) -> None:
"""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) -> None:
"""
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) -> None:
"""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: PixelArray | list | tuple | None = None
) -> Image.Image:
"""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.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: PixelArray | list | tuple, convert_from_floats: bool = False
) -> PixelArray:
"""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: PixelArray | list | tuple, convert_from_floats: bool = False
) -> None:
"""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: PixelArray = 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: PixelArray = converted_array
else:
# Set in place
self.pixel_array[:, :, :] = converted_array[:, :, :]
[docs]
def set_background(
self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False
) -> None:
"""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]
) -> PixelArray:
"""
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]
) -> None:
"""
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) -> 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: PixelArray) -> None:
self.set_pixel_array(background)
####
[docs]
def get_mobjects_to_display(
self,
mobjects: Iterable[Mobject],
include_submobjects: bool = True,
excluded_mobjects: list | None = None,
) -> list[Mobject]:
"""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) -> bool:
"""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) -> None:
"""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: Any) -> None:
"""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: PixelArray) -> cairo.Context | None:
"""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: PixelArray, ctx: cairo.Context) -> None:
"""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: PixelArray) -> cairo.Context:
"""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.data,
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[VMobject], pixel_array: PixelArray
) -> None:
"""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: Iterable[VMobject], pixel_array: PixelArray
) -> None:
"""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) -> Self:
"""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) -> Self:
"""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 self
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: FloatRGBALike_Array, vmobject: VMobject
) -> Self:
"""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)))
offsets = np.linspace(0, 1, len(rgbas))
for rgba, offset in zip(rgbas, offsets, strict=True):
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) -> Self:
"""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
) -> Self:
"""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
) -> FloatRGBA_Array:
"""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) -> FloatRGBA_Array:
"""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,
) -> BackgroundColoredVMobjectDisplayer:
"""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.
"""
if self.background_colored_vmobject_displayer is None:
self.background_colored_vmobject_displayer = (
BackgroundColoredVMobjectDisplayer(self)
)
return self.background_colored_vmobject_displayer
[docs]
def display_multiple_background_colored_vmobjects(
self, cvmobjects: Iterable[VMobject], pixel_array: PixelArray
) -> Self:
"""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: Iterable[PMobject], pixel_array: PixelArray
) -> None:
"""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: Point3D_Array,
rgbas: FloatRGBA_Array,
thickness: float,
pixel_array: PixelArray,
) -> None:
"""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: Iterable[AbstractImageMobject],
pixel_array: PixelArray,
) -> None:
"""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
) -> None:
"""Display an :class:`~.ImageMobject` by changing the ``pixel_array`` suitably.
Parameters
----------
image_mobject
The :class:`~.ImageMobject` to display.
pixel_array
The pixel array to put the :class:`~.ImageMobject` in.
"""
sub_image = Image.fromarray(image_mobject.get_pixel_array(), mode="RGBA")
original_coords = np.array(
[
[0, 0],
[sub_image.width, 0],
[0, sub_image.height],
[sub_image.width, sub_image.height],
]
)
target_coords = self.points_to_subpixel_coords(
image_mobject, image_mobject.points
)
int_target_coords = target_coords.astype(np.int64)
# Temporarily translate target coords to upper left corner to calculate the
# smallest possible size for the target image.
shift_vector = np.array(
[
min(*[x for x, y in int_target_coords]),
min(*[y for x, y in int_target_coords]),
]
)
target_coords -= shift_vector
int_target_coords -= shift_vector
target_size = (
max(*[x for x, y in int_target_coords]),
max(*[y for x, y in int_target_coords]),
)
# Check that the quadrilateral of the transformed image can actually contain any
# pixels by checking that its height from the longest side is longer than 0.5 pixels.
# If it's not, do not render the image. Otherwise, the perspective transform
# coefficients below might have broken values due to the extreme distortion (for
# example, when the image is perpendicular to the camera).
ordered_vertices = [target_coords[i] for i in (0, 1, 3, 2)]
sides = [ordered_vertices[(i + 1) % 4] - ordered_vertices[i] for i in range(4)]
side_lengths_in_pixels = np.linalg.norm(sides, axis=1)
longest_side_index = np.argmax(side_lengths_in_pixels)
longest_side = sides[longest_side_index]
longest_side_length_in_pixels = side_lengths_in_pixels[longest_side_index]
if longest_side_length_in_pixels == 0:
return
previous_side = sides[(longest_side_index - 1) % 4]
next_side = sides[(longest_side_index - 1) % 4]
# height = area / base
h1 = abs(cross2d(longest_side, previous_side)) / longest_side_length_in_pixels
h2 = abs(cross2d(longest_side, next_side)) / longest_side_length_in_pixels
height_from_longest_side_in_pixels = max(h1, h2)
if height_from_longest_side_in_pixels < 0.5:
return
# Use PIL.Image.Image.transform() to apply a perspective transform to the image.
# The transform coefficients must be calculated. The following is adapted from:
# https://pc-pillow.readthedocs.io/en/latest/Image_class/Image_transform.html#transform-perspective-coefficients
# https://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil
# The derivation can be found here:
# https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/
homography_matrix = []
for (x, y), (X, Y) in zip(target_coords, original_coords, strict=True):
homography_matrix.append([x, y, 1, 0, 0, 0, -X * x, -X * y])
homography_matrix.append([0, 0, 0, x, y, 1, -Y * x, -Y * y])
A = np.array(homography_matrix, dtype=np.float64)
b = original_coords.reshape(8).astype(np.float64)
try:
transform_coefficients = np.linalg.solve(A, b)
except np.linalg.LinAlgError:
# The matrix A might be singular if three points are collinear.
# In this case, do nothing and return.
return
sub_image = sub_image.transform(
size=target_size, # Use the smallest possible size for speed.
method=Image.Transform.PERSPECTIVE,
data=transform_coefficients,
resample=image_mobject.resampling_algorithm,
)
# 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",
)
full_image.paste(
sub_image,
box=(
shift_vector[0],
shift_vector[1],
shift_vector[0] + target_size[0],
shift_vector[1] + target_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
) -> None:
"""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) -> None:
"""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) -> 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: Mobject,
points: Point3D_Array,
) -> Point3D_Array: # 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_subpixel_coords(
self,
mobject: Mobject,
points: Point3D_Array,
) -> npt.NDArray[
ManimFloat
]: # 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
def points_to_pixel_coords(
self,
mobject: Mobject,
points: Point3D_Array,
) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method.
return self.points_to_subpixel_coords(mobject, points).astype(np.int64)
[docs]
def on_screen_pixels(self, pixel_coords: np.ndarray) -> PixelArray:
"""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: float = op.add(config["pixel_height"], config["pixel_width"])
this_sum: float = 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) -> PixelArray:
"""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
) -> PixelArray:
"""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) -> PixelArray:
"""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: dict[str, PixelArray] = {}
self.pixel_array = np.array(camera.pixel_array)
self.reset_pixel_array()
def reset_pixel_array(self) -> None:
self.pixel_array[:, :] = 0
[docs]
def resize_background_array(
self,
background_array: PixelArray,
new_width: float,
new_height: float,
mode: str = "RGBA",
) -> PixelArray:
"""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: PixelArray, pixel_array: PixelArray
) -> PixelArray:
"""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
) -> PixelArray:
"""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) -> PixelArray | None:
"""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