├── .github
├── FUNDING.yml
└── workflows
│ └── python-publish.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── examples
├── arrival.py
├── data
│ └── seinfeld.csv
├── heart.py
├── hop.py
├── phone.py
├── seinfeld.py
└── spiral.py
├── gif.py
├── images
├── arrival.gif
├── beating_heart.gif
├── heart.gif
├── hop.gif
├── logo.png
├── phone.gif
├── seinfeld.gif
└── spiral.gif
├── mypy.ini
├── setup.py
└── tests
└── test_gif.py
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: maxhumber
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 |
--------------------------------------------------------------------------------
/.github/workflows/python-publish.yml:
--------------------------------------------------------------------------------
1 | name: Upload to PyPI
2 | on:
3 | release:
4 | types: [published]
5 | permissions:
6 | contents: read
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | - name: Set up Python
13 | uses: actions/setup-python@v3
14 | with:
15 | python-version: '3.9'
16 | - name: Install test dependencies
17 | run: |
18 | python -m pip install --upgrade pip
19 | pip install -e ".[test]"
20 | - name: Run tests
21 | run: pytest
22 | - name: Install build dependencies
23 | run: pip install build
24 | - name: Build package
25 | run: python -m build
26 | - name: Publish package
27 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
28 | with:
29 | user: __token__
30 | password: ${{ secrets.PYPI_API_TOKEN }}
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Custom
2 | paper/
3 | playground.py
4 | test.gif
5 | test-*.gif
6 | geckodriver.log
7 | example.gif
8 |
9 | # Byte-compiled / optimized / DLL files
10 | __pycache__/
11 | *.py[cod]
12 | *$py.class
13 |
14 | # C extensions
15 | *.so
16 |
17 | # Distribution / packaging
18 | .Python
19 | build/
20 | develop-eggs/
21 | dist/
22 | downloads/
23 | eggs/
24 | .eggs/
25 | lib/
26 | lib64/
27 | parts/
28 | sdist/
29 | var/
30 | wheels/
31 | pip-wheel-metadata/
32 | share/python-wheels/
33 | *.egg-info/
34 | .installed.cfg
35 | *.egg
36 | MANIFEST
37 |
38 | # PyInstaller
39 | # Usually these files are written by a python script from a template
40 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
41 | *.manifest
42 | *.spec
43 |
44 | # Installer logs
45 | pip-log.txt
46 | pip-delete-this-directory.txt
47 |
48 | # Unit test / coverage reports
49 | htmlcov/
50 | .tox/
51 | .nox/
52 | .coverage
53 | .coverage.*
54 | .cache
55 | nosetests.xml
56 | coverage.xml
57 | *.cover
58 | *.py,cover
59 | .hypothesis/
60 | .pytest_cache/
61 |
62 | # Translations
63 | *.mo
64 | *.pot
65 |
66 | # Django stuff:
67 | *.log
68 | local_settings.py
69 | db.sqlite3
70 | db.sqlite3-journal
71 |
72 | # Flask stuff:
73 | instance/
74 | .webassets-cache
75 |
76 | # Scrapy stuff:
77 | .scrapy
78 |
79 | # Sphinx documentation
80 | docs/_build/
81 |
82 | # PyBuilder
83 | target/
84 |
85 | # Jupyter Notebook
86 | .ipynb_checkpoints
87 |
88 | # IPython
89 | profile_default/
90 | ipython_config.py
91 |
92 | # pyenv
93 | .python-version
94 |
95 | # pipenv
96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
99 | # install all needed dependencies.
100 | #Pipfile.lock
101 |
102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
103 | __pypackages__/
104 |
105 | # Celery stuff
106 | celerybeat-schedule
107 | celerybeat.pid
108 |
109 | # SageMath parsed files
110 | *.sage.py
111 |
112 | # Environments
113 | .env
114 | .venv
115 | env/
116 | venv/
117 | ENV/
118 | env.bak/
119 | venv.bak/
120 |
121 | # Spyder project settings
122 | .spyderproject
123 | .spyproject
124 |
125 | # Rope project settings
126 | .ropeproject
127 |
128 | # mkdocs documentation
129 | /site
130 |
131 | # mypy
132 | .mypy_cache/
133 | .dmypy.json
134 | dmypy.json
135 |
136 | # Pyre type checker
137 | .pyre/
138 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Max Humber
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test:
2 | pytest tests
3 |
4 | format:
5 | isort .
6 | black .
7 |
8 | types:
9 | mypy gif.py
10 | pyright gif.py
11 |
12 | loc:
13 | find . -name 'gif.py' | xargs wc -l | sort -nr
14 | find tests -name '*.py' | xargs wc -l | sort -nr
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
9 |
10 | ### About
11 |
12 | The [matplotlib](https://matplotlib.org/) Animation Extension
13 |
14 | ### Install & Import
15 |
16 | ```sh
17 | pip install gif
18 | ```
19 |
20 | ```python
21 | import gif
22 | ```
23 |
24 | ### Quickstart
25 |
26 | ```python
27 | import gif
28 | from random import randint
29 | from matplotlib import pyplot as plt
30 |
31 | x = [randint(0, 100) for _ in range(100)]
32 | y = [randint(0, 100) for _ in range(100)]
33 |
34 | # (Optional) Set the dots per inch resolution to 300
35 | gif.options.matplotlib["dpi"] = 300
36 |
37 | # Decorate a plot function with @gif.frame
38 | @gif.frame
39 | def plot(i):
40 | xi = x[i*10:(i+1)*10]
41 | yi = y[i*10:(i+1)*10]
42 | plt.scatter(xi, yi)
43 | plt.xlim((0, 100))
44 | plt.ylim((0, 100))
45 |
46 | # Construct "frames"
47 | frames = [plot(i) for i in range(10)]
48 |
49 | # Save "frames" to gif with a specified duration (milliseconds) between each frame
50 | gif.save(frames, 'example.gif', duration=50)
51 | ```
52 |
53 |
54 | ### Examples
55 |
56 | | [](examples/arrival.py) | [](examples/hop.py) | [](examples/phone.py) |
57 | | ------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
58 | | [](examples/seinfeld.py) | [](examples/spiral.py) | [](heart.py) |
59 |
60 |
61 | ### Warning
62 |
63 | Altair and Plotly are no longer supported in `22.5.0`+
64 |
65 | Please use `pip install gif==3.0.0` if you still need to interface with these libraries
66 |
--------------------------------------------------------------------------------
/examples/arrival.py:
--------------------------------------------------------------------------------
1 | import random
2 | from collections import Counter
3 |
4 | from matplotlib import pyplot as plt
5 | from PIL import Image
6 |
7 | import gif
8 |
9 | random.seed(2020)
10 |
11 |
12 | @gif.frame
13 | def plot_arrival(count, count_last):
14 | plt.figure(figsize=(5, 3), dpi=100)
15 | plt.bar(count.keys(), count.values())
16 | plt.bar(count_last.keys(), count_last.values())
17 | plt.xlim(-1, 11)
18 | plt.xticks(range(0, 10 + 1))
19 | plt.ylim(0, 100)
20 |
21 |
22 | def simulate_arrival(count, p=0.10):
23 | if random.uniform(0, 1) <= p:
24 | group = len(count)
25 | else:
26 | k = list(count.keys())
27 | v = list(count.values())
28 | group = random.choices(k, weights=v)[0]
29 | return group
30 |
31 |
32 | count = Counter({0})
33 | count_last = count.copy()
34 | frames = []
35 | for _ in range(100):
36 | group = simulate_arrival(count)
37 | count.update({group})
38 | frame = plot_arrival(count, count_last)
39 | frames.append(frame)
40 | count_last = count.copy()
41 |
42 | gif.save(frames, "images/arrival.gif")
43 |
--------------------------------------------------------------------------------
/examples/heart.py:
--------------------------------------------------------------------------------
1 | import matplotlib.pyplot as plt
2 | import numpy as np
3 |
4 | import gif
5 |
6 | COLOR = "#d66582"
7 | SIZE = 3
8 |
9 |
10 | def random_scatter(x, y, beta=0.15, seed=None):
11 | np.random.seed(seed)
12 | ratio_x = -beta * np.log(np.random.rand(x.shape[0]))
13 | ratio_y = -beta * np.log(np.random.rand(y.shape[0]))
14 | dx = ratio_x * x
15 | dy = ratio_y * y
16 | return x - dx, y - dy
17 |
18 |
19 | def plot_random_scatter(ax, x, y, beta, c=COLOR, s=SIZE, alpha=None, seed=None):
20 | x, y = random_scatter(x, y, beta=beta, seed=seed)
21 | ax.scatter(x, y, s=3, c=c, alpha=alpha)
22 |
23 |
24 | @gif.frame
25 | def plot_heart(x, y, i):
26 | fig = plt.figure(figsize=(5, 3), dpi=100, facecolor="white")
27 | ax = plt.gca()
28 | ax.set_facecolor("white")
29 | x = x * np.sin(i)
30 | y = y * np.sin(i)
31 | ax.scatter(x, y, s=SIZE, c=COLOR)
32 | plot_random_scatter(ax, x, y, 0.15, seed=1)
33 | plot_random_scatter(ax, x, y, 0.15, seed=2)
34 | plot_random_scatter(ax, x, y, 0.15, seed=3)
35 | xi = x[: x.shape[0] : 2] * np.sin(i) * 0.7
36 | yi = y[: y.shape[0] : 2] * np.sin(i) * 0.7
37 | plot_random_scatter(ax, xi, yi, 0.25, seed=4)
38 | xo = x[: x.shape[0] : 2] * np.sin(i) * 1.2
39 | yo = y[: y.shape[0] : 2] * np.sin(i) * 1.2
40 | plot_random_scatter(ax, xo, yo, 0.1, alpha=0.8, seed=6)
41 |
42 | for spine in ax.spines.values():
43 | spine.set_visible(False)
44 | ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False)
45 | plt.xlim((-6 * np.pi, 6 * np.pi))
46 | plt.ylim((-6 * np.pi, 6 * np.pi))
47 | plt.tight_layout(pad=0)
48 |
49 |
50 | frames = []
51 | for i in np.linspace(np.pi / 3, 2 * np.pi / 3, 20):
52 | t = np.linspace(0, 2 * np.pi, 2000)
53 | x = 16 * np.sin(t) ** 3
54 | y = 13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t)
55 | frame = plot_heart(x, y, i)
56 | frames.append(frame)
57 |
58 | gif.save(frames, "images/heart.gif", duration=100)
59 |
--------------------------------------------------------------------------------
/examples/hop.py:
--------------------------------------------------------------------------------
1 | import matplotlib.pyplot as plt
2 | import numpy as np
3 |
4 | import gif
5 |
6 | N = 50
7 | red = np.random.normal(loc=45, scale=3, size=N)
8 | blue = np.random.normal(loc=48, scale=5, size=N)
9 |
10 |
11 | @gif.frame
12 | def plot_hop(i, margin=0.1):
13 | plt.figure(figsize=(5, 3), dpi=100)
14 | plt.hlines(y=red[i], xmin=0, xmax=1 - margin, colors="r", lw=2)
15 | plt.hlines(y=blue[i], xmin=1 + margin, xmax=2, colors="b", lw=2)
16 | plt.xlim(0 - margin * 2, 2 + margin * 2)
17 | plt.ylim(0, 100)
18 | plt.xticks([0.5, 1.5], ["Red Team", "Blue Team"])
19 | plt.yticks([0, 25, 50, 75, 100], ["0", "25", "50", "75", "100%"])
20 |
21 |
22 | frames = []
23 | for i in range(N):
24 | frame = plot_hop(i)
25 | frames.append(frame)
26 |
27 | gif.save(frames, "images/hop.gif", duration=200)
28 |
--------------------------------------------------------------------------------
/examples/phone.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 | from matplotlib import pyplot as plt
3 |
4 | import gif
5 |
6 | START = pd.Timestamp("2019-04-20")
7 | END = pd.Timestamp("2020-05-01")
8 |
9 | data = "1.9,2.0,3.8,2.9,2.7,1.5,1.4,2.0,1.8,2.6,2.1,1.4,2.8,3.2,3.0,3.6,2.4,4.2,3.3,4.3,2.0,4.0,2.0,2.2,2.5,1.8,1.8,1.6,2.6,2.6,2.8,2.1,2.4,1.9,1.4,1.2,3.9,2.9,1.7,1.8,1.7,2.4,2.3,1.5,2.4,2.6,1.6,1.2,1.9,2.5,2.3,2.6,2.0,1.8,2.5,1.9,2.5,2.7,2.5,2.0,1.6,1.4,2.4,2.4,0.7,3.5,3.6,2.9,3.4,1.6,1.8,1.8,1.1,1.9,1.9,1.3,1.6,2.1,1.7,3.1,4.4,3.7,2.8,3.6,4.0,5.6,2.5,1.4,1.6,1.6,2.9,2.0,2.9,2.0,1.9,1.9,1.9,1.2,2.1,1.8,2.5,2.0,2.0,2.1,2.3,2.9,1.4,1.6,1.4,2.2,2.2,2.4,1.6,1.2,1.8,1.8,2.2,1.8,5.3,0.8,2.1,3.3,4.5,1.4,1.3,2.8,0.9,1.7,1.6,1.3,1.8,2.4,3.6,2.6,3.6,5.8,2.4,1.2,1.5,2.1,2.5,3.1,1.8,2.0,1.6,1.8,3.6,2.2,2.1,2.2,1.0,1.7,2.0,2.3,2.0,1.6,1.6,1.2,1.1,1.6,1.7,2.2,1.5,1.9,1.6,2.0,2.3,1.8,3.2,2.7,2.0,2.3,1.3,1.4,1.0,2.1,1.6,1.6,2.7,2.7,2.9,2.7,2.9,2.5,2.2,2.7,2.5,1.7,3.0,2.9,2.4,3.0,3.1,3.0,3.4,2.2,1.7,4.3,2.8,2.8,2.0,4.3,4.2,7.9,9.1,3.6,2.7,4.9,4.1,4.5,3.1,4.1,3.1,3.0,3.7,2.9,3.0,4.1,4.4,4.8,1.9,2.6,2.3,2.2,1.8,3.7,1.7,1.9,3.5,6.3,3.2,2.2,5.0,1.4,2.8,2.2,2.3,3.6,2.4,3.9,1.7,2.2,1.9,2.5,2.6,3.4,4.8,3.2,5.0,5.8,3.3,3.8,2.6,2.8,3.5,3.2,3.6,3.1,6.3,7.5,3.2,3.5,3.4,4.2,2.7,2.9,6.9,4.3,4.3,2.7,2.7,3.1,4.4,5.8,2.8,3.2,4.1,2.0,3.0,5.1,5.4,6.5,3.0,2.7,2.8,3.1,2.6,4.4,5.7,3.6,3.4,4.1,4.2,4.3,5.6,4.9,2.7,2.1,3.5,3.5,3.1,2.7,0.7,3.1,1.9,3.9,2.9,2.9,2.4,2.5,2.5,3.2,2.1,2.3,2.1,2.3,4.7,4.7,4.4,4.5,4.0,3.4,3.0,1.9,3.8,1.4,2.6,1.7,2.8,2.7,2.7,2.2,2.6,4.3,6.7,7.0,4.2,4.9,3.8,4.8,4.8,3.5,3.0,1.8,1.3,2.4,4.1,4.5,4.6,4.5,4.2,3.4,2.8,1.4,3.0,2.4,2.2,1.9,2.1,1.6,2.8,2.8,4.2,3.1,3.7,2.0,2.6,1.7,2.1,1.6,3.5,1.6,1.8,2.1,3.0,5.4,2.9,3.0"
10 |
11 | df = pd.DataFrame(
12 | {
13 | "date": pd.date_range(start=START, end=END),
14 | "time": [float(d) for d in data.split(",")],
15 | }
16 | )
17 |
18 |
19 | @gif.frame
20 | def plot(date):
21 | d = df[df["date"] <= date]
22 | fig, ax = plt.subplots(figsize=(5, 3), dpi=100)
23 | plt.plot(d["date"], d["time"])
24 | ax.set_xlim([START, END])
25 | ax.set_ylim([0, 10])
26 | ax.set_xticks([date])
27 | ax.set_yticks([0, 2, 4, 6, 8, 10])
28 | ax.set_xticklabels([date.strftime("%b '%y")])
29 | ax.set_yticklabels([0, 2, 4, 6, 8, "\n10\nhours"])
30 |
31 |
32 | frames = []
33 | for date in df["date"]:
34 | frame = plot(date)
35 | frames.append(frame)
36 |
37 | gif.save(frames, "images/phone.gif", duration=35)
38 |
--------------------------------------------------------------------------------
/examples/seinfeld.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import pandas as pd
4 | from matplotlib import pyplot as plt
5 |
6 | import gif
7 |
8 | # script cleaning
9 | df = pd.read_csv("gallery/data/seinfeld.csv")
10 | df = df[df["character"].isin(["JERRY", "GEORGE", "ELAINE", "KRAMER"])]
11 | df["character"] = df["character"].str.capitalize()
12 | df["episode"] = df["episode"].apply(
13 | lambda x: float(f'{x.split("E")[0][1:]}.{x.split("E")[1]}')
14 | )
15 | df["line"] = df["line"].apply(lambda x: re.sub("(?<=\()(.*)(?=\))", "", x))
16 | df["words"] = df["line"].apply(lambda x: len(re.findall("\w+", x)))
17 | df = df.groupby(["episode", "character"])["words"].sum().reset_index()
18 | df = df.sort_values(["episode", "character"])
19 |
20 | # if character doesn't appear in episode...
21 | df = df.set_index(["episode", "character"])
22 | df = df.reindex(
23 | pd.MultiIndex.from_product(
24 | [df.index.levels[0], df.index.levels[1]], names=["episode", "character"]
25 | ),
26 | fill_value=0,
27 | )
28 | df = df.reset_index()
29 |
30 | # calculate words in episode
31 | wie = df.groupby(["episode"]).sum()
32 | wie = wie.rename(columns={"words": "wie"})
33 | wie["wie_cumsum"] = wie["wie"].cumsum()
34 | wie = wie.reset_index()
35 |
36 | # calculate character cumsum
37 | df = pd.merge(df, wie, on=["episode"])
38 | df["character_cumsum"] = df.groupby("character")["words"].cumsum()
39 | df["%"] = df["character_cumsum"] / df["wie_cumsum"]
40 | df["e%"] = df["words"] / df["wie"]
41 | df = df[["episode", "character", "%", "e%"]]
42 | df = df.sort_values(["episode", "%"])
43 | df["episode"] = df["episode"].apply(
44 | lambda x: str(x) + "0" if len(str(x)) == 3 else str(x)
45 | )
46 |
47 | # colour mapping
48 | colours = {
49 | "Jerry": "#0526E3",
50 | "George": "#7F8068",
51 | "Elaine": "#D1DE1F",
52 | "Kramer": "#E3C505",
53 | }
54 |
55 | df["color"] = df["character"].map(colours)
56 |
57 |
58 | @gif.frame
59 | def plot(episode):
60 | ep = df[df["episode"] == episode].copy()
61 | title = ep["episode"].values[0].split(".")
62 | fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(5, 3), dpi=100)
63 | # episode plot
64 | axes[0].barh(ep["character"], ep["e%"], color=ep["color"])
65 | axes[0].set_xlim([0, 1])
66 | axes[0].set_xticks([])
67 | axes[0].yaxis.set_tick_params(labelsize=10)
68 | axes[0].yaxis.set_ticks_position("none")
69 | axes[0].set_facecolor("#FFFFFF")
70 | axes[0].set_xlabel(f"Season {title[0]} Episode {int(title[1])}")
71 | # total plot
72 | axes[1].barh(ep["character"], ep["%"], color=ep["color"])
73 | axes[1].set_xlim([0, 1])
74 | axes[1].set_xticks([])
75 | axes[1].set_yticks([])
76 | axes[1].set_xlabel(f"Total")
77 | axes[1].set_facecolor("#FFFFFF")
78 |
79 |
80 | frames = []
81 | for episode in df.episode.unique():
82 | frame = plot(episode)
83 | frames.append(frame)
84 |
85 | gif.save(frames, "images/seinfeld.gif", duration=100)
86 |
--------------------------------------------------------------------------------
/examples/spiral.py:
--------------------------------------------------------------------------------
1 | import matplotlib.pyplot as plt
2 | import numpy as np
3 | from mpl_toolkits.mplot3d import Axes3D
4 |
5 | import gif
6 |
7 | N = 100
8 |
9 |
10 | @gif.frame
11 | def plot_spiral(i):
12 | fig = plt.figure(figsize=(5, 3), dpi=100)
13 | ax = fig.add_subplot(projection="3d")
14 | a, b = 0.5, 0.2
15 | th = np.linspace(475, 500, N)
16 | x = a * np.exp(b * th) * np.cos(th)
17 | y = a * np.exp(b * th) * np.sin(th)
18 | z = np.linspace(0, 2, len(th))
19 | ax.plot(x[:i], y[:i], z[:i], lw=4, color="purple")
20 | ax.set_xlim(min(x), max(x))
21 | ax.set_ylim(min(y), max(y))
22 | ax.set_zlim(min(z), max(z))
23 | ax.set_xticklabels([])
24 | ax.set_yticklabels([])
25 | ax.set_zticklabels([])
26 |
27 |
28 | frames = []
29 | for i in range(N):
30 | frame = plot_spiral(i)
31 | frames.append(frame)
32 |
33 | gif.save(frames, "images/spiral.gif", duration=50)
34 |
--------------------------------------------------------------------------------
/gif.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from io import BytesIO as Buffer
3 | from typing import Callable, List, TypeVar, ByteString
4 |
5 | from matplotlib import pyplot as plt
6 | from numpy import array as numpy_array
7 | from numpy import vsplit as numpy_vsplit
8 | from numpy import vstack as numpy_vstack
9 | from PIL import Image as PI
10 |
11 | Plot = TypeVar("Plot")
12 | Frame = PI.Image
13 | Milliseconds = float
14 |
15 |
16 | class Options:
17 | """Matplotlib export options
18 |
19 | See: ["savefig"](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.savefig.html)
20 |
21 | Example:
22 |
23 | ```python
24 | gif.options.matplotlib["dpi"] = 300
25 | ```
26 | """
27 |
28 | def __init__(self):
29 | self.matplotlib = {}
30 |
31 | def reset(self) -> None:
32 | self.matplotlib = {}
33 |
34 |
35 | options = Options()
36 |
37 |
38 | def frame(plot: Callable[..., Plot]) -> Callable[..., Frame]: # type: ignore[valid-type]
39 | """Prepare plot for animation
40 |
41 | Example:
42 |
43 | ```python
44 | @gif.frame
45 | def plot(i):
46 | plt.scatter(x[:i], y[:i])
47 | plt.xlim((0, 100))
48 | plt.ylim((0, 100))
49 | ```
50 | """
51 |
52 | @wraps(plot)
53 | def inner(*args, **kwargs) -> Frame: # type: ignore[valid-type]
54 | buffer = Buffer()
55 | plot(*args, **kwargs)
56 | plt.savefig(buffer, format="png", **options.matplotlib)
57 | plt.close()
58 | buffer.seek(0)
59 | frame = PI.open(buffer)
60 | return frame # type: ignore[no-any-return]
61 |
62 | return inner
63 |
64 |
65 | def _optimize_frames(frames: List[Frame]) -> (List[PI.Image], ByteString): # type: ignore[valid-type]
66 | joined_img = PI.fromarray(numpy_vstack(frames))
67 | joined_img = joined_img.quantize(colors=255, dither=0)
68 | palette = b"\xff\x00\xff" + joined_img.palette.getdata()[1]
69 | joined_img_arr = numpy_array(joined_img)
70 | joined_img_arr += 1
71 | arrays = numpy_vsplit(joined_img_arr, len(frames))
72 | prev_array = arrays[0]
73 | for array in arrays[1:]:
74 | mask = (array == prev_array)
75 | prev_array = array.copy()
76 | array[mask] = 0
77 | frames_out = [PI.fromarray(array) for array in arrays]
78 | return frames_out, palette
79 |
80 |
81 | def save(
82 | frames: List[Frame], # type: ignore[valid-type]
83 | path: str,
84 | duration: Milliseconds = 100,
85 | *,
86 | overlapping: bool = True,
87 | optimize: bool = False,
88 | ) -> None:
89 | """Save prepared frames to .gif file
90 |
91 | Example:
92 |
93 | ```python
94 | frames = [plot(i) for i in range(10)]
95 | gif.save(frames, "test.gif", duration=50)
96 | ```
97 | """
98 |
99 | if not path.endswith(".gif"):
100 | raise ValueError(f"'{path}' must end with .gif")
101 |
102 | kwargs = {}
103 | if optimize:
104 | frames, palette = _optimize_frames(frames)
105 | kwargs = {"palette": palette, "transparency": 0}
106 |
107 | frames[0].save( # type: ignore
108 | path,
109 | save_all=True,
110 | append_images=frames[1:],
111 | optimize=True,
112 | duration=duration,
113 | disposal=0 if overlapping else 2,
114 | loop=0,
115 | **kwargs,
116 | )
117 |
--------------------------------------------------------------------------------
/images/arrival.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/arrival.gif
--------------------------------------------------------------------------------
/images/beating_heart.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/beating_heart.gif
--------------------------------------------------------------------------------
/images/heart.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/heart.gif
--------------------------------------------------------------------------------
/images/hop.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/hop.gif
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/logo.png
--------------------------------------------------------------------------------
/images/phone.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/phone.gif
--------------------------------------------------------------------------------
/images/seinfeld.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/seinfeld.gif
--------------------------------------------------------------------------------
/images/spiral.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maxhumber/gif/c34a3cf6d85de398a7d3a0a77379a8e6f2067ce9/images/spiral.gif
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version = 3.9
3 | warn_return_any = True
4 | warn_unused_configs = True
5 |
6 | [mypy-matplotlib.*]
7 | ignore_missing_imports = True
8 |
9 | [mypy-PIL.*]
10 | ignore_missing_imports = True
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | with open("README.md", "r", encoding="utf-8") as f:
4 | long_description = f.read()
5 |
6 | setup(
7 | name="gif",
8 | version="23.03.0",
9 | description="The matplotlib Animation Extension",
10 | long_description=long_description,
11 | long_description_content_type="text/markdown",
12 | url="https://github.com/maxhumber/gif",
13 | author="Max Humber",
14 | author_email="max.humber@gmail.com",
15 | license="MIT",
16 | classifiers=[
17 | "Development Status :: 5 - Production/Stable",
18 | "Topic :: Multimedia :: Graphics",
19 | ],
20 | keywords=[
21 | "gif",
22 | "gifs",
23 | "animation",
24 | "PIL",
25 | "pillow",
26 | "matplotlib",
27 | ],
28 | py_modules=["gif"],
29 | python_requires=">=3.9",
30 | install_requires=["matplotlib>=3.5,<4.0", "Pillow>=9.1"],
31 | extras_require={
32 | "dev": [
33 | "mypy",
34 | "isort",
35 | "pyright",
36 | "black",
37 | "matplotlib",
38 | "pandas",
39 | "pytest",
40 | ],
41 | "test": [
42 | "matplotlib",
43 | "pandas",
44 | "pytest",
45 | ],
46 | },
47 | )
48 |
--------------------------------------------------------------------------------
/tests/test_gif.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import pytest
4 | from matplotlib import pyplot as plt
5 | from PIL import Image
6 | from PIL.PngImagePlugin import PngImageFile
7 |
8 | import gif
9 |
10 |
11 | def milliseconds(img):
12 | img.seek(0)
13 | duration = 0
14 | while True:
15 | try:
16 | duration += img.info["duration"]
17 | img.seek(img.tell() + 1)
18 | except EOFError:
19 | return duration
20 |
21 |
22 | @gif.frame
23 | def plot(x, y):
24 | plt.scatter(x, y)
25 |
26 |
27 | def make_gif(tmpdir_factory, filename, dpi=None, **kwargs):
28 | if dpi is not None:
29 | gif.options.matplotlib["dpi"] = 300
30 | frames = [plot([0, 5], [0, 5]), plot([0, 10], [0, 10])]
31 | if dpi is not None:
32 | gif.options.reset()
33 | path = str(tmpdir_factory.mktemp("matplotlib").join(filename))
34 | gif.save(frames, path, **kwargs)
35 | return path
36 |
37 |
38 | @pytest.fixture(scope="session")
39 | def default_file(tmpdir_factory):
40 | return make_gif(tmpdir_factory, "default.gif")
41 |
42 |
43 | @pytest.fixture(scope="session")
44 | def optimized_file(tmpdir_factory):
45 | return make_gif(tmpdir_factory, "optimized.gif", optimize=True)
46 |
47 |
48 | @pytest.fixture(scope="session")
49 | def hd_file(tmpdir_factory):
50 | return make_gif(tmpdir_factory, "hd.gif", dpi=300)
51 |
52 |
53 | @pytest.fixture(scope="session")
54 | def long_file(tmpdir_factory):
55 | return make_gif(tmpdir_factory, "long.gif", duration=2500)
56 |
57 |
58 | def test_frame():
59 | frame = plot([0, 5], [0, 5])
60 | assert isinstance(frame, PngImageFile)
61 |
62 |
63 | def test_default_save(default_file):
64 | img = Image.open(default_file)
65 | assert img.format == "GIF"
66 | assert milliseconds(img) == 200
67 |
68 |
69 | def test_optimization(default_file, optimized_file):
70 | default_size = os.stat(default_file).st_size
71 | optimized_size = os.stat(optimized_file).st_size
72 | assert optimized_size < default_size * 0.9
73 |
74 |
75 | def test_dpi_save(hd_file):
76 | img = Image.open(hd_file)
77 | assert img.format == "GIF"
78 | assert milliseconds(img) == 200
79 |
80 |
81 | def test_long_save(long_file):
82 | img = Image.open(long_file)
83 | assert img.format == "GIF"
84 | assert milliseconds(img) == 5000
85 |
--------------------------------------------------------------------------------