diff --git a/manim/camera/camera.py b/manim/camera/camera.py index af5899c5c5..8a1d7493ae 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -15,19 +15,18 @@ 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 + +from manim._config import config, logger +from manim.constants import * +from manim.mobject.mobject import Mobject +from manim.mobject.types.image_mobject import AbstractImageMobject +from manim.mobject.types.point_cloud_mobject import PMobject +from manim.mobject.types.vectorized_mobject import VMobject +from manim.typing import ManimFloat +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 LINE_JOIN_MAP = { LineJointType.AUTO: None, # TODO: this could be improved @@ -963,61 +962,84 @@ def display_multiple_image_mobjects( def display_image_mobject( self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray - ): - """Displays an ImageMobject by changing the pixel_array suitably. + ) -> None: + """Displays an :class:`~.ImageMobject` by changing the ``pixel_array`` suitably. Parameters ---------- image_mobject - The imageMobject to display + The :class:`~.ImageMobject` to display. pixel_array - The Pixel array to put the imagemobject in. + The pixel array to put the :class:`~.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") + 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_pixel_coords(image_mobject, image_mobject.points) - # 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, + # 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 target_coords]), + min(*[y for x, y in target_coords]), + ] + ) + target_coords -= shift_vector + target_size = ( + max(*[x for x, y in target_coords]), + max(*[y for x, y in target_coords]), ) - # 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, - ) + # 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://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil + # and + # 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): + 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=ManimFloat) + b = original_coords.reshape(8).astype(ManimFloat) + + try: + transform_coefficients = np.linalg.solve(A, b) + except np.linalg.LinAlgError: + # The matrix A might be singular. + # In this case, do nothing and return. + return - # TODO, there is no accounting for a shear... + 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 + # 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], + 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 + # Paint on top of existing pixel array. self.overlay_PIL_image(pixel_array, full_image) def overlay_rgba_array(self, pixel_array: np.ndarray, new_array: np.ndarray): diff --git a/manim/constants.py b/manim/constants.py index 0a3e00da85..4383da6f9b 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -111,14 +111,10 @@ RESAMPLING_ALGORITHMS = { "nearest": Resampling.NEAREST, "none": Resampling.NEAREST, - "lanczos": Resampling.LANCZOS, - "antialias": Resampling.LANCZOS, "bilinear": Resampling.BILINEAR, "linear": Resampling.BILINEAR, "bicubic": Resampling.BICUBIC, "cubic": Resampling.BICUBIC, - "box": Resampling.BOX, - "hamming": Resampling.HAMMING, } # Geometry: directions diff --git a/manim/mobject/types/image_mobject.py b/manim/mobject/types/image_mobject.py index 56029f941e..807aec7fec 100644 --- a/manim/mobject/types/image_mobject.py +++ b/manim/mobject/types/image_mobject.py @@ -85,15 +85,15 @@ def set_resampling_algorithm(self, resampling_algorithm: int) -> Self: * 'hamming' * 'lanczos' or 'antialias' """ - if isinstance(resampling_algorithm, int): - self.resampling_algorithm = resampling_algorithm - else: + if resampling_algorithm not in RESAMPLING_ALGORITHMS.values(): raise ValueError( "resampling_algorithm has to be an int, one of the values defined in " "RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. " - "Available algorithms: 'bicubic', 'nearest', 'box', 'bilinear', " - "'hamming', 'lanczos'.", + "Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), " + "'bilinear' (or 'linear').", ) + + self.resampling_algorithm = resampling_algorithm return self def reset_points(self) -> None: @@ -155,27 +155,18 @@ def construct(self): [0, 0, 0, 255] ])) - img.height = 2 - img1 = img.copy() - img2 = img.copy() - img3 = img.copy() - img4 = img.copy() - img5 = img.copy() - - img1.set_resampling_algorithm(RESAMPLING_ALGORITHMS["nearest"]) - img2.set_resampling_algorithm(RESAMPLING_ALGORITHMS["lanczos"]) - img3.set_resampling_algorithm(RESAMPLING_ALGORITHMS["linear"]) - img4.set_resampling_algorithm(RESAMPLING_ALGORITHMS["cubic"]) - img5.set_resampling_algorithm(RESAMPLING_ALGORITHMS["box"]) - img1.add(Text("nearest").scale(0.5).next_to(img1,UP)) - img2.add(Text("lanczos").scale(0.5).next_to(img2,UP)) - img3.add(Text("linear").scale(0.5).next_to(img3,UP)) - img4.add(Text("cubic").scale(0.5).next_to(img4,UP)) - img5.add(Text("box").scale(0.5).next_to(img5,UP)) - - x= Group(img1,img2,img3,img4,img5) - x.arrange() - self.add(x) + img.height = 3 + + group = Group() + algorithm_texts = ["nearest", "linear", "cubic"] + for algorithm_text in algorithm_texts: + algorithm = RESAMPLING_ALGORITHMS[algorithm_text] + img_copy = img.copy().set_resampling_algorithm(algorithm) + img_copy.add(Text(algorithm_text).scale(0.5).next_to(img_copy, UP)) + group.add(img_copy) + + group.arrange() + self.add(group) """ def __init__( diff --git a/tests/test_graphical_units/control_data/img_and_svg/ImageInterpolation.npz b/tests/test_graphical_units/control_data/img_and_svg/ImageInterpolation.npz index dec7b5f42d..b4b1685ff1 100644 Binary files a/tests/test_graphical_units/control_data/img_and_svg/ImageInterpolation.npz and b/tests/test_graphical_units/control_data/img_and_svg/ImageInterpolation.npz differ diff --git a/tests/test_graphical_units/control_data/img_and_svg/ImageMobject.npz b/tests/test_graphical_units/control_data/img_and_svg/ImageMobject.npz index aa28d6e139..3694b5e9db 100644 Binary files a/tests/test_graphical_units/control_data/img_and_svg/ImageMobject.npz and b/tests/test_graphical_units/control_data/img_and_svg/ImageMobject.npz differ diff --git a/tests/test_graphical_units/test_img_and_svg.py b/tests/test_graphical_units/test_img_and_svg.py index b2ae820cb6..7c353fe9bb 100644 --- a/tests/test_graphical_units/test_img_and_svg.py +++ b/tests/test_graphical_units/test_img_and_svg.py @@ -268,21 +268,16 @@ def test_ImageInterpolation(scene): img = ImageMobject( np.uint8([[63, 0, 0, 0], [0, 127, 0, 0], [0, 0, 191, 0], [0, 0, 0, 255]]), ) - img.height = 2 - img1 = img.copy() - img2 = img.copy() - img3 = img.copy() - img4 = img.copy() - img5 = img.copy() - - img1.set_resampling_algorithm(RESAMPLING_ALGORITHMS["nearest"]) - img2.set_resampling_algorithm(RESAMPLING_ALGORITHMS["lanczos"]) - img3.set_resampling_algorithm(RESAMPLING_ALGORITHMS["linear"]) - img4.set_resampling_algorithm(RESAMPLING_ALGORITHMS["cubic"]) - img5.set_resampling_algorithm(RESAMPLING_ALGORITHMS["box"]) - - scene.add(img1, img2, img3, img4, img5) - [s.shift(4 * LEFT + pos * 2 * RIGHT) for pos, s in enumerate(scene.mobjects)] + img.height = 3 + + algorithm_texts = ["nearest", "linear", "cubic"] + for i, algorithm_text in enumerate(algorithm_texts): + algorithm = RESAMPLING_ALGORITHMS[algorithm_text] + img_copy = img.copy().set_resampling_algorithm(algorithm) + position = img.height * (i - (len(algorithm_texts) - 1) / 2) * RIGHT + img_copy.move_to(position) + scene.add(img_copy) + scene.wait()