---
title: "ASCII Reels in Python: an Eye That Zooms Into a Heart, Roses Rising and a Golden-Angle Sunflower"
seoTitle: "ASCII Art Reels in Python: Procedural Eye Zoom, Roses and a Fibonacci Sunflower (NumPy + ffmpeg)"
description: "How I took apart a viral ASCII eye reel and rebuilt it in Python with NumPy and ffmpeg, then made two more: roses that grow and bloom, and a sunflower drawn with the golden angle."
date: "2026-11-13"
type: "build"
tags: ["python", "numpy", "ascii art", "reels", "ffmpeg", "generative art"]
---

I saw a reel going around: a Python window titled "Eye -> Space", full of coloured ASCII characters, that zooms into an eye, through the pupil, out into a starfield. No footage anywhere. I wanted to know exactly how it was made, so I pulled it apart frame by frame, rebuilt it, and then made two more.

<div style="display:flex;gap:10px;margin:28px 0">
  <video src="/assets/ascii-reels/eye_to_heart.mp4" poster="/assets/ascii-reels/eye_to_heart.jpg" autoplay muted loop playsinline preload="metadata" style="flex:1;min-width:0;width:100%;border-radius:8px;background:#000;margin:0"></video>
  <video src="/assets/ascii-reels/roses_rising.mp4" poster="/assets/ascii-reels/roses_rising.jpg" autoplay muted loop playsinline preload="metadata" style="flex:1;min-width:0;width:100%;border-radius:8px;background:#000;margin:0"></video>
  <video src="/assets/ascii-reels/golden_sunflower.mp4" poster="/assets/ascii-reels/golden_sunflower.jpg" autoplay muted loop playsinline preload="metadata" style="flex:1;min-width:0;width:100%;border-radius:8px;background:#000;margin:0"></video>
</div>

<div class="highlight">
<p><strong>The short version:</strong> every frame is maths on a grid of character cells. Each cell gets a colour and a brightness; brightness picks a character from a density ramp (<code>.</code> to <code>@</code>), colour tints it. A pre-rendered glyph atlas turns the grid into pixels in one NumPy indexing step, and ffmpeg turns the frames into a 1080×1920 MP4.</p>
<p style="margin-bottom:0"><strong>Stack:</strong> Python, NumPy, Pillow, ffmpeg. No camera, no 3D engine, no GPU. About 0.04 seconds a frame, so a 13 second reel renders in under half a minute.</p>
</div>

## What the original is actually doing

Freeze it on the first second and you can read the code behind the window. Lines like `np.exp(-((du - cr_pos) / 0.020) ** 2) * upper` and `mix(skin, np.array([0.68, 0.40, 0.31]), ...)` give it away: the eye is not a photo run through an ASCII filter. Every part of it (skin, eyelid, sclera, iris, pupil) is a formula evaluated over a grid, and the "camera" is just a zoom factor applied to the coordinates.

Then the trick that sells it: they filmed their monitor with a phone. The code in VS Code sits behind the window, the screen glows, and you believe a human typed it.

So there are three parts to rebuild: the renderer, the scenes, and the look.

## 1. The renderer: brightness picks the character

The screen is a grid of character cells. At a 9 px cell width that is 120 columns by 120 rows on a vertical reel (cells are 1.75 times taller than wide, like a real monospace font).

Each scene returns three arrays the size of that grid: a colour per cell, a brightness per cell, and an optional forced character (for stars, stems and text, where the shape of the glyph matters more than its density). Brightness maps onto this ramp, darkest to densest:

```text
 .'`^",:;Il!i><~+_-?][}{1)(|\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$
```

Drawing 14,000 characters a frame with Pillow is slow. Instead I render every glyph once into an atlas, and then the whole frame is a single fancy-index plus a reshape:

```python
idx = np.where(forced, char, (np.clip(lum ** 0.7, 0, 0.92) * (len(RAMP) - 1)).astype(int))
img = atlas[idx][..., None] * colour[:, :, None, None, :]      # rows, cols, ch, cw, 3
img = img.transpose(0, 2, 1, 3, 4).reshape(rows * ch, cols * cw, 3)
```

`atlas[idx]` grabs the bitmap for every cell at once, multiplying by colour tints it, and the transpose lays the cells out as an image. That is the whole renderer.

One more line gives it the shimmer you see in the original, where characters churn even when nothing moves:

```python
lum = np.clip(lum + rng.normal(0, 0.06, lum.shape), 0, 1)
```

A little noise before quantising means cells near a threshold flip characters every frame. It makes a still image feel alive.

Frames go straight into ffmpeg through a pipe, so nothing touches disk:

```python
ff = subprocess.Popen(["ffmpeg", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "1080x1920",
                       "-r", "30", "-i", "-", "-c:v", "libx264", "-crf", "18",
                       "-pix_fmt", "yuv420p", out], stdin=subprocess.PIPE)
for i in range(frames):
    ff.stdin.write(render(i / 30).tobytes())
```

## 2. The eye: realism is layers

My first eye was an almond, a white, a brown circle and a black dot. It read as a cartoon. What made it look real was stacking small, physically motivated layers, each one a few lines of NumPy:

- **Lids that aren't symmetric.** The upper lid is a taller arc than the lower one, and the blink squeezes the upper lid down onto the lower.
- **Eyelashes as a fan.** Above the lid, each pixel's position is bent outward and curled by its height, then snapped to one of 24 strands with random lengths. Dark strands on lit skin is what reads as lashes.
- **A shadow from the lid.** The upper lid darkens the top of the eyeball. Without it the eye looks pasted on.
- **A round eyeball.** The white is shaded like a sphere, goes slightly pink toward the corners, has faint veins (ridged noise), and there is a pink blob at the inner corner.
- **A fibrous iris.** Noise stretched along the angle gives radial fibres, a jagged brighter ring (the collarette) sits halfway out, there are dark pits, and a dark rim at the edge. Gold in the middle, brown outside.
- **A window catchlight** on the cornea, and a pupil that dilates as the camera pushes in.
- **A slight gaze drift** for the first two seconds, then it locks on the camera right before the zoom.

The zoom itself is exponential, because a linear zoom feels like it slows down as you get closer:

```python
zoom = 1.05 * np.exp(ss(2.6, 7.2, t) ** 1.15 * np.log(9 / 1.05))
eye_colour, eye_lum, pupil = eye(gx / zoom, -gy / zoom, t)
```

Inside the pupil the space scene fades in, so by the time the pupil fills the screen you are already in the stars.

## 3. Getting it to read on a phone

This is where most of the time went, and none of it is in the original reel because they had a real screen and a real camera doing it for them.

**Everything was turning into W.** Bright regions all hit the top of the ramp, so the iris and the whites became the same wall of `W` and the fibre detail vanished. The fix is a soft tone curve instead of a hard clip, so highlights keep their texture:

```python
lum = 1 - np.exp(-2.0 * colour.mean(-1) ** 1.2)
```

**Then everything was too dark.** A character only covers a fraction of its cell, so an ASCII frame viewed small, like on a phone, loses most of its brightness. Two things fixed it: Menlo Bold instead of regular (thicker strokes), and a soft bloom, which is the glow you get for free when a phone films a bright monitor:

```python
small = frame.resize((W // 4, H // 4)).filter(ImageFilter.GaussianBlur(4))
frame = frame + small.resize((W, H)) * 1.1
```

**And it looked flat.** A vignette that darkens toward the edges turned it moody and put your eye on the iris.

## 4. The heart

In the space part I swapped the original's planet for a heart. It is the classic implicit curve: every point where this is negative is inside the heart.

```python
f = (x * x + y * y - 1) ** 3 - x * x * y ** 3
on = f < 0
depth = np.clip(-f * 2.5, 0, 1) ** 0.5          # how far inside, used as fake 3D shading
```

The depth term gives it a rounded, lit look, a highlight sits on the upper left lobe, and the size pulses with a lub-dub (two bumps per beat, about 66 bpm) while a glow around it breathes with the beat.

## 5. Roses rising

Five stems grow up from the bottom at staggered times. Stems are drawn as forced characters, picking `|`, `/` or `\` from the slope of the stem at each row, so they look hand-typed rather than rendered.

Each bloom is five layers of five petals, outer layers first and inner ones painted on top, opening over three seconds. The part that made them read as roses and not pink blobs is the shading inside each petal: the cup is nearly black and the rim catches the light.

```python
cup = ss(Rj * 0.45, edge, r)                    # 0 deep in the petal, 1 at its edge
sh = (0.06 + 0.94 * cup ** 1.6) * crease ** 1.5
```

The centre is a tight swirl (the angle plus the radius, wrapped), and loose petals drift upward the whole time.

## 6. The golden-angle sunflower

This one is pure maths, and it is my favourite. Every seed is placed with two rules:

```python
GOLDEN = np.pi * (3 - np.sqrt(5))               # 137.508 degrees
theta = n * GOLDEN
r = R_HEAD * np.sqrt(n / N_SEEDS)
```

Each seed turns 137.508° from the last and sits a square root further out. Nothing else. The spiral arms you see (21 one way, 34 the other, both Fibonacci numbers) are not drawn anywhere; they fall out of those two lines.

To make the arms pop I light every other one. Seeds `n`, `n + 21`, `n + 42` and so on sit on the same arm, but arm 1 and arm 2 are not next to each other around the flower, so lighting "the first half" of them looks random. Sort the 21 arms by where they actually sit, then alternate:

```python
ARM_RANK = np.argsort(np.argsort((np.arange(21) * GOLDEN) % (2 * np.pi)))
lit = ARM_RANK[n % 21] % 2 == 0
```

Then 34 petals unfold, with a darker ring of 21 behind them, and the formula types itself out underneath with a live seed count.

## Making your own with Claude

I did all of this in one conversation with Claude: I dropped in the reel, it pulled frames and read the code off the screen, wrote the renderer, and then we iterated on stills until each scene looked right. If you want to do the same, this is the prompt to start from:

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">Here is a reel of an ASCII animation made in Python. Pull frames from it with ffmpeg and work out how it is made: what is footage and what is generated, how brightness maps to characters, and what happens in each section.

Then rebuild the effect as a Python script that renders straight to a 1080x1920, 30 fps MP4 with NumPy, Pillow and ffmpeg (no screen recording). Use a character grid, a density ramp, a pre-rendered glyph atlas so each frame is one NumPy indexing step, and a little per-frame noise so the characters shimmer.

The scene should be [describe yours: e.g. a realistic eye that zooms into the pupil and out to a beating heart]. Render stills at a few timestamps first and look at them at phone size before rendering the full video. Watch for highlights all turning into the same character, the frame being too dark when viewed small, and an empty first frame.</div>
</div>

## Things I'd tell you before you post one

- **Render, don't screen-record.** Rendering frames directly gives you a perfect 1080×1920 at any length. If you want the "I coded this" feel, put the animation in a window over your code (my renderer has a flag for it) or film your screen like the original did.
- **Check stills at phone size.** Every problem above was invisible at full resolution on a laptop and obvious at 400 px wide.
- **Never open on black.** My roses first started with a second and a half of empty screen. I moved the start two seconds into the growth so stems are already rising on the first frame.
- **Add the audio in Instagram.** The renders are silent on purpose, so you can use the trending sound.
