Создаём анимационные обучающие видео на Python с помощью Manim
Привет! Меня зовут Константин Мохов, я тимлид, который однажды прошёл курс Практикума по аналитике данных, по большей части для собственного развития. Тема создания анимированных видео на Python заинтересовала меня позже, когда в телеграм-канале Алексея Макарова из Практикума появилось сообщение, что его команде нужна помощь с анимацией. Мне захотелось попробовать создать интересное и наглядное обучающее видео, раскрывающее одну из тем курса, например, гистограммы.
Я углубился в изучение вопроса и перечитал немало статей на тему создания анимации «как у 3Blue1Brown», которые в основном были либо переводами, либо копией оригинального туториала Гранта Сандерсона. Грант создал и выложил в открытый доступ специальную библиотеку на Python — Manim, которая предназначена для создания анимации. В роликах, запрограммированных с помощью Manim, он объясняет математические темы на своём YouTube-канале.
В этой статье я поделюсь личным опытом: рецептом создания объектов и анимаций. Вместе мы создадим обучающее видео о гистограммах. Вот как будет выглядеть итоговый вариант:
А теперь поехали!
Готовим проект к запуску
Для начала установим пакеты LaTeX и FFmpeg в систему — они нужны для рендера видео и текста (все операции производятся в macOS Big Sur). Для установки LaTeX перейдём на сайт www.tug.org/mactex, скачаем и установим пакет в систему. Есть и более каноничный вариант — использовать Brew и сразу установить оба пакета, как и написано в Readme репозитория:
> brew install ffmpeg mactex
Важно! Иногда после установки Manim отказывается рендерить видео, в которых есть русский текст (возможно, это особенности MacOS). Сначала я грешил на неправильную установку LaTeX, так как ошибка указывала именно на него, но дело оказалось в другом. Объясню ниже.
Собирать и запускать проект будем по-модному — через Poetry.
> poetry new habr_manim
> cd habr_manim
> poetry add manimlib
> touch main.py
> mkdir classes
Пробуем создать простейшее видео
Для создания видео нам потребуются:
- текст,
- шарики (точки),
- эффекты появления и затухания.
В нашем примере новым объектом будет только текст, так как для применения собственного шрифта стандартный класс необходимо перегрузить, добавив переменную класса CONFIG. Остальные элементы — стандартные классы из Manim.
Создадим файл histogram_text.py со следующим содержимым:
from manimlib.imports import Text
TEXT_FONT_FAMILY = "Suisse Intl Regular"
class HistogramText(Text):
"""Overridden class of Text to assign a new font"""
CONFIG = {
"font": TEXT_FONT_FAMILY,
}
Для создания шариков используем элемент библиотеки Dot, который принимает кучу разных параметров. Нам нужны следующие:
- point (координаты объекта на плоскости),
- radius (размер),
- stroke_width (ширина обводки),
- stroke_color (цвет обводки),
- color (цвет кружка).
Полный вариант кода будет ниже, а пока опишу основные методы и классы, которые нам пригодятся:
- Scene — класс сцены,
- Scene.add () — метод добавления объектов на сцену,
- Scene.play () — метод проигрывания анимации,
- Scene.wait () — метод ожидания,
- Dot () — класс точки,
- VGroup () — класс для группировки объектов сцены,
- HistogramText — класс текста,
- HistogramText.move_to () — метод перемещения текста на сцене,
- HistogramText.scale () — метод изменения размера текста,
- FadeIn — класс анимации появления объекта в сцене,
- FadeOut — класс анимации исчезновения объекта из сцены.
Создадим в корне проекта файл scenario.py, в котором опишем основные сценарии для разных анимаций. Код в этом файле будет похож на действительный сценарий (обратите внимание на метод play_whole_scenario () — яркий пример такой реализации), а всю остальную логику спрячем в классы-объекты сцены. Начнём с самого простого — приветственной видеозаставки «Гистограммы».
from manimlib.imports import MovingCameraScene, Dot, VGroup, BLACK, FadeIn, FadeOut
from numpy import array
from random import randint
from classes import HistogramText
class Scenario:
def __init__(self, scene: Scene):
"""Main scenario class initialization.
Args:
scene (Scene): Instance of the Scene class.
"""
self.scene = scene
def play_first_scene(self):
# We are creating list for storing dots
dots = []
# Columns with dots will be placed one after another, so X position
# will be calculated automatically
start_x = -4
# Dot size
point_radius = 0.3
for _ in range(5):
# Dots inside columns will be placed one above the other, so Y position
# will be calculated automatically
start_y = -2
for _ in range(randint(2, 6)):
dots.append(
Dot(
point=array([start_x, start_y, 0]), # Coordinates on the screen, assigned with x,y,z
radius=point_radius, # Dot size
stroke_width=1, # Border width
stroke_color=BLACK, # Border color
color="#7fcc81", # Dot color
)
)
start_y += 0.7
start_x += 2
# Grouping dots into VGroup that is the Scene's element
dots = VGroup(*dots)
# Adding dots to the scene
self.scene.add(dots)
# Creating text. We are using our own Overridden class.
heading = HistogramText("Гистограммы", color=BLACK)
# Changin text size with scale
heading.scale(2)
# Changing text location
heading.move_to(array([0, 2.5, 0]))
# Playing animation for the text appearing
self.scene.play(FadeIn(heading))
# Waiting
self.scene.wait(2)
# Playing animation for the text disappearing
self.scene.play(FadeOut(dots), FadeOut(heading))
# Waiting
self.scene.wait(1)
Все файлы готовы. Чтобы запуститься, нам осталось лишь донастроить приложение, вызвав из main.py сценарий.
Класс MainScene отвечает за запуск приложения. Для запуска рендера видео этот класс обязательно должен наследоваться от CameraScene (мы будем наследоваться от MovingCameraScene, так как для дальнейшей разработки понадобятся методы перемещения камеры, которых нет в стандартной CameraScene). В нашем классе объявлена переменная CONFIG, в которую можно добавить настройки сцены. Метод construct — точка входа в приложение. Вот как это выглядит в коде:
import os
from pathlib import Path
# We are importing MovingCamera, instead of CameraScene to be able to
# move the camera around.
from manimlib.imports import MovingCameraScene
from config import SCENE_BACKGROUND_COLOR
from scenario import Scenario
# Adding flags to build animation.
# -l (low quality)
# -s (only screenshot)
RESOLUTION = ""
FLAGS = f"-pl {RESOLUTION}"
SCENE = "MainScene"
class MainScene(MovingCameraScene):
# Scene background is black by default, to change it we need to
# override CONFIG dictionary.
CONFIG = {
"camera_config": {
"background_color": SCENE_BACKGROUND_COLOR,
},
}
def construct(self):
"""Construct method - enter point to create animation"""
hist = Scenario(self)
hist.play_first_scene()
if __name__ == "__main__":
script_name = Path(__file__).resolve()
os.system(f"manim {script_name} {SCENE} {FLAGS}")
Итоговая структура проекта получилась такой:
classes/
__init__.py
histogram_text.py
main.py
scenario.py
pyproject.toml
Приложение готово к первому запуску. Изменим pyproject.toml, добавив директорию packages с нашими классами и соберём проект:
> vi pyproject.toml
[tool.poetry]
...
packages = [
{ include = "classes" }
]
> poetry build
> poetry install
> poetry run python main.py
Первая анимация готова!
Решаем проблему рендеринга русскоязычного текста
Что делать, если вы столкнулись с проблемой рендеринга русского текста? Рассказываю:
> poetry env info
Копируем путь к venv, он будет выглядеть примерно так: ~/Library/Caches/pypoetry/virtualenvs/habr-manim-jyKtqU_G-py3.7
Находим файл tex_template.tex в виртуальном окружении и редактируем его:
> vi ~/Library/Caches/pypoetry/virtualenvs/habr-manim-jyKtqU_G-py3.7/lib/python3.7/site-packages/manimlib/tex_template.tex
Сверяемся, чтобы содержимое было следующего вида:
\documentclass[preview]{standalone}
\usepackage[english]{babel}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{dsfont}
\usepackage{setspace}
\usepackage{tipa}
\usepackage{relsize}
\usepackage{textcomp}
\usepackage{mathrsfs}
\usepackage{calligra}
\usepackage{wasysym}
\usepackage{ragged2e}
\usepackage{physics}
\usepackage{xcolor}
\usepackage{microtype}
\DisableLigatures{encoding = *, family = * }
%\usepackage[UTF8]{ctex}
\linespread{1}
\begin{document}
YourTextHere
\end{document}
Сохраняем файл. Теперь русский текст будет отображаться нормально.
Итого мы сделали каркас для приложения, настроили его к запуску, переопределили стандартный класс текста, чтобы применить собственный шрифт, и создали видео для вступления. Но дальше — больше. Будем строить график, таблицу, воронку и шарики.
Определяем сценарии анимации
Дисклеймер! Противникам ООП после прочтения может стать плохо. Сначала определим сценарий и объекты, необходимые для его реализации:
Сценарий 1
- появление таблицы и шариков,
- появление графика,
- перенос шариков из таблицы на график.
Сценарий 2
- появление таблицы и шариков,
- появление графика,
- перенос шариков из таблицы на график,
- появление корзин (воронок),
- перенос шариков с графика в воронку.
Исходя из этих сценариев, нам понадобятся следующие объекты:
- таблица (должна быть динамической длины),
- график (нужно два вида, дискретный и непрерывный),
- шарики (должны хранить значение),
- воронки (воронам нужно прописать физику падения для шариков).
Создаём шарики
Начнём с самого простого. В директорию classes добавим новый файл — histogram_dot.py, в котором определим класс для шарика. Код ниже под спойлером, но ключевые моменты объясню тут:
- Все классы новых объектов должны наследовать уже существующие классы Manim. В случае шарика мы наследуем класс VGroup, который формирует группу из текста и точки. Так мы можем создавать любые объекты, группируя их в VGroup и работая с ними как с одним объектом-группой.
- После инициализации существующих объектов не забываем проинициализировать родителя (super) с передачей созданных объектов.
from typing import Dict, Union
from colour import Color
from manimlib.imports import BLACK, WHITE, Dot, VGroup
from numpy import ndarray
from .histogram_text import HistogramText
from .shape_point import ShapePoint
class HistogramDot(VGroup):
"""This class contains Dot, Text and all needed info that we want, such as 'value'"""
colors: Dict[int, str] = {
1: "#7FCC81", # green
2: "#FFE236", # yellow
3: "#FFB742", # orange
4: "#FF7555", # red
}
dot_scale_float: Union[int, float] = 0.25
dot_scale_int: Union[int, float] = 0.4
radius: Union[int, float] = 0.2
def __init__(
self,
value: int,
point: ndarray,
radius: float = None,
color: Color = None,
):
"""Class initialization.
Args:
value (int): Text of the dot.
point (array): Location on the screen.
radius (float, optional): Dot radius. Defaults to None.
color (Color, optional): Dot color. Defaults to None.
"""
self.value = value
self.radius = radius or self.radius
if not color:
color = self.colors.get(value, WHITE)
dot = Dot(
point=point,
radius=self.radius,
color=color,
stroke_color=BLACK,
stroke_width=1,
)
text = HistogramText(str(self.value), color=BLACK)
self.point = ShapePoint(point)
# We are changing the text size to be able to add it inside a d
if isinstance(self.value, float):
text.scale(self.dot_scale_float)
else:
text.scale(self.dot_scale_int)
# Moving text inside a dot
text.move_to(dot.get_center())
super().__init__(dot, text)
def __repr__(self):
return f"{self.__class__.__name__}({self.value}, {self.point}, {self.radius}, {self.color})"
Создаём таблицу
Теперь напишем класс для построения таблицы и добавим в него возможность настройки размеров и параметров. В рамках нашей задачи удобство и универсальность важнее, чем single-responsibility principle. Поэтому мы сделаем так, чтобы построение таблицы вызывалось одним методом и работало максимально просто. Сама таблица в этом случае тоже является объектом VGroup и состоит из кучи линий, текста и шариков.
Чтобы не запутаться, разделим таблицу на обычную (класс Table, состоящий из линий) и таблицу покупателей (она наследует Table и дополняет линии текстом и шариками). В процессе построения на вход подаются кортежи с координатами, их валидацию мы выносим в отдельный класс-helper ShapePoint. Он будет валидировать и обозначать координаты точек на плоскости.
from typing import Tuple, Union
from numpy import array, ndarray
class ShapePointException(Exception):
pass
class ShapePointTypeError(ShapePointException):
pass
class ShapePointTooManyValuesException(ShapePointException):
pass
class ShapePoint:
"""Class for validation and storing information about screen points"""
_coords: ndarray
def __init__(self, coords: Tuple[Union[int, float], Union[int, float]]):
self.coords = coords
@property
def coords(self):
return self._coords
@coords.setter
def coords(self, value: Tuple[Union[int, float], Union[int, float]]):
"""Method -setter for validation and storing coordinates.
Args:
value (Tuple[Union[int, float], Union[int, float]]): tuple with coordinates
Raises:
ShapePointTypeError: Wrong data type was passed.
ShapePointTooManyValuesException: You put too many variables inside tuple.
ShapePointTypeError: Data inside tuple is in the wrong format.
"""
if not isinstance(value, Tuple) and not isinstance(value, ndarray):
detail = f"Coords must be a type of: [tuple, np.ndarray], got [{type(value)}] instead."
raise ShapePointTypeError(detail)
if isinstance(value, ndarray):
value = value[:2]
if len(value) != 2:
detail = "Coords must contain 2 values"
raise ShapePointTooManyValuesException(detail)
for coord in value:
if not isinstance(coord, int) and not isinstance(coord, float):
detail = f"Values in coords must be a type of: [int, float], got [{coord}:{type(coord)}] instead."
raise ShapePointTypeError(detail)
self._coords = array([value[0], value[1], 0])
def __getitem__(self, item):
return self.coords[item]
def __repr__(self):
return f"{self.__class__.__name__}({self.coords})"
Алгоритм построения базовой таблицы (класс Table) простой: задаём координаты первой линии, количество строк, высоту строк, количество колонок, ширину колонок и количество строк, которое будет видно. Отрисовка каждого элемента на экране занимает время и память, соответственно, в таблице, где видно всего 10 строк из 100, нет смысла рисовать ещё 90. Также задаём цвет и ширину границы. Затем для каждой строки добавляем горизонтальную линию и столько вертикальных линий, сколько у нас колонок. В итоге получаем набор VGroup из линий, который и является таблицей.
Построить таблицу для покупателей (CustomerTable) тоже просто: наследуемся от базового класса и добавляем на каждую строчку текст и шарик. Чтобы числа всегда получались одинаковыми, и не было необходимости вручную вводить кучу значений, в класс CustomerTable добавлен параметр start_dot_values, в который можно добавить именно те значения, о которых говорится в описании таблицы. Все остальные значения генерируются с помощью random.seed () (это псевдорандом, то есть Python генерирует одни и те же числа каждый раз, когда запускаешь проект). Таким образом, запустив скрипт 500 раз, мы получим 500 одинаковых «рандомных» значений (люблю питончик).
Файл таблицы выглядит следующим образом:
import random
from typing import Tuple, Union
from colour import Color
from manimlib.imports import BLACK, LEFT_SIDE, Line, VGroup
from numpy import array
from .histogram_dot import HistogramDot
from .histogram_text import HistogramText
from .shape_point import ShapePoint
class TableException(Exception):
pass
class TableLineEmptyException(TableException):
pass
class Table(VGroup):
"""Table class. Built from Lines"""
def __init__(
self,
start_end_points: Tuple[tuple, tuple],
row_count: int = 0,
row_height: Union[int, float] = 0.2,
column_count: int = 0,
visible_row_count: int = 0,
columns_width: tuple = None,
lines_color: Color = BLACK,
stroke_width: Union[int, float] = 1,
*args,
**kwargs,
):
"""Class initialization.
Args:
start_end_points (Tuple[tuple, tuple]): Left top and right top points. ((x1,y1), (x2,y2)).
row_count (int, optional): Table row count. Defaults to 0.
row_height (Union[int, float], optional): Table row height. Defaults to 0.2.
column_count (int, optional): Table column count. Defaults to 0.
visible_row_count (int, optional): Table visible row count. Defaults to 0.
columns_width (tuple, optional): Table column width. For 3 columns it looks like
(.4, .4, .2). Defaults to None.
lines_color (Color, optional): Table lines color. Defaults to BLACK.
stroke_width (Union[int, float], optional): Table lines width. Defaults to 1.
Raises:
TableLineEmptyException: Raises when no start_end_points were passed.
"""
if not start_end_points:
detail = "Can't create a graph with the empty start line."
raise TableLineEmptyException(detail)
if columns_width:
assert (
len(columns_width) == column_count
), "Columns count and list with their widths must be the same length."
self.horizontal_line = [
ShapePoint(start_end_points[0]),
ShapePoint(start_end_points[1]),
]
self.row_count = row_count
self.row_height = row_height
self.column_count = column_count
self.visible_row_count = visible_row_count
self.columns_width = columns_width
self.lines_color = lines_color
self.stroke_width = stroke_width
self.lines = self._create_table()
super().__init__(*self.lines, *args, **kwargs)
def _create_table(self) -> VGroup:
"""Method for creating tables.
Returns:
VGroup: Object made from the list of lines (Line).
"""
lines = []
y_point = self.horizontal_line[0][1]
y_step = self.row_height
x_left_point = self.horizontal_line[0][0]
x_right_point = self.horizontal_line[1][0]
distance = abs(x_right_point - x_left_point)
# Drawing table
for i in range(self.row_count + 1):
# Adding horizontal line
lines.append(
Line(
array([x_left_point, y_point, 0]),
array([x_right_point, y_point, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
)
if i == self.row_count:
break
# Adding vertical lines
x_point = x_left_point
for j in range(self.column_count + 1):
lines.append(
Line(
array([x_point, y_point, 0]),
array([x_point, y_point - y_step, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
)
if j == self.column_count:
break
if self.columns_width:
temp_step = self.columns_width[j]
assert isinstance(temp_step, float), "Column width must be a float value"
assert 0 < temp_step <= 1, "Column width must be in range [0 < column_width <= 1]"
x_point = x_point + (distance * temp_step)
else:
x_point = x_point + (distance / self.column_count)
y_point -= y_step
lines = VGroup(*lines)
return lines
class CustomersTable(Table):
"""Overridden Table class. Custom text and dots were added."""
def __init__(
self,
start_end_points: Tuple[tuple, tuple],
row_count: int = 0,
row_height: Union[int, float] = 0.5,
visible_row_count: int = 0,
colors: list = None,
bins: Union[int, float] = 0,
text: str = "",
start_dots_values: list = None,
):
"""Class initialization.
Args:
start_end_points (Tuple[tuple, tuple]): Left top and right top points. ((x1,y1), (x2,y2)).
row_count (int, optional): Table row count. Defaults to 0.
row_height (Union[int, float], optional): Table row height. Defaults to 0.2.
visible_row_count (int, optional): Table visible row count. Defaults to 0.
colors (list, optional): List with dot colors. Defaults to None.
bins (Union[int, float], optional): Count of possible dots values.
Defaults to 0.
text (str, optional): Text for adding to the table. Ex "Customer"
Defaults to "".
start_dots_values (list, optional): List with initial values for the dots.
Defaults to None.
"""
self.horizontal_line = [
ShapePoint(start_end_points[0]),
ShapePoint(start_end_points[1]),
]
self.colors = colors or list()
self.bins = bins
column_count = 2
columns_width = (0.8, 0.2)
self.text = text
self.text_scale = 0.6
self.start_dots_values = start_dots_values
self.default_color = "red"
self.customers, self.dots = self._add_dots_and_customers_to_table(
row_count=row_count,
row_height=row_height,
columns_width=columns_width,
)
super().__init__(
start_end_points,
row_count,
row_height,
column_count,
visible_row_count,
columns_width,
BLACK,
1,
*self.customers,
*self.dots,
)
def _add_dots_and_customers_to_table(
self,
row_height: Union[int, float],
row_count: int,
columns_width: Tuple,
) -> Tuple[VGroup, VGroup]:
"""Method for creating dots and texts.
Args:
row_height (Union[int, float]): Table rows height.
row_count (int): Table rows count.
columns_width (Tuple): Table rows width.
Returns:
Tuple[VGroup, VGroup]: Tuple with all dots and texts.
"""
customers = []
dots = []
y_point = self.horizontal_line[0][1]
y_step = row_height
x_left_point = self.horizontal_line[0][0]
x_right_point = self.horizontal_line[1][0]
distance = abs(x_right_point - x_left_point)
step_x = distance * columns_width[0]
# Adding texts and dots
for i in range(row_count):
# Adding text to the table
customer = HistogramText(
f"{self.text} {i+1}",
color=BLACK,
)
# Changing text size
customer.scale(self.text_scale)
# Moving text to the table cell
customer.move_to(
array([x_left_point + 0.2, y_point - (y_step / 2), 0]),
aligned_edge=LEFT_SIDE, # Aligning it at the left side of the cell
)
customers.append(customer)
# Forcing to generate always the same numbers
random.seed(i + 1)
# Adding dot value
if self.start_dots_values and i < len(self.start_dots_values):
dot_value = self.start_dots_values[i]
else:
if isinstance(self.bins, int):
dot_value = random.randrange(1, self.bins + 1)
else:
dot_value = round(random.uniform(1.0, self.bins + 1.0), 1)
if len(self.colors) < dot_value:
dot_color = self.default_color
else:
dot_color = self.colors[int(dot_value) - 1]
# Adding dot
dot = HistogramDot(
value=dot_value,
point=array([x_left_point + step_x + 0.3, y_point - (y_step / 2), 0]),
color=dot_color,
)
dots.append(dot)
y_point -= y_step
customers = VGroup(*customers)
dots = VGroup(*dots)
return customers, dots
Выносим всю кастомизацию в классы — получаем читабельный код
Вы спросите — зачем же столько кода, ужасное ООП, куча наследования и так далее? Всё просто: чтобы спрятать ненужную шелуху в классы. Тогда код будет выглядеть максимально читабельным и красивым. Давайте посмотрим, что можно сделать с уже написанным.
Создадим новый метод play_second_scene в классе Scenario. Добавим таблицу: укажем координаты, общее количество строк, количество строк, которое будет видно на экране, и количество корзин в таблице. Добавим метод scene.play, в котором вызовем метод появления таблицы. В файле main.py в методе construct заменим hist.play_first_scene () на hist.play_second_scene ().
После этого запускаем рендер видео из консоли с помощью команды
> poetry run python main.py
… и наслаждаемся видом нашей таблицы покупателей:
def play_second_scene(self):
table = CustomersTable(
((-2, 2), (2, 2)),
row_count=10,
visible_row_count=10,
bins=2,
)
self.scene.play(FadeIn(table))
self.scene.wait(3)
Теперь у нас осталась всего одна инициализация таблицы и один вызов метода появления. Код чистый, красивый, с ним легко следить за сценарием видео, его легко поддерживать. Вся кастомизация вынесена в классы, и мы о ней вообще не задумываемся.
Пойдём дальше — таким же способом создадим классы для графика и воронки.
Строим график
Следующая задача — построение графика. Опишу его параметры:
- График может быть двух видов: дискретным и непрерывным.
- Графику нужно добавить возможность запоминать все координаты, на которые перемещаются шарики. Мы будем перемещать шарики из таблицы на график, и когда у нас появится два шарика с числом 3, они должны будут встать один над другим, выстроившись в столбик. То есть, чтобы расположить шарик на плоскости, нужно взять координаты предыдущего шарика, добавить к координате Y диаметр шарика и расположить новый шарик на новых координатах.
Небольшое пояснение к коду: во-первых, мы снова используем наследование. То есть создаём класс Graph и делаем его абстрактным. В этот класс добавляем общий для всех потомков метод _prepare_next_dot_coords и абстрактный метод _create_graph. От класса Graph наследуются два других класса: CategoricalGraph и ContinuousGraph. Во-вторых, благодаря питоническому Multiple Inheritance в классы-наследники добавляется VGroup, и на выходе получается новый объект, которым можно манипулировать (добавлять в сцену, удалять, перемещать и т. д.).
from abc import ABC, abstractmethod
from typing import Tuple
from manimlib.imports import BLACK, Line, VGroup
from numpy import array
from .histogram_text import HistogramText
from .shape_point import ShapePoint
class GraphException(Exception):
pass
class GraphLinesEmptyException(GraphException):
pass
class Graph(ABC):
"""Class for drawing Graphs"""
bins: int
color: str
annot: bool
text_scale: float = 0.6
dot_padding: float = 0.25
stroke_width: float
def __init__(
self,
start_end_points: Tuple[tuple, tuple] = None,
vertical_line: Tuple[tuple, tuple] = None,
bins: int = 1,
annot: bool = False,
color=BLACK,
stroke_width=1,
):
"""Graph initialization
Args:
start_end_points (Tuple[tuple, tuple]): Left top and right top points. ((x1,y1), (x2,y2)).
Defaults to None.
vertical_line (Tuple[tuple, tuple], optional): Vertical line coordinates
((x1, y1), (x2, y2)). Defaults to None.
bins (int, optional): Bins count for the graph. Defaults to 1.
annot (bool, optional): Do we need to annotate bins or not. Defaults to False.
color ([type], optional): Graph lines color. Defaults to BLACK.
stroke_width (int, optional): Graph lines width. Defaults to 1.
Raises:
GraphLinesEmptyException: Raises when start_end_points and vertical_line weren't passed.
"""
if not start_end_points and not vertical_line:
detail = "Can't create a graph with empty lines."
raise GraphLinesEmptyException(detail)
# Initialise graph lines
self.horizontal_line = None
if start_end_points:
self.horizontal_line = [
ShapePoint(start_end_points[0]),
ShapePoint(start_end_points[1]),
]
self.step_x = abs(start_end_points[0][0] - start_end_points[1][0]) / bins
self.vertical_line = None
if vertical_line:
self.vertical_line = [
ShapePoint(vertical_line[0]),
ShapePoint(vertical_line[1]),
]
self.step_y = abs(vertical_line[0][1] - vertical_line[1][1]) / bins
self.bins = bins
self.color = color
self.annot = annot
self.stroke_width = stroke_width
lines, texts = self.create_graph()
super().__init__(*lines, *texts)
def _prepare_next_dot_coords(self) -> dict:
"""Dict preparation with information about the bin center.
Returns:
dict: Dictionary with filled bin center coordinates.
"""
d = {}
start_x = self.horizontal_line[0][0]
for i in range(1, int(self.bins) + 1):
d[i] = {
"x": start_x + (self.step_x / 2),
"y": self.horizontal_line[0][1] + 0.25,
}
start_x += self.step_x
return d
@abstractmethod
def create_graph(self) -> Tuple[list, list]:
pass
class CategoricalGraph(Graph, VGroup):
"""Categorical Graph. Inherited from Graph"""
def create_graph(self) -> Tuple[list, list]:
"""Implementation of create_graph method.
Returns:
Tuple[list, list]: Tuple of the list with lines and texts.
"""
lines = []
texts = []
if self.horizontal_line:
# Adding horizontal line
line = Line(
self.horizontal_line[0].coords,
self.horizontal_line[1].coords,
color=self.color,
stroke_width=self.stroke_width,
)
lines.append(line)
# Adding vertical lines
start_x = self.horizontal_line[0].coords[0]
y_coord = self.horizontal_line[0].coords[1]
for i in range(1, self.bins + 2):
lines.append(
Line(
array([start_x, y_coord + 0.3, 0]),
array([start_x, y_coord - 0.3, 0]),
color=self.color,
stroke_width=self.stroke_width,
)
)
# Adding text for the bins
if self.annot and (i != self.bins + 1):
text = HistogramText(str(i), color=BLACK)
text.scale(self.text_scale)
text.move_to(array([start_x + (self.step_x / 2), y_coord - 0.3, 0]))
texts.append(text)
start_x += self.step_x
if self.vertical_line:
# Adding vertical line
line = Line(
self.vertical_line[0].coords,
self.vertical_line[1].coords,
color=self.color,
stroke_width=self.stroke_width,
)
lines.append(line)
# Adding horizontal line
start_y = self.vertical_line[0].coords[1]
x_coord = self.vertical_line[0].coords[0]
for i in range(1, self.bins + 2):
lines.append(
Line(
array([x_coord - 0.3, start_y, 0]),
array([x_coord + 0.3, start_y, 0]),
color=self.color,
stroke_width=self.stroke_width,
)
)
if self.annot and (i != self.bins + 1):
text = HistogramText(str(i), color=BLACK)
text.scale(self.text_scale)
text.move_to(array([x_coord - 0.3, start_y - (self.step_y / 2), 0]))
texts.append(text)
start_y -= self.step_y
return texts, lines
class ContinuousGraph(Graph, VGroup):
def create_graph(self) -> Tuple[list, list]:
"""Implementation of create_graph method.
Returns:
Tuple[list, list]: Tuple of the list with lines and texts.
"""
lines = []
texts = []
if self.horizontal_line:
# Adding horizontal line
lines.append(
Line(
self.horizontal_line[0].coords,
self.horizontal_line[1].coords,
color=self.color,
stroke_width=self.stroke_width,
)
)
y_coord = self.horizontal_line[0].coords[1]
# Adding 2 vertical lines
lines.extend(
[
Line(
array([self.horizontal_line[0].coords[0], y_coord + 0.3, 0]),
array([self.horizontal_line[0].coords[0], y_coord - 0.3, 0]),
color=self.color,
stroke_width=self.stroke_width,
),
Line(
array([self.horizontal_line[1].coords[0], y_coord + 0.3, 0]),
array([self.horizontal_line[1].coords[0], y_coord - 0.3, 0]),
color=self.color,
stroke_width=self.stroke_width,
),
]
)
if self.annot:
text0 = HistogramText(str(0), color=BLACK)
text0.scale(self.text_scale)
text0.move_to(array([self.horizontal_line[0].coords[0], y_coord - 0.55, 0]))
text1 = HistogramText(str(self.bins), color=BLACK)
text1.scale(self.text_scale)
text1.move_to(array([self.horizontal_line[1].coords[0], y_coord - 0.55, 0]))
texts.extend([text0, text1])
if self.vertical_line:
# Adding vertical line
lines.append(
Line(
self.vertical_line[0].coords,
self.vertical_line[1].coords,
color=self.color,
stroke_width=self.stroke_width,
)
)
x_coord = self.vertical_line[0].coords[0]
# Adding 2 horizontal lines
lines.extend(
[
Line(
array([x_coord - 0.3, self.vertical_line[0].coords[1], 0]),
array([x_coord + 0.3, self.vertical_line[0].coords[1], 0]),
color=self.color,
stroke_width=self.stroke_width,
),
Line(
array([x_coord - 0.3, self.vertical_line[1].coords[1], 0]),
array([x_coord + 0.3, self.vertical_line[1].coords[1], 0]),
color=self.color,
stroke_width=self.stroke_width,
),
]
)
if self.annot:
text0 = HistogramText(str(0), color=BLACK)
text0.scale(self.text_scale)
text0.move_to(array([x_coord - 0.55, self.vertical_line[0].coords[1], 0]))
text1 = HistogramText(str(self.bins), color=BLACK)
text1.scale(self.text_scale)
text1.move_to(array([x_coord - 0.55, self.vertical_line[1].coords[1], 0]))
texts.extend([text0, text1])
return lines, texts
Теперь соберём всё в метод play_third_scene и запустим сборку, как в предыдущем варианте:
def play_third_scene(self):
cont_graph = ContinuousGraph(
((-4, -1), (0, -1)),
((-2, 1), (-2, -3)),
bins=4,
annot=False,
)
cat_graph = CategoricalGraph(
((0, 2), (4, 2)),
None,
bins=4,
annot=True,
)
self.scene.play(FadeIn(cont_graph), FadeIn(cat_graph))
self.scene.wait(3)
В итоге мы получили два графика: непрерывный и дискретный. У каждого своя логика отрисовки корзин и подписи:
Рисуем воронки
Осталось нарисовать только воронки. Как и объекты, которые мы создавали ранее, воронки представляют собой набор линий с определёнными координатами. Однако перед тем, как перейти к их созданию, вспомним изначальное видео и второй сценарий. Шарики должны падать именно в свою воронку, а не в какую-то другую, плюс не просто переноситься, а именно скатываться по горлышку воронки.
Также нам нужно, чтобы воронка была самостоятельным объектом. Это даст возможность строить столько воронок, сколько нужно. Мы напишем два класса: один для воронки, а второй для управления построением воронок. Звучит сложно, но здесь и начинается всё самое интересное!
from typing import Tuple, Union
from colour import Color
from manimlib.imports import BLACK, Line, VGroup
from numpy import array, mean
from .histogram_text import HistogramText
from .shape_point import ShapePoint
class Funnel(VGroup):
text_scale: Union[int, float] = 0.6
def __init__(
self,
start_end_points: Tuple[tuple, tuple],
height: Union[int, float],
point_radius: Union[int, float],
annot: bool = False,
annot_text: str = "",
lines_color: Color = BLACK,
stroke_width: Union[int, float] = 1,
):
"""Funnel initialization.
Args:
start_end_points (Tuple[tuple, tuple]): Left top and right top points. ((x1,y1), (x2,y2)).
height (Union[int, float]): Funnel height.
point_radius (Union[int, float]): Point radius. With that value we could calculate the opening
of the funnel.
annot (bool, optional): Do we need to annotate the funnel or not. Defaults to False.
annot_text (str, optional): Text to annotate funnel. Defaults to "".
lines_color (Color, optional): Lines color for the funnel. Defaults to BLACK.
stroke_width (Union[int, float], optional): Line width for the funnel. Defaults to 1.
"""
self.left_top_point = ShapePoint(start_end_points[0])
self.right_top_point = ShapePoint(start_end_points[1])
self.height = height
self.point_radius = point_radius
self.point_diameter = point_radius * 1.5
self.lines_color = lines_color
self.stroke_width = stroke_width
self.annot = annot
self.annot_text = annot_text
self.y_point_top = self.left_top_point[1]
self.y_point_bottom = self.y_point_top - self.height
self.x_point_left = self.left_top_point[0]
self.x_point_right = self.right_top_point[0]
self.y_bottom_shift = 0.2
self.x_funnel_center = mean(array([self.right_top_point[0], self.left_top_point[0]]))
self.left_to_bottom = Line(
array([self.x_point_left, self.y_point_top, 0]),
array([self.x_point_left, self.y_point_bottom, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.left_to_bottom_right = Line(
array([self.x_point_left, self.y_point_top, 0]),
array([self.x_funnel_center - self.point_diameter, self.y_point_top - 0.5, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.right_to_bottom = Line(
array([self.x_point_right, self.y_point_top, 0]),
array([self.x_point_right, self.y_point_bottom, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.right_to_bottom_left = Line(
array([self.x_point_right, self.y_point_top, 0]),
array([self.x_funnel_center + self.point_diameter, self.y_point_top - 0.5, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.left_funnel_appendix = Line(
array([self.x_funnel_center + self.point_diameter, self.y_point_top - 0.5, 0]),
array([self.x_funnel_center + self.point_diameter, self.y_point_top - 0.7, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.right_funnel_appendix = Line(
array([self.x_funnel_center - self.point_diameter, self.y_point_top - 0.5, 0]),
array([self.x_funnel_center - self.point_diameter, self.y_point_top - 0.7, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
self.bottom_line = Line(
array([self.x_point_left - 0.2, self.y_point_bottom + self.y_bottom_shift, 0]),
array([self.x_point_right + 0.2, self.y_point_bottom + self.y_bottom_shift, 0]),
color=self.lines_color,
stroke_width=self.stroke_width,
)
texts = []
if annot:
text = HistogramText(annot_text, color=self.lines_color)
text.move_to(array([self.x_funnel_center, self.y_point_bottom, 0]))
text.scale(self.text_scale)
texts.append(text)
super().__init__(
self.left_to_bottom,
self.right_to_bottom,
self.left_to_bottom_right,
self.right_to_bottom_left,
self.left_funnel_appendix,
self.right_funnel_appendix,
self.bottom_line,
*texts,
)
Создадим четвёртую сцену, определив необходимые параметры для создания воронок, и запустим сборку:
def play_fourth_scene(self):
funnel = Funnel(
((-2, 2), (1, -2)),
height=4,
point_radius=0.2,
)
self.scene.play(
FadeIn(funnel),
)
self.scene.wait(3)
Теперь у нас появились красивые воронки, с которыми можно работать дальше:
Анимируем объекты
Итак, у нас есть воронки, графики, таблицы, шарики… Но мы пока что не сделали одну важную вещь — сейчас эти объекты статические. То есть мы их определили, отобразили и на этом всё. Нам же нужно, чтобы шарики могли взаимодействовать с остальными объектами, перемещаясь по ним.
По сценарию у нас есть таблица со списком покупателей, где напротив каждого покупателя нарисован шарик с количеством покупок (значение шарика). Для отображения количества покупок в виде гистограммы нужно создать график, вывести его на экран и затем перенести на него шарики так, чтобы каждому значению шарика соответствовало такое же значение на графике.
Для реализации этой логики создадим (ни за что не догадаетесь) класс! Назовем его Movable. Класс будет иметь собственный словарь _next_dot_coords, в котором лежат координаты всех точек, уже перенесённых на график, а также метод _get_next_dot_coords, который по значению шарика будет отдавать его координаты на графике и, наконец, метод drag_in_dots, отвечающий за «притяжение» к себе всех шариков.
Пояснение: есть таблица с шариками и график. Вызываем метод графика drag_in_dots, в который передаём шарики, находящиеся в данный момент в таблице. В цикле проходимся по шарикам, где для каждого шарика вызываем метод графика _get_next_dot_coords, который отдаёт новые координаты шарика, и перемещаем шарики на новые координаты. Готово.
И всё-таки совсем понятно станет после реализации. Давайте ей и займёмся:
from abc import ABC
from copy import deepcopy
from typing import Dict, Union
from manimlib.imports import DEFAULT_ANIMATION_RUN_TIME, ApplyMethod, Scene, Transform, VGroup
from numpy import array, ndarray
from .graph import CategoricalGraph, ContinuousGraph
from .histogram_dot import HistogramDot
class Movable(ABC):
"""Abstract class to add 'movable' functionality to the graph"""
_next_dot_coords: Dict[Union[int, float], Dict[str, Union[int, float]]] = {}
dot_padding: Union[int, float] = 0
def __init__(self, *args, **kwargs):
self._next_dots_coords = self._prepare_next_dot_coords()
super().__init__(*args, **kwargs)
def _get_next_dot_coords(self, dot: HistogramDot) -> ndarray:
"""Getting points for dots to move.
Args:
dot (HistogramDot): Dot from which we will calculate current coordinates.
Returns:
array: Next dot location.
"""
current_coord = self._next_dots_coords.get(int(dot.value), {})
bin_center = array([current_coord.get("x", 0), current_coord.get("y", 0), 0])
self._next_dots_coords[int(dot.value)]["y"] = current_coord.get("y", 0) + dot.radius + self.dot_padding
return bin_center
def drag_in_dots(
self,
scene: Scene,
dots: VGroup,
animate_slow: int,
animate_rest: bool,
run_time: Union[int, float] = None,
delay: Union[int, float] = None,
):
"""Moving dots to the graph.
Args:
scene (Scene): Scene where all our objects are located.
dots (VGroup): List of dots to move.
animate_slow (int): How many dots do we need to animate slowly.
animate_rest (bool): Do we need to move the rest of the dots or not.
run_time (Union[int, float]): How quickly we need to animate dots. Defaults to None.
delay (Union[int, float], optional): Delay between animations. Defaults to None.
"""
if not run_time:
run_time = DEFAULT_ANIMATION_RUN_TIME
for dot in dots[:animate_slow]:
scene.play(
ApplyMethod(dot.move_to, self._get_next_dot_coords(dot)),
run_time=run_time,
)
if delay:
scene.wait(delay)
if animate_rest:
dots_rest = deepcopy(dots[animate_slow:])
for dot in dots_rest:
dot.move_to(self._get_next_dot_coords(dot))
scene.play(Transform(dots[animate_slow:], dots_rest))
scene.remove(dots[animate_slow:])
else:
for dot in dots[animate_slow:]:
dot.move_to(self._get_next_dot_coords(dot))
class MovableContinuousGraph(ContinuousGraph, Movable):
"""Continuous graph that could move dots"""
class MovableCategoricalGraph(CategoricalGraph, Movable):
"""Categorical graph that could move dots"""
Теперь создадим видео по первому сценарию: выведем на экран таблицу с шариками, затем график и переместим на него шарики из таблицы.
def play_fifth_scene(self):
# Initial dot values, to keep them the same over several animation builds
start_dot_values = [1, 2, 1, 3, 4, 2, 1]
# Table initialization
table = CustomersTable(
((-6, 2), (-2, 2)),
row_count=10,
visible_row_count=10,
bins=4,
start_dots_values=start_dot_values,
)
# Graph initialization
x_graph = MovableCategoricalGraph(
((0, 0), (4, 0)),
None,
bins=4,
annot=True,
)
# Playing animation for the table and graph appearing
self.scene.play(FadeIn(table), FadeIn(x_graph))
self.scene.wait(2)
# Moving dots from the table to the graph
x_graph.drag_in_dots(self.scene, dots=table.dots, animate_slow=3, animate_rest=True)
self.scene.wait(3)
После сборки получим такую прекрасную анимацию:
Всё здорово, остался последний элемент — воронки тоже нужно сделать динамическими. Movable-воронкам для перемещения шариков не подойдёт простой drag_in, потому что метод должен определять, к какой воронке относится шарик, а после отрисовывать, как шарик скатывается по ней внутрь. Звучит интересно, давайте попробуем реализовать.
Во время создания воронок класс Funnel запоминает все линии с координатами. То есть мы легко можем сравнить их с координатами шарика, который должен упасть в воронку.
Весь алгоритм состоит из следующих пунктов:
- Вызвать метод drag_in_dots, который запустит цикл обработки шариков.
- Для каждого шарика вызвать метод _get_next_dot_coords.
- В методе _get_next_dot_coords для каждого шарика нужно:
- получить координаты текущего шарика;
- получить координаты левой и правой «крыши»;
- получить координаты центра воронки.
После нахождения координат рассчиты