Why this works on GitHub specifically
GitHub strips <script> tags from READMEs and sanitizes almost all inline CSS. But it fully renders SVG files embedded via a normal <img> tag, and it runs their built-in SMIL animations. That’s the loophole: all the motion has to live inside the SVG file itself, the README just points to it.
Setup
gh repo create <your-username> --public --clone
cd <your-username>
pip install pillow numpy opencv-python-headless rembg onnxruntime
First run downloads a ~176MB background-removal model, one time only.
The pipeline, four stages
Each stage exists because skipping it makes the output visibly worse.
1. Cut out the background
rembg isolates the subject. Everything outside the subject gets forced to pure white, which maps to the blank end of the character ramp. Skip this and the background fills with dense characters and drowns the portrait.
2. CLAHE for local contrast
Contrast-limited adaptive histogram equalization. Regular autocontrast works globally and leaves a flatly-lit face as one uniform mid-tone. CLAHE boosts contrast per-tile so the face gets real highlights and shadows. Clip limit around 2.2 to 3.0 is the useful range, higher amplifies skin texture into noise. Run a bilateral filter first to smooth skin while keeping edges.
3. Map brightness to characters
RAMP = " .`:-=+*cs#%@" # bright/sparse -> dark/dense
The leading space clears the background to nothing. Monospace characters are roughly twice as tall as wide, so the row count is derived from that ratio. Two choices matter here: stay monochrome (one fill color, per-character rainbow coloring is what makes most ASCII portraits look like static), and tune gamma (1.0 to 1.4 depending on the photo, raising it pushes mid-tones lighter so only real shadow draws).
4. Animate the typing
Each row is wrapped in a clip-path whose width animates from 0 to full. A small block rides the wipe edge as a cursor. Rows are staggered top to bottom, and (if you want it to loop instead of playing once) a self-chaining driver animation restarts the whole sequence forever with a pause at the end of each cycle.
Light and dark mode
A <style> block inside the SVG (not the README) handles both automatically:
.a { fill: #6e7681; }
@media (prefers-color-scheme: dark) { .a { fill: #c9d1d9; } }
This works because the SVG renders in its own context inside the <img>. Hardcoding a single color makes the art invisible in whichever mode you didn’t test.
Photo requirements
This is the part that decides whether the output is good, and no amount of parameter tuning fixes a bad input photo. ASCII draws with shadow, not detail, about 13 brightness levels total.
- Side light. Window at roughly 45 degrees, everything else off. One side of the face lit, the other in shadow. Overhead lighting is the worst case, it leaves nothing to render.
- Fill the frame. Chin to just above the hair. A small face in the frame only gets a handful of characters across and won’t resolve.
- Plain background, and don’t wear black on a dark background.
- Slight angle, not straight on. Gives the nose and jaw a real shadow edge.
Tuning table
| Symptom | Fix |
|---|---|
| Whole image is a wall of dense characters | Background not removed, or gamma too low, raise it to 1.4-2.0 |
| Washed out, barely any characters | Gamma too high, or CLAHE clip too low |
| Noisy, speckled skin | CLAHE clip too high, drop to ~2.0 and increase the bilateral filter strength |
| Face unrecognizable, only a silhouette | Not enough columns, or the face is too small in the frame, crop tighter |
| Invisible in GitHub light mode | Colors hardcoded instead of using the media-query style block |
Get the script
Comment the keyword on the post and I’ll send you the full script, ready to run.