A lot of my daily work involves plotting with matplotlib. While Jupyter notebooks are the popular choice for this, especially for sharing and presenting results, I’m not a fan of using them as a development environment. They encourage bad practices, and I much prefer a lean terminal/script-based workflow for Python development.

Plotting has always been a pain point in this setup - until now. I’ve hacked together a tiny matplotlib backend called “kitcat” that lets you plot directly into (compatible) terminals:

kitcat backend demo: running a .py file

kitcat backend demo: running inside REPL

How to use it?

Assuming that you’re using one of the compatible terminal emulators install kitcat via pip:

$ pip install kitcat

and select it as your matplotlib backend:

import matplotlib
matplotlib.use("kitcat")

How does it work?

kitty, my terminal emulator of choice, supports showing graphics via its kitten icat binary:

$ kitten icat some_image.jpeg

This is particularly useful when working with files over SSH. But I discovered that other terminal emulators have adopted Kitty’s protocol and some tools like Yazi file manager use it to display images. I looked into the actual graphic protocol and it’s surprisingly simple. All you have to is to send chunked base64 image data via an escape code in the form of \033_G<control data>;<payload>\033\.

Here’s the actual code to turn that into a matplotlib backend:

import sys
from base64 import b64encode
from io import BytesIO

from matplotlib.backend_bases import FigureManagerBase
from matplotlib.backends.backend_agg import FigureCanvasAgg

__all__ = ["FigureCanvas", "FigureManager"]

CHUNK_SIZE = 4096


def display_png(pixel_data):
    data = b64encode(pixel_data).decode("ascii")

    first_chunk, more_data = data[:CHUNK_SIZE], data[CHUNK_SIZE:]
    sys.stdout.write(
        f"\033_Gm={"1" if more_data else "0"},a=T,f=100;{first_chunk}\033\\"
    )

    while more_data:
        chunk, more_data = more_data[:CHUNK_SIZE], more_data[CHUNK_SIZE:]
        sys.stdout.write(f"\033_Gm={"1" if more_data else "0"};{chunk}\033\\")

    sys.stdout.write("\n")
    sys.stdout.flush()


class KitcatFigureManager(FigureManagerBase):
    def show(self):
        with BytesIO() as buf:
            self.canvas.print_png(buf)
            buf.seek(0)
            display_png(pixel_data=buf.read())


class KitcatFigureCanvas(FigureCanvasAgg):
    manager_class = KitcatFigureManager


# provide the standard names that matplotlib is expecting
FigureCanvas = KitcatFigureCanvas
FigureManager = KitcatFigureManager

What’s next?

There are other interesting standards to explore. iTerm2 has expanded the protocol to support animations, and some emulators support the older Sixel graphics standard. I’d love to add support for these eventually.

Acknowledgment

👏 After building this, I discovered matplotlib-backend-kitty which provides similar functionality specifically for Kitty. My solution works across any terminal supporting the protocol, so I decided to publish it anyway.