camera.py -> cairo_camera.py; delete other cairo camera subclasses

This commit is contained in:
Benjamin Hackl 2023-01-05 14:46:23 +01:00
commit 9c40838fcb
11 changed files with 9 additions and 1652 deletions

View file

@ -38,11 +38,7 @@ Cameras
*******
.. inheritance-diagram::
manim.camera.camera
manim.camera.mapping_camera
manim.camera.moving_camera
manim.camera.multi_camera
manim.camera.three_d_camera
manim.camera.cairo_camera
:parts: 1
:top-classes: manim.camera.camera.Camera, manim.mobject.mobject.Mobject

View file

@ -37,11 +37,7 @@ from .animation.transform import *
from .animation.transform_matching_parts import *
from .animation.updaters.mobject_update_utils import *
from .animation.updaters.update import *
from .camera.camera import *
from .camera.mapping_camera import *
from .camera.moving_camera import *
from .camera.multi_camera import *
from .camera.three_d_camera import *
from .camera.cairo_camera import *
from .constants import *
from .mobject.frame import *
from .mobject.geometry.arc import *

View file

@ -38,8 +38,8 @@ LINE_JOIN_MAP = {
}
class Camera:
"""Base camera class.
class CairoCamera:
"""Cairo rendering implementation.
This is the object which takes care of what exactly is displayed
on screen at any given moment.
@ -1211,135 +1211,3 @@ class Camera:
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.
class BackgroundColoredVMobjectDisplayer:
def __init__(self, camera: Camera):
"""
Parameters
----------
camera
Camera object to use.
"""
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
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)
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)
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
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

View file

@ -1,139 +0,0 @@
"""A camera that allows mapping between objects."""
from __future__ import annotations
__all__ = ["MappingCamera", "OldMultiCamera", "SplitScreenCamera"]
import math
import numpy as np
from ..camera.camera import Camera
from ..mobject.types.vectorized_mobject import VMobject
from ..utils.config_ops import DictAsObject
# TODO: Add an attribute to mobjects under which they can specify that they should just
# map their centers but remain otherwise undistorted (useful for labels, etc.)
class MappingCamera(Camera):
"""Camera object that allows mapping
between objects.
"""
def __init__(
self,
mapping_func=lambda p: p,
min_num_curves=50,
allow_object_intrusion=False,
**kwargs,
):
self.mapping_func = mapping_func
self.min_num_curves = min_num_curves
self.allow_object_intrusion = allow_object_intrusion
super().__init__(**kwargs)
def points_to_pixel_coords(self, mobject, points):
return super().points_to_pixel_coords(
mobject,
np.apply_along_axis(self.mapping_func, 1, points),
)
def capture_mobjects(self, mobjects, **kwargs):
mobjects = self.get_mobjects_to_display(mobjects, **kwargs)
if self.allow_object_intrusion:
mobject_copies = mobjects
else:
mobject_copies = [mobject.copy() for mobject in mobjects]
for mobject in mobject_copies:
if (
isinstance(mobject, VMobject)
and 0 < mobject.get_num_curves() < self.min_num_curves
):
mobject.insert_n_curves(self.min_num_curves)
super().capture_mobjects(
mobject_copies,
include_submobjects=False,
excluded_mobjects=None,
)
# Note: This allows layering of multiple cameras onto the same portion of the pixel array,
# the later cameras overwriting the former
#
# TODO: Add optional separator borders between cameras (or perhaps peel this off into a
# CameraPlusOverlay class)
# TODO, the classes below should likely be deleted
class OldMultiCamera(Camera):
def __init__(self, *cameras_with_start_positions, **kwargs):
self.shifted_cameras = [
DictAsObject(
{
"camera": camera_with_start_positions[0],
"start_x": camera_with_start_positions[1][1],
"start_y": camera_with_start_positions[1][0],
"end_x": camera_with_start_positions[1][1]
+ camera_with_start_positions[0].pixel_width,
"end_y": camera_with_start_positions[1][0]
+ camera_with_start_positions[0].pixel_height,
},
)
for camera_with_start_positions in cameras_with_start_positions
]
super().__init__(**kwargs)
def capture_mobjects(self, mobjects, **kwargs):
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.capture_mobjects(mobjects, **kwargs)
self.pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
] = shifted_camera.camera.pixel_array
def set_background(self, pixel_array, **kwargs):
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.set_background(
pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
],
**kwargs,
)
def set_pixel_array(self, pixel_array, **kwargs):
super().set_pixel_array(pixel_array, **kwargs)
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.set_pixel_array(
pixel_array[
shifted_camera.start_y : shifted_camera.end_y,
shifted_camera.start_x : shifted_camera.end_x,
],
**kwargs,
)
def init_background(self):
super().init_background()
for shifted_camera in self.shifted_cameras:
shifted_camera.camera.init_background()
# A OldMultiCamera which, when called with two full-size cameras, initializes itself
# as a split screen, also taking care to resize each individual camera within it
class SplitScreenCamera(OldMultiCamera):
def __init__(self, left_camera, right_camera, **kwargs):
Camera.__init__(self, **kwargs) # to set attributes such as pixel_width
self.left_camera = left_camera
self.right_camera = right_camera
half_width = math.ceil(self.pixel_width / 2)
for camera in [self.left_camera, self.right_camera]:
camera.reset_pixel_shape(camera.pixel_height, half_width)
super().__init__(
(left_camera, (0, 0)),
(right_camera, (0, half_width)),
)

View file

@ -1,253 +0,0 @@
"""A camera able to move through a scene.
.. SEEALSO::
:mod:`.moving_camera_scene`
"""
from __future__ import annotations
__all__ = ["MovingCamera"]
import numpy as np
from .. import config
from ..camera.camera import Camera
from ..constants import DOWN, LEFT, RIGHT, UP
from ..mobject.frame import ScreenRectangle
from ..mobject.mobject import Mobject
from ..utils.color import WHITE
class MovingCamera(Camera):
"""
Stays in line with the height, width and position of it's 'frame', which is a Rectangle
.. SEEALSO::
:class:`.MovingCameraScene`
"""
def __init__(
self,
frame=None,
fixed_dimension=0, # width
default_frame_stroke_color=WHITE,
default_frame_stroke_width=0,
**kwargs,
):
"""
Frame is a Mobject, (should almost certainly be a rectangle)
determining which region of space the camera displays
"""
self.fixed_dimension = fixed_dimension
self.default_frame_stroke_color = default_frame_stroke_color
self.default_frame_stroke_width = default_frame_stroke_width
if frame is None:
frame = ScreenRectangle(height=config["frame_height"])
frame.set_stroke(
self.default_frame_stroke_color,
self.default_frame_stroke_width,
)
self.frame = frame
super().__init__(**kwargs)
# TODO, make these work for a rotated frame
@property
def frame_height(self):
"""Returns the height of the frame.
Returns
-------
float
The height of the frame.
"""
return self.frame.height
@property
def frame_width(self):
"""Returns the width of the frame
Returns
-------
float
The width of the frame.
"""
return self.frame.width
@property
def frame_center(self):
"""Returns the centerpoint of the frame in cartesian coordinates.
Returns
-------
np.array
The cartesian coordinates of the center of the frame.
"""
return self.frame.get_center()
@frame_height.setter
def frame_height(self, frame_height: float):
"""Sets the height of the frame in MUnits.
Parameters
----------
frame_height
The new frame_height.
"""
self.frame.stretch_to_fit_height(frame_height)
@frame_width.setter
def frame_width(self, frame_width: float):
"""Sets the width of the frame in MUnits.
Parameters
----------
frame_width
The new frame_width.
"""
self.frame.stretch_to_fit_width(frame_width)
@frame_center.setter
def frame_center(self, frame_center: np.ndarray | list | tuple | Mobject):
"""Sets the centerpoint of the frame.
Parameters
----------
frame_center
The point to which the frame must be moved.
If is of type mobject, the frame will be moved to
the center of that mobject.
"""
self.frame.move_to(frame_center)
def capture_mobjects(self, mobjects, **kwargs):
# self.reset_frame_center()
# self.realign_frame_shape()
super().capture_mobjects(mobjects, **kwargs)
# Since the frame can be moving around, the cairo
# context used for updating should be regenerated
# at each frame. So no caching.
def get_cached_cairo_context(self, pixel_array):
"""
Since the frame can be moving around, the cairo
context used for updating should be regenerated
at each frame. So no caching.
"""
return None
def cache_cairo_context(self, pixel_array, ctx):
"""
Since the frame can be moving around, the cairo
context used for updating should be regenerated
at each frame. So no caching.
"""
pass
# def reset_frame_center(self):
# self.frame_center = self.frame.get_center()
# def realign_frame_shape(self):
# height, width = self.frame_shape
# if self.fixed_dimension == 0:
# self.frame_shape = (height, self.frame.width
# else:
# self.frame_shape = (self.frame.height, width)
# self.resize_frame_shape(fixed_dimension=self.fixed_dimension)
def get_mobjects_indicating_movement(self):
"""
Returns all mobjects whose movement implies that the camera
should think of all other mobjects on the screen as moving
Returns
-------
list
"""
return [self.frame]
def auto_zoom(
self,
mobjects: list[Mobject],
margin: float = 0,
only_mobjects_in_frame: bool = False,
animate: bool = True,
):
"""Zooms on to a given array of mobjects (or a singular mobject)
and automatically resizes to frame all the mobjects.
.. NOTE::
This method only works when 2D-objects in the XY-plane are considered, it
will not work correctly when the camera has been rotated.
Parameters
----------
mobjects
The mobject or array of mobjects that the camera will focus on.
margin
The width of the margin that is added to the frame (optional, 0 by default).
only_mobjects_in_frame
If set to ``True``, only allows focusing on mobjects that are already in frame.
animate
If set to ``False``, applies the changes instead of returning the corresponding animation
Returns
-------
Union[_AnimationBuilder, ScreenRectangle]
_AnimationBuilder that zooms the camera view to a given list of mobjects
or ScreenRectangle with position and size updated to zoomed position.
"""
scene_critical_x_left = None
scene_critical_x_right = None
scene_critical_y_up = None
scene_critical_y_down = None
for m in mobjects:
if (m == self.frame) or (
only_mobjects_in_frame and not self.is_in_frame(m)
):
# detected camera frame, should not be used to calculate final position of camera
continue
# initialize scene critical points with first mobjects critical points
if scene_critical_x_left is None:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
scene_critical_y_up = m.get_critical_point(UP)[1]
scene_critical_y_down = m.get_critical_point(DOWN)[1]
else:
if m.get_critical_point(LEFT)[0] < scene_critical_x_left:
scene_critical_x_left = m.get_critical_point(LEFT)[0]
if m.get_critical_point(RIGHT)[0] > scene_critical_x_right:
scene_critical_x_right = m.get_critical_point(RIGHT)[0]
if m.get_critical_point(UP)[1] > scene_critical_y_up:
scene_critical_y_up = m.get_critical_point(UP)[1]
if m.get_critical_point(DOWN)[1] < scene_critical_y_down:
scene_critical_y_down = m.get_critical_point(DOWN)[1]
# calculate center x and y
x = (scene_critical_x_left + scene_critical_x_right) / 2
y = (scene_critical_y_up + scene_critical_y_down) / 2
# calculate proposed width and height of zoomed scene
new_width = abs(scene_critical_x_left - scene_critical_x_right)
new_height = abs(scene_critical_y_up - scene_critical_y_down)
m_target = self.frame.animate if animate else self.frame
# zoom to fit all mobjects along the side that has the largest size
if new_width / self.frame.width > new_height / self.frame.height:
return m_target.set_x(x).set_y(y).set(width=new_width + margin)
else:
return m_target.set_x(x).set_y(y).set(height=new_height + margin)

View file

@ -1,101 +0,0 @@
"""A camera supporting multiple perspectives."""
from __future__ import annotations
__all__ = ["MultiCamera"]
from manim.mobject.types.image_mobject import ImageMobject
from ..camera.moving_camera import MovingCamera
from ..utils.iterables import list_difference_update
class MultiCamera(MovingCamera):
"""Camera Object that allows for multiple perspectives."""
def __init__(
self,
image_mobjects_from_cameras: ImageMobject | None = None,
allow_cameras_to_capture_their_own_display=False,
**kwargs,
):
"""Initialises the MultiCamera
Parameters
----------
image_mobjects_from_cameras
kwargs
Any valid keyword arguments of MovingCamera.
"""
self.image_mobjects_from_cameras = []
if image_mobjects_from_cameras is not None:
for imfc in image_mobjects_from_cameras:
self.add_image_mobject_from_camera(imfc)
self.allow_cameras_to_capture_their_own_display = (
allow_cameras_to_capture_their_own_display
)
super().__init__(**kwargs)
def add_image_mobject_from_camera(self, image_mobject_from_camera: ImageMobject):
"""Adds an ImageMobject that's been obtained from the camera
into the list ``self.image_mobject_from_cameras``
Parameters
----------
image_mobject_from_camera
The ImageMobject to add to self.image_mobject_from_cameras
"""
# A silly method to have right now, but maybe there are things
# we want to guarantee about any imfc's added later.
imfc = image_mobject_from_camera
assert isinstance(imfc.camera, MovingCamera)
self.image_mobjects_from_cameras.append(imfc)
def update_sub_cameras(self):
"""Reshape sub_camera pixel_arrays"""
for imfc in self.image_mobjects_from_cameras:
pixel_height, pixel_width = self.pixel_array.shape[:2]
imfc.camera.frame_shape = (
imfc.camera.frame.height,
imfc.camera.frame.width,
)
imfc.camera.reset_pixel_shape(
int(pixel_height * imfc.height / self.frame_height),
int(pixel_width * imfc.width / self.frame_width),
)
def reset(self):
"""Resets the MultiCamera.
Returns
-------
MultiCamera
The reset MultiCamera
"""
for imfc in self.image_mobjects_from_cameras:
imfc.camera.reset()
super().reset()
return self
def capture_mobjects(self, mobjects, **kwargs):
self.update_sub_cameras()
for imfc in self.image_mobjects_from_cameras:
to_add = list(mobjects)
if not self.allow_cameras_to_capture_their_own_display:
to_add = list_difference_update(to_add, imfc.get_family())
imfc.camera.capture_mobjects(to_add, **kwargs)
super().capture_mobjects(mobjects, **kwargs)
def get_mobjects_indicating_movement(self):
"""Returns all mobjects whose movement implies that the camera
should think of all other mobjects on the screen as moving
Returns
-------
list
"""
return [self.frame] + [
imfc.camera.frame for imfc in self.image_mobjects_from_cameras
]

View file

@ -1,441 +0,0 @@
"""A camera that can be positioned and oriented in three-dimensional space."""
from __future__ import annotations
__all__ = ["ThreeDCamera"]
from typing import Callable
import numpy as np
from manim.mobject.mobject import Mobject
from manim.mobject.three_d.three_d_utils import (
get_3d_vmob_end_corner,
get_3d_vmob_end_corner_unit_normal,
get_3d_vmob_start_corner,
get_3d_vmob_start_corner_unit_normal,
)
from manim.mobject.value_tracker import ValueTracker
from .. import config
from ..camera.camera import Camera
from ..constants import *
from ..mobject.types.point_cloud_mobject import Point
from ..utils.color import get_shaded_rgb
from ..utils.family import extract_mobject_family_members
from ..utils.space_ops import rotation_about_z, rotation_matrix
class ThreeDCamera(Camera):
def __init__(
self,
focal_distance=20.0,
shading_factor=0.2,
default_distance=5.0,
light_source_start_point=9 * DOWN + 7 * LEFT + 10 * OUT,
should_apply_shading=True,
exponential_projection=False,
phi=0,
theta=-90 * DEGREES,
gamma=0,
zoom=1,
**kwargs,
):
"""Initializes the ThreeDCamera
Parameters
----------
*kwargs
Any keyword argument of Camera.
"""
self._frame_center = Point(kwargs.get("frame_center", ORIGIN), stroke_width=0)
super().__init__(**kwargs)
self.focal_distance = focal_distance
self.phi = phi
self.theta = theta
self.gamma = gamma
self.zoom = zoom
self.shading_factor = shading_factor
self.default_distance = default_distance
self.light_source_start_point = light_source_start_point
self.light_source = Point(self.light_source_start_point)
self.should_apply_shading = should_apply_shading
self.exponential_projection = exponential_projection
self.max_allowable_norm = 3 * config["frame_width"]
self.phi_tracker = ValueTracker(self.phi)
self.theta_tracker = ValueTracker(self.theta)
self.focal_distance_tracker = ValueTracker(self.focal_distance)
self.gamma_tracker = ValueTracker(self.gamma)
self.zoom_tracker = ValueTracker(self.zoom)
self.fixed_orientation_mobjects = {}
self.fixed_in_frame_mobjects = set()
self.reset_rotation_matrix()
@property
def frame_center(self):
return self._frame_center.points[0]
@frame_center.setter
def frame_center(self, point):
self._frame_center.move_to(point)
def capture_mobjects(self, mobjects, **kwargs):
self.reset_rotation_matrix()
super().capture_mobjects(mobjects, **kwargs)
def get_value_trackers(self):
"""Returns list of ValueTrackers of phi, theta, focal_distance and gamma
Returns
-------
list
list of ValueTracker objects
"""
return [
self.phi_tracker,
self.theta_tracker,
self.focal_distance_tracker,
self.gamma_tracker,
self.zoom_tracker,
]
def modified_rgbas(self, vmobject, rgbas):
if not self.should_apply_shading:
return rgbas
if vmobject.shade_in_3d and (vmobject.get_num_points() > 0):
light_source_point = self.light_source.points[0]
if len(rgbas) < 2:
shaded_rgbas = rgbas.repeat(2, axis=0)
else:
shaded_rgbas = np.array(rgbas[:2])
shaded_rgbas[0, :3] = get_shaded_rgb(
shaded_rgbas[0, :3],
get_3d_vmob_start_corner(vmobject),
get_3d_vmob_start_corner_unit_normal(vmobject),
light_source_point,
)
shaded_rgbas[1, :3] = get_shaded_rgb(
shaded_rgbas[1, :3],
get_3d_vmob_end_corner(vmobject),
get_3d_vmob_end_corner_unit_normal(vmobject),
light_source_point,
)
return shaded_rgbas
return rgbas
def get_stroke_rgbas(
self,
vmobject,
background=False,
): # NOTE : DocStrings From parent
return self.modified_rgbas(vmobject, vmobject.get_stroke_rgbas(background))
def get_fill_rgbas(self, vmobject): # NOTE : DocStrings From parent
return self.modified_rgbas(vmobject, vmobject.get_fill_rgbas())
def get_mobjects_to_display(self, *args, **kwargs): # NOTE : DocStrings From parent
mobjects = super().get_mobjects_to_display(*args, **kwargs)
rot_matrix = self.get_rotation_matrix()
def z_key(mob):
if not (hasattr(mob, "shade_in_3d") and mob.shade_in_3d):
return np.inf
# Assign a number to a three dimensional mobjects
# based on how close it is to the camera
return np.dot(mob.get_z_index_reference_point(), rot_matrix.T)[2]
return sorted(mobjects, key=z_key)
def get_phi(self):
"""Returns the Polar angle (the angle off Z_AXIS) phi.
Returns
-------
float
The Polar angle in radians.
"""
return self.phi_tracker.get_value()
def get_theta(self):
"""Returns the Azimuthal i.e the angle that spins the camera around the Z_AXIS.
Returns
-------
float
The Azimuthal angle in radians.
"""
return self.theta_tracker.get_value()
def get_focal_distance(self):
"""Returns focal_distance of the Camera.
Returns
-------
float
The focal_distance of the Camera in MUnits.
"""
return self.focal_distance_tracker.get_value()
def get_gamma(self):
"""Returns the rotation of the camera about the vector from the ORIGIN to the Camera.
Returns
-------
float
The angle of rotation of the camera about the vector
from the ORIGIN to the Camera in radians
"""
return self.gamma_tracker.get_value()
def get_zoom(self):
"""Returns the zoom amount of the camera.
Returns
-------
float
The zoom amount of the camera.
"""
return self.zoom_tracker.get_value()
def set_phi(self, value: float):
"""Sets the polar angle i.e the angle between Z_AXIS and Camera through ORIGIN in radians.
Parameters
----------
value
The new value of the polar angle in radians.
"""
self.phi_tracker.set_value(value)
def set_theta(self, value: float):
"""Sets the azimuthal angle i.e the angle that spins the camera around Z_AXIS in radians.
Parameters
----------
value
The new value of the azimuthal angle in radians.
"""
self.theta_tracker.set_value(value)
def set_focal_distance(self, value: float):
"""Sets the focal_distance of the Camera.
Parameters
----------
value
The focal_distance of the Camera.
"""
self.focal_distance_tracker.set_value(value)
def set_gamma(self, value: float):
"""Sets the angle of rotation of the camera about the vector from the ORIGIN to the Camera.
Parameters
----------
value
The new angle of rotation of the camera.
"""
self.gamma_tracker.set_value(value)
def set_zoom(self, value: float):
"""Sets the zoom amount of the camera.
Parameters
----------
value
The zoom amount of the camera.
"""
self.zoom_tracker.set_value(value)
def reset_rotation_matrix(self):
"""Sets the value of self.rotation_matrix to
the matrix corresponding to the current position of the camera
"""
self.rotation_matrix = self.generate_rotation_matrix()
def get_rotation_matrix(self):
"""Returns the matrix corresponding to the current position of the camera.
Returns
-------
np.array
The matrix corresponding to the current position of the camera.
"""
return self.rotation_matrix
def generate_rotation_matrix(self):
"""Generates a rotation matrix based off the current position of the camera.
Returns
-------
np.array
The matrix corresponding to the current position of the camera.
"""
phi = self.get_phi()
theta = self.get_theta()
gamma = self.get_gamma()
matrices = [
rotation_about_z(-theta - 90 * DEGREES),
rotation_matrix(-phi, RIGHT),
rotation_about_z(gamma),
]
result = np.identity(3)
for matrix in matrices:
result = np.dot(matrix, result)
return result
def project_points(self, points: np.ndarray | list):
"""Applies the current rotation_matrix as a projection
matrix to the passed array of points.
Parameters
----------
points
The list of points to project.
Returns
-------
np.array
The points after projecting.
"""
frame_center = self.frame_center
focal_distance = self.get_focal_distance()
zoom = self.get_zoom()
rot_matrix = self.get_rotation_matrix()
points = points - frame_center
points = np.dot(points, rot_matrix.T)
zs = points[:, 2]
for i in 0, 1:
if self.exponential_projection:
# Proper projection would involve multiplying
# x and y by d / (d-z). But for points with high
# z value that causes weird artifacts, and applying
# the exponential helps smooth it out.
factor = np.exp(zs / focal_distance)
lt0 = zs < 0
factor[lt0] = focal_distance / (focal_distance - zs[lt0])
else:
factor = focal_distance / (focal_distance - zs)
factor[(focal_distance - zs) < 0] = 10**6
points[:, i] *= factor * zoom
return points
def project_point(self, point: list | np.ndarray):
"""Applies the current rotation_matrix as a projection
matrix to the passed point.
Parameters
----------
point
The point to project.
Returns
-------
np.array
The point after projection.
"""
return self.project_points(point.reshape((1, 3)))[0, :]
def transform_points_pre_display(
self,
mobject,
points,
): # TODO: Write Docstrings for this Method.
points = super().transform_points_pre_display(mobject, points)
fixed_orientation = mobject in self.fixed_orientation_mobjects
fixed_in_frame = mobject in self.fixed_in_frame_mobjects
if fixed_in_frame:
return points
if fixed_orientation:
center_func = self.fixed_orientation_mobjects[mobject]
center = center_func()
new_center = self.project_point(center)
return points + (new_center - center)
else:
return self.project_points(points)
def add_fixed_orientation_mobjects(
self,
*mobjects: Mobject,
use_static_center_func: bool = False,
center_func: Callable[[], np.ndarray] | None = None,
):
"""This method allows the mobject to have a fixed orientation,
even when the camera moves around.
E.G If it was passed through this method, facing the camera, it
will continue to face the camera even as the camera moves.
Highly useful when adding labels to graphs and the like.
Parameters
----------
*mobjects
The mobject whose orientation must be fixed.
use_static_center_func
Whether or not to use the function that takes the mobject's
center as centerpoint, by default False
center_func
The function which returns the centerpoint
with respect to which the mobject will be oriented, by default None
"""
# This prevents the computation of mobject.get_center
# every single time a projection happens
def get_static_center_func(mobject):
point = mobject.get_center()
return lambda: point
for mobject in mobjects:
if center_func:
func = center_func
elif use_static_center_func:
func = get_static_center_func(mobject)
else:
func = mobject.get_center
for submob in mobject.get_family():
self.fixed_orientation_mobjects[submob] = func
def add_fixed_in_frame_mobjects(self, *mobjects: Mobject):
"""This method allows the mobject to have a fixed position,
even when the camera moves around.
E.G If it was passed through this method, at the top of the frame, it
will continue to be displayed at the top of the frame.
Highly useful when displaying Titles or formulae or the like.
Parameters
----------
**mobjects
The mobject to fix in frame.
"""
for mobject in extract_mobject_family_members(mobjects):
self.fixed_in_frame_mobjects.add(mobject)
def remove_fixed_orientation_mobjects(self, *mobjects: Mobject):
"""If a mobject was fixed in its orientation by passing it through
:meth:`.add_fixed_orientation_mobjects`, then this undoes that fixing.
The Mobject will no longer have a fixed orientation.
Parameters
----------
mobjects
The mobjects whose orientation need not be fixed any longer.
"""
for mobject in extract_mobject_family_members(mobjects):
if mobject in self.fixed_orientation_mobjects:
del self.fixed_orientation_mobjects[mobject]
def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject):
"""If a mobject was fixed in frame by passing it through
:meth:`.add_fixed_in_frame_mobjects`, then this undoes that fixing.
The Mobject will no longer be fixed in frame.
Parameters
----------
mobjects
The mobjects which need not be fixed in frame any longer.
"""
for mobject in extract_mobject_family_members(mobjects):
if mobject in self.fixed_in_frame_mobjects:
self.fixed_in_frame_mobjects.remove(mobject)

View file

@ -768,7 +768,7 @@ class Mobject:
def get_image(self, camera=None):
if camera is None:
from ..camera.camera import Camera
from ..camera.cairo_camera import CairoCamera as Camera
camera = Camera()
camera.capture_mobject(self)

View file

@ -8,7 +8,7 @@ import numpy as np
from manim.utils.hashing import get_hash_from_play_call
from .. import config, logger
from ..camera.camera import Camera
from ..camera.cairo_camera import CairoCamera as Camera
from ..mobject.mobject import Mobject
from ..scene.scene_file_writer import SceneFileWriter
from ..utils.exceptions import EndSceneEarlyException

View file

@ -17,35 +17,17 @@ else:
import moderngl
import numpy as np
import OpenGL.GL as gl
from PIL import Image
from scipy.spatial.transform import Rotation
from manim import config, logger
from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
from manim.utils.caching import handle_caching_play
from manim.utils.color import BLACK, color_to_rgba
from manim.utils.exceptions import EndSceneEarlyException
from ..constants import *
from ..scene.scene_file_writer import SceneFileWriter
from ..utils import opengl
from ..utils.config_ops import _Data
from ..utils.simple_functions import clip, fdiv
from ..utils.space_ops import (
angle_of_vector,
normalize,
quaternion_from_angle_axis,
quaternion_mult,
rotation_matrix_transpose,
rotation_matrix_transpose_from_quaternion,
)
from .shader import Mesh, Shader
from .vectorized_mobject_rendering import (
render_opengl_vectorized_mobject_fill,
render_opengl_vectorized_mobject_stroke,
)
from ..utils.simple_functions import fdiv
from ..utils.space_ops import normalize
class OpenGLCameraFrame(OpenGLMobject):
@ -538,554 +520,3 @@ class OpenGLCamera:
if tid_and_texture:
tid_and_texture[1].release()
return self
class OpenGLCameraLegacy(OpenGLMobject):
euler_angles = _Data()
def __init__(
self,
frame_shape=None,
center_point=None,
# Theta, phi, gamma
euler_angles=[0, 0, 0],
focal_distance=2,
light_source_position=[-10, 10, 10],
orthographic=False,
minimum_polar_angle=-PI / 2,
maximum_polar_angle=PI / 2,
model_matrix=None,
**kwargs,
):
self.use_z_index = True
self.frame_rate = 60
self.orthographic = orthographic
self.minimum_polar_angle = minimum_polar_angle
self.maximum_polar_angle = maximum_polar_angle
if self.orthographic:
self.projection_matrix = opengl.orthographic_projection_matrix()
self.unformatted_projection_matrix = opengl.orthographic_projection_matrix(
format=False,
)
else:
self.projection_matrix = opengl.perspective_projection_matrix()
self.unformatted_projection_matrix = opengl.perspective_projection_matrix(
format=False,
)
if frame_shape is None:
self.frame_shape = (config["frame_width"], config["frame_height"])
else:
self.frame_shape = frame_shape
if center_point is None:
self.center_point = ORIGIN
else:
self.center_point = center_point
if model_matrix is None:
model_matrix = opengl.translation_matrix(0, 0, 11)
self.focal_distance = focal_distance
if light_source_position is None:
self.light_source_position = [-10, 10, 10]
else:
self.light_source_position = light_source_position
self.light_source = OpenGLPoint(self.light_source_position)
self.default_model_matrix = model_matrix
super().__init__(model_matrix=model_matrix, should_render=False, **kwargs)
if euler_angles is None:
euler_angles = [0, 0, 0]
euler_angles = np.array(euler_angles, dtype=float)
self.euler_angles = euler_angles
self.refresh_rotation_matrix()
def get_position(self):
return self.model_matrix[:, 3][:3]
def set_position(self, position):
self.model_matrix[:, 3][:3] = position
return self
@cached_property
def formatted_view_matrix(self):
return opengl.matrix_to_shader_input(np.linalg.inv(self.model_matrix))
@cached_property
def unformatted_view_matrix(self):
return np.linalg.inv(self.model_matrix)
def init_points(self):
self.set_points([ORIGIN, LEFT, RIGHT, DOWN, UP])
self.set_width(self.frame_shape[0], stretch=True)
self.set_height(self.frame_shape[1], stretch=True)
self.move_to(self.center_point)
def to_default_state(self):
self.center()
self.set_height(config["frame_height"])
self.set_width(config["frame_width"])
self.set_euler_angles(0, 0, 0)
self.model_matrix = self.default_model_matrix
return self
def refresh_rotation_matrix(self):
# Rotate based on camera orientation
theta, phi, gamma = self.euler_angles
quat = quaternion_mult(
quaternion_from_angle_axis(theta, OUT, axis_normalized=True),
quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True),
quaternion_from_angle_axis(gamma, OUT, axis_normalized=True),
)
self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion(quat)
def rotate(self, angle, axis=OUT, **kwargs):
curr_rot_T = self.inverse_rotation_matrix
added_rot_T = rotation_matrix_transpose(angle, axis)
new_rot_T = np.dot(curr_rot_T, added_rot_T)
Fz = new_rot_T[2]
phi = np.arccos(Fz[2])
theta = angle_of_vector(Fz[:2]) + PI / 2
partial_rot_T = np.dot(
rotation_matrix_transpose(phi, RIGHT),
rotation_matrix_transpose(theta, OUT),
)
gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0])
self.set_euler_angles(theta, phi, gamma)
return self
def set_euler_angles(self, theta=None, phi=None, gamma=None):
if theta is not None:
self.euler_angles[0] = theta
if phi is not None:
self.euler_angles[1] = phi
if gamma is not None:
self.euler_angles[2] = gamma
self.refresh_rotation_matrix()
return self
def set_theta(self, theta):
return self.set_euler_angles(theta=theta)
def set_phi(self, phi):
return self.set_euler_angles(phi=phi)
def set_gamma(self, gamma):
return self.set_euler_angles(gamma=gamma)
def increment_theta(self, dtheta):
self.euler_angles[0] += dtheta
self.refresh_rotation_matrix()
return self
def increment_phi(self, dphi):
phi = self.euler_angles[1]
new_phi = clip(phi + dphi, -PI / 2, PI / 2)
self.euler_angles[1] = new_phi
self.refresh_rotation_matrix()
return self
def increment_gamma(self, dgamma):
self.euler_angles[2] += dgamma
self.refresh_rotation_matrix()
return self
def get_shape(self):
return (self.get_width(), self.get_height())
def get_center(self):
# Assumes first point is at the center
return self.points[0]
def get_width(self):
points = self.points
return points[2, 0] - points[1, 0]
def get_height(self):
points = self.points
return points[4, 1] - points[3, 1]
def get_focal_distance(self):
return self.focal_distance * self.get_height()
def interpolate(self, *args, **kwargs):
super().interpolate(*args, **kwargs)
self.refresh_rotation_matrix()
points_per_curve = 3
class OpenGLRenderer:
def __init__(self, file_writer_class=SceneFileWriter, skip_animations=False):
# Measured in pixel widths, used for vector graphics
self.anti_alias_width = 1.5
self._file_writer_class = file_writer_class
self._original_skipping_status = skip_animations
self.skip_animations = skip_animations
self.animation_start_time = 0
self.animation_elapsed_time = 0
self.time = 0
self.animations_hashes = []
self.num_plays = 0
self.camera = OpenGLCamera()
self.pressed_keys = set()
# Initialize texture map.
self.path_to_texture_id = {}
self.background_color = config["background_color"]
def init_scene(self, scene):
self.partial_movie_files = []
self.file_writer: Any = self._file_writer_class(
self,
scene.__class__.__name__,
)
self.scene = scene
self.background_color = config["background_color"]
if not hasattr(self, "window"):
if self.should_create_window():
from .opengl_renderer_window import Window
self.window = Window(self)
self.context = self.window.ctx
self.frame_buffer_object = self.context.detect_framebuffer()
else:
self.window = None
try:
self.context = moderngl.create_context(standalone=True)
except Exception:
self.context = moderngl.create_context(
standalone=True,
backend="egl",
)
self.frame_buffer_object = self.get_frame_buffer_object(self.context, 0)
self.frame_buffer_object.use()
self.context.enable(moderngl.BLEND)
self.context.wireframe = config["enable_wireframe"]
self.context.blend_func = (
moderngl.SRC_ALPHA,
moderngl.ONE_MINUS_SRC_ALPHA,
moderngl.ONE,
moderngl.ONE,
)
def should_create_window(self):
if config["force_window"]:
logger.warning(
"'--force_window' is enabled, this is intended for debugging purposes "
"and may impact performance if used when outputting files",
)
return True
return (
config["preview"]
and not config["save_last_frame"]
and not config["format"]
and not config["write_to_movie"]
and not config["dry_run"]
)
def get_pixel_shape(self):
if hasattr(self, "frame_buffer_object"):
return self.frame_buffer_object.viewport[2:4]
else:
return None
def refresh_perspective_uniforms(self, camera):
pw, ph = self.get_pixel_shape()
fw, fh = camera.get_shape()
# TODO, this should probably be a mobject uniform, with
# the camera taking care of the conversion factor
anti_alias_width = self.anti_alias_width / (ph / fh)
# Orient light
rotation = camera.inverse_rotation_matrix
light_pos = camera.light_source.get_location()
light_pos = np.dot(rotation, light_pos)
self.perspective_uniforms = {
"frame_shape": camera.get_shape(),
"anti_alias_width": anti_alias_width,
"camera_center": tuple(camera.get_center()),
"camera_rotation": tuple(np.array(rotation).T.flatten()),
"light_source_position": tuple(light_pos),
"focal_distance": camera.get_focal_distance(),
}
def render_mobject(self, mobject):
if isinstance(mobject, OpenGLVMobject):
if config["use_projection_fill_shaders"]:
render_opengl_vectorized_mobject_fill(self, mobject)
if config["use_projection_stroke_shaders"]:
render_opengl_vectorized_mobject_stroke(self, mobject)
shader_wrapper_list = mobject.get_shader_wrapper_list()
# Convert ShaderWrappers to Meshes.
for shader_wrapper in shader_wrapper_list:
shader = Shader(self.context, shader_wrapper.shader_folder)
# Set textures.
for name, path in shader_wrapper.texture_paths.items():
tid = self.get_texture_id(path)
shader.shader_program[name].value = tid
# Set uniforms.
for name, value in it.chain(
shader_wrapper.uniforms.items(),
self.perspective_uniforms.items(),
):
try:
shader.set_uniform(name, value)
except KeyError:
pass
try:
shader.set_uniform(
"u_view_matrix", self.scene.camera.formatted_view_matrix
)
shader.set_uniform(
"u_projection_matrix",
self.scene.camera.projection_matrix,
)
except KeyError:
pass
# Set depth test.
if shader_wrapper.depth_test:
self.context.enable(moderngl.DEPTH_TEST)
else:
self.context.disable(moderngl.DEPTH_TEST)
# Render.
mesh = Mesh(
shader,
shader_wrapper.vert_data,
indices=shader_wrapper.vert_indices,
use_depth_test=shader_wrapper.depth_test,
primitive=mobject.render_primitive,
)
mesh.set_uniforms(self)
mesh.render()
def get_texture_id(self, path):
if repr(path) not in self.path_to_texture_id:
tid = len(self.path_to_texture_id)
texture = self.context.texture(
size=path.size,
components=len(path.getbands()),
data=path.tobytes(),
)
texture.repeat_x = False
texture.repeat_y = False
texture.filter = (moderngl.NEAREST, moderngl.NEAREST)
texture.swizzle = "RRR1" if path.mode == "L" else "RGBA"
texture.use(location=tid)
self.path_to_texture_id[repr(path)] = tid
return self.path_to_texture_id[repr(path)]
def update_skipping_status(self):
"""
This method is used internally to check if the current
animation needs to be skipped or not. It also checks if
the number of animations that were played correspond to
the number of animations that need to be played, and
raises an EndSceneEarlyException if they don't correspond.
"""
# there is always at least one section -> no out of bounds here
if self.file_writer.sections[-1].skip_animations:
self.skip_animations = True
if (
config["from_animation_number"]
and self.num_plays < config["from_animation_number"]
):
self.skip_animations = True
if (
config["upto_animation_number"]
and self.num_plays > config["upto_animation_number"]
):
self.skip_animations = True
raise EndSceneEarlyException()
@handle_caching_play
def play(self, scene, *args, **kwargs):
# TODO: Handle data locking / unlocking.
self.animation_start_time = time.time()
self.file_writer.begin_animation(not self.skip_animations)
scene.compile_animation_data(*args, **kwargs)
scene.begin_animations()
if scene.is_current_animation_frozen_frame():
self.update_frame(scene)
if not self.skip_animations:
for _ in range(int(config.frame_rate * scene.duration)):
self.file_writer.write_frame(self)
if self.window is not None:
self.window.swap_buffers()
while time.time() - self.animation_start_time < scene.duration:
pass
self.animation_elapsed_time = scene.duration
else:
scene.play_internal()
self.file_writer.end_animation(not self.skip_animations)
self.time += scene.duration
self.num_plays += 1
def clear_screen(self):
self.frame_buffer_object.clear(*self.background_color)
self.window.swap_buffers()
def render(self, scene, frame_offset, moving_mobjects):
self.update_frame(scene)
if self.skip_animations:
return
self.file_writer.write_frame(self)
if self.window is not None:
self.window.swap_buffers()
while self.animation_elapsed_time < frame_offset:
self.update_frame(scene)
self.window.swap_buffers()
def update_frame(self, scene):
self.frame_buffer_object.clear(*self.background_color)
self.refresh_perspective_uniforms(scene.camera)
for mobject in scene.mobjects:
self.render_mobject(mobject)
for obj in scene.meshes:
for mesh in obj.get_meshes():
mesh.set_uniforms(self)
mesh.render()
self.animation_elapsed_time = time.time() - self.animation_start_time
def scene_finished(self, scene):
# When num_plays is 0, no images have been output, so output a single
# image in this case
if self.num_plays > 0:
self.file_writer.finish()
elif self.num_plays == 0 and config.write_to_movie:
config.write_to_movie = False
if self.should_save_last_frame():
config.save_last_frame = True
self.update_frame(scene)
self.file_writer.save_final_image(self.get_image())
def should_save_last_frame(self):
if config["save_last_frame"]:
return True
if self.scene.interactive_mode:
return False
return self.num_plays == 0
def get_image(self) -> Image.Image:
"""Returns an image from the current frame. The first argument passed to image represents
the mode RGB with the alpha channel A. The data we read is from the currently bound frame
buffer. We pass in 'raw' as the name of the decoder, 0 and -1 args are specifically
used for the decoder tand represent the stride and orientation. 0 means there is no
padding expected between bytes and -1 represents the orientation and means the first
line of the image is the bottom line on the screen.
Returns
-------
PIL.Image
The PIL image of the array.
"""
raw_buffer_data = self.get_raw_frame_buffer_object_data()
image = Image.frombytes(
"RGBA",
self.get_pixel_shape(),
raw_buffer_data,
"raw",
"RGBA",
0,
-1,
)
return image
def save_static_frame_data(self, scene, static_mobjects):
pass
def get_frame_buffer_object(self, context, samples=0):
pixel_width = config["pixel_width"]
pixel_height = config["pixel_height"]
num_channels = 4
return context.framebuffer(
color_attachments=context.texture(
(pixel_width, pixel_height),
components=num_channels,
samples=samples,
),
depth_attachment=context.depth_renderbuffer(
(pixel_width, pixel_height),
samples=samples,
),
)
def get_raw_frame_buffer_object_data(self, dtype="f1"):
# Copy blocks from the fbo_msaa to the drawn fbo using Blit
# pw, ph = self.get_pixel_shape()
# gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo_msaa.glo)
# gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.fbo.glo)
# gl.glBlitFramebuffer(
# 0, 0, pw, ph, 0, 0, pw, ph, gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR
# )
num_channels = 4
ret = self.frame_buffer_object.read(
viewport=self.frame_buffer_object.viewport,
components=num_channels,
dtype=dtype,
)
return ret
def get_frame(self):
# get current pixel values as numpy data in order to test output
raw = self.get_raw_frame_buffer_object_data(dtype="f1")
pixel_shape = self.get_pixel_shape()
result_dimensions = (pixel_shape[1], pixel_shape[0], 4)
np_buf = np.frombuffer(raw, dtype="uint8").reshape(result_dimensions)
np_buf = np.flipud(np_buf)
return np_buf
# Returns offset from the bottom left corner in pixels.
# top_left flag should be set to True when using a GUI framework
# where the (0,0) is at the top left: e.g. PySide6
def pixel_coords_to_space_coords(self, px, py, relative=False, top_left=False):
pixel_shape = self.get_pixel_shape()
if pixel_shape is None:
return np.array([0, 0, 0])
pw, ph = pixel_shape
fw, fh = config["frame_width"], config["frame_height"]
fc = self.camera.get_center()
if relative:
return 2 * np.array([px / pw, py / ph, 0])
else:
# Only scale wrt one axis
scale = fh / ph
return fc + scale * np.array(
[(px - pw / 2), (-1 if top_left else 1) * (py - ph / 2), 0]
)
@property
def background_color(self):
return self._background_color
@background_color.setter
def background_color(self, value):
self._background_color = color_to_rgba(value, 1.0)

View file

@ -15,7 +15,7 @@ from typing import Any
import numpy as np
from manim.animation.animation import Animation
from manim.camera.camera import Camera
from manim.camera.cairo_camera import CairoCamera as Camera
from manim.mobject.mobject import Mobject
from .. import config, logger