├── .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 | gif 3 |
4 |
5 | 6 | 7 | Downloads 8 |
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 | | [![arrival.gif](images/arrival.gif)](examples/arrival.py) | [![hop.gif](images/hop.gif)](examples/hop.py) | [![phone.gif](images/phone.gif)](examples/phone.py) | 57 | | ------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- | 58 | | [![seinfeld.gif](images/seinfeld.gif)](examples/seinfeld.py) | [![spiral.gif](images/spiral.gif)](examples/spiral.py) | [![heart.gif](images/heart.gif)](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 | --------------------------------------------------------------------------------