DEV Community

ssapable
ssapable

Posted on

Three pieces of code that make a PIL frame look hand-drawn: boiling lines, white-key cut-outs, and a feet-anchored sprite

My explainer videos are rendered frame by frame with Pillow and piped into ffmpeg. For a day they looked like what that sounds like: clean vector lines and a character that slid around like a PowerPoint shape. Nobody watched them, including me.

Three small changes made them read as hand-drawn cartoons. None of them is clever. All of them are short enough to paste here.

If you want to see the result first, this one is 3:47 and every frame went through the code below:

1. The line has to boil

Hand-drawn animation has a texture called boiling: the outline of a still object wobbles slightly from frame to frame because each frame was drawn by hand. A line that stays pixel-identical for two seconds reads as "computer" immediately.

The trick is to jitter every point of every line by a pixel or two, but to change the jitter only every third frame. At 30 fps that's ten "redraws" a second, which is roughly the cadence of drawn-on-twos animation. Jitter every frame and it shimmers like a bad TV signal.

class Pen:
    """Draws on a frame with a boiling hand-drawn line. `boil` changes every 3 frames."""
    def __init__(self, im, frame):
        self.im = im
        self.d = ImageDraw.Draw(im)
        self.rng = random.Random(frame // 3)      # same seed for 3 frames, then a new one
        self.frame = frame

    def w(self, pts, amp=2.0):
        return [(x + self.rng.uniform(-amp, amp), y + self.rng.uniform(-amp, amp)) for x, y in pts]

    def line(self, pts, width=6, color=INK, amp=2.0):
        pts = self.w(pts, amp)
        self.d.line(pts, fill=color, width=int(width), joint="curve")
        r = width / 2
        for x, y in (pts[0], pts[-1]):            # round caps; PIL's are square
            self.d.ellipse([x - r, y - r, x + r, y + r], fill=color)
Enter fullscreen mode Exit fullscreen mode

Seeding the generator with frame // 3 is the whole idea. Everything drawn through the pen (labels, speech bubbles, charts, arrows, props) gets the same treatment for free, and because the seed is derived from the frame number, a re-render of one shot produces exactly the same wobble as before. That matters when you re-render shot 9 and splice it between 8 and 10.

amp is per call. Big props boil at 2 px, small text at 1, and a chart line at 1 so it doesn't look like the market is shaking.

2. Cut the character out of a white background

The character is not drawn in code. Code-drawn characters were the thing people told me looked cheap, and they were right. Instead I have one character sheet (expressions, poses, colours on one page), and I ask an image model for each pose with the sheet as the reference image, on a plain white background. Eighteen poses cover a whole video.

Those PNGs have no alpha channel. The keying is one numpy expression plus a flood fill:

def _cutout(path):
    im = Image.open(path).convert("RGB")
    a = np.asarray(im).astype(np.int16)
    d = 255 - a.min(axis=2)                               # distance from white, per pixel
    alpha = np.clip((d - 18) * 6, 0, 255).astype(np.uint8) # 18..60 ramps 0..255
    rgba = Image.fromarray(np.dstack([np.asarray(im), alpha]), "RGBA")

    # anything enclosed by lines should be opaque even if it's white (eyes, teeth, a shirt)
    mask = Image.fromarray(alpha).filter(ImageFilter.MaxFilter(9))
    m = np.asarray(mask) > 0
    outside = flood_from_corners(m)                       # BFS over the dilated mask
    alpha2 = np.where(~outside, 255, alpha).astype(np.uint8)

    rgba.putalpha(Image.fromarray(alpha2).filter(ImageFilter.GaussianBlur(0.8)))
    return rgba.crop(rgba.getbbox())
Enter fullscreen mode Exit fullscreen mode

Two details carry it. First, "distance from white" is 255 - min(r, g, b), so a pale yellow robot and a black outline both count as far from paper, and only near-white counts as background. The ramp from 18 to 60 keeps anti-aliased edges soft instead of jagged.

Second, the flood fill. A character's eyes are white. The whites of a shirt are white. If you key on colour alone they become holes and the background shows through the face. So the alpha mask is dilated by a few pixels to close small gaps in the outline, the outside is flooded from the four corners, and everything that the flood didn't reach is declared interior and set opaque. That one step is the difference between a sticker and a stencil.

The result is cached next to the source PNG, so the keying runs once per pose, not once per frame.

3. Anchor the sprite at the feet

A cut-out that is positioned by its top-left corner looks like it's floating, because every pose has a different height and the feet move when the pose changes. Positioning by the feet fixes that, and gives you three cheap kinds of life:

def draw(self, P, x, y, scale=1.0, pose="idle", t=0.0, flip=False, rot=0.0, bob=True, squash=0.0, shadow=True):
    im = self.sprites[pose]
    s = self.height * scale / im.height
    w, h = int(im.width * s), int(im.height * s)
    if bob:                                   # breathing: tiny vertical wobble, feet stay planted
        h = int(h * (1 + 0.012 * math.sin(t * 2.4)))
    if squash:                                # landing: wider and shorter for a few frames
        w, h = int(w * (1 + squash)), int(h * (1 - squash))
    spr = im.resize((max(1, w), max(1, h)), Image.LANCZOS)
    if flip:
        spr = spr.transpose(Image.FLIP_LEFT_RIGHT)
    if abs(rot) > 0.1:
        spr = spr.rotate(rot, resample=Image.BICUBIC, expand=True)
    if shadow:
        sw = int(w * 0.55)
        P.d.ellipse([x - sw // 2, y - 10, x + sw // 2, y + 10], fill=(214, 212, 206))
    P.im.paste(spr, (int(x - spr.width / 2), int(y - spr.height)), spr)
Enter fullscreen mode Exit fullscreen mode

(x, y) is where the feet are. The breathing is a 1.2% vertical scale on a slow sine; you don't see it, you notice when it's gone. Squash is set by the shot for a few frames after a landing, and the ground shadow is an ellipse that gets wider with the sprite, which is most of what sells "standing on the floor". Walking is just x interpolated between keyframes while the pose swaps between a run sprite and an idle sprite.

One rule I learned the expensive way: never flip a sprite that has text on it. The character's sweatshirt says WIT, and mirrored it says TIW in every shot where he faces left. Generate the mirrored pose instead.

What a shot looks like

A shot is a function of (P, u, t, L): the pen, progress through the narration from 0 to 1, seconds since the shot started, and the narration loudness this frame. Everything is timed against u, so the animation stretches to fit whatever the synthetic voice does with the sentence:

def shot_hook_twenty(P, u, t, L):
    monitor_chart(P, DESK_MON, 1.0, seed=9)
    P.tag(510, 390, "+20 MNQ", p=seg(u, 0.05, 0.12), size=64, color=RED, rot=-6)
    P.tag(880, 360, "usual size: 1", p=seg(u, 0.3, 0.38), size=40, rot=3)
    WIT.draw(P, 1250, 1000, 1.0, "wit-surprised" if u > 0.1 else "wit-typing", t)
    P.bubble(520, 860, "SIZE: 20 contracts,\nyour usual MNQ size is 1", p=seg(u, 0.72, 0.82), size=34)
Enter fullscreen mode Exit fullscreen mode

Camera moves are a crop keyframed per shot with an optional shake, sound effects are synthesized (pop, stamp, thud, whoosh, tick) and placed at the same u values, and the whole thing is one PIL frame per 1/30 s piped to ffmpeg. The 3:47 video above is 14 shots and 90 sound events, and renders in under 15 minutes on a laptop; changing one shot re-renders only that shot.

What it still doesn't do

Lip sync. L, the narration loudness, is passed to every shot, but since the cast became cut-outs nothing uses it; a mouth layer driven by it is the next thing. Hand-drawn in-betweens between poses: a pose swap is a cut, not a tween, and it reads fine at a cut every few seconds, which is the style I was copying anyway. And it can't draw: the poses come from an image model with the sheet as reference, and every one of them was reviewed against the sheet before it went in.

The engine, the cut-out module and the prompt kits are packaged here if you want to run it on your own storyboard: https://payhip.com/b/A2wa8

Top comments (0)