Live demo: camera-to-ascii.netlify.app
Real-time camera to ASCII art, running entirely in the browser. No server, no dependencies, no install — just open index.html.
One-shot with Claude Code using Sonnet 4.6 Medium.
██████╗ █████╗ ███╗ ███╗███████╗██████╗ █████╗
██╔════╝██╔══██╗████╗ ████║██╔════╝██╔══██╗██╔══██╗
██║ ███████║██╔████╔██║█████╗ ██████╔╝███████║
██║ ██╔══██║██║╚██╔╝██║██╔══╝ ██╔══██╗██╔══██║
╚██████╗██║ ██║██║ ╚═╝ ██║███████╗██║ ██║██║ ██║
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
Open index.html in any modern browser. For phone use, serve it over your local network:
# Python 3
python -m http.server 8080
# Node (npx)
npx serve .Then visit http://<your-local-ip>:8080 on your phone. Grant camera permission when prompted.
| Button | Action |
|---|---|
FLIP |
Toggle front / back camera |
SZ |
Cycle font size: XS → S → M → L |
SMPL / DETL |
Switch character ramp (10-level simple or 70-level detailed) |
INV |
Invert colors (black on white) |
FPS |
Show live frame rate counter |
The conversion runs on every animation frame (~60 fps):
Camera feed
│
▼
drawImage() → tiny canvas (cols × rows px) ← GPU-accelerated downscale
│
▼
getImageData() ← single CPU read
│
▼
per pixel: luminance → LUT → char ← integer arithmetic, no floats
│
▼
fillText() × rows ← one draw call per row
GPU downscale first — the video frame is drawn to a processing canvas that is exactly cols × rows pixels (one pixel per character cell). The GPU handles all the interpolation; the CPU only sees the final grid.
Pre-built lookup table — on startup (and on ramp change) a 256-entry charLUT maps every possible luminance value directly to a character. The inner loop becomes a simple array lookup with no arithmetic.
Integer luminance — grayscale is computed with the Rec.601 coefficients using only integer multiplies and a bit-shift, avoiding Math.round and floating-point operations:
// R×0.299 + G×0.587 + B×0.114 ≈ (R×77 + G×150 + B×29) >> 8
const lum = (data[i] * 77 + data[i+1] * 150 + data[i+2] * 29) >> 8;Row-level text rendering — characters for each row are collected into an array and joined into a single string, then rendered with one fillText() call. This keeps total draw calls at rows (~60–100) instead of cols × rows (~7 000+).
DPR-aware canvas — the output canvas is sized in physical pixels (window.innerWidth × devicePixelRatio), keeping text sharp on retina and high-DPI screens.
Two ramps are available:
Simple (10 levels): ' .:-=+*#%@'
Detailed (70 levels): ' .`'^",:;il!I><~+_-?...MW&8%B@$'
Index 0 maps to the darkest luminance (rendered as space / background), the last index to the brightest. The inverted mode uses a reversed lookup table built at the same time, so switching costs nothing at runtime.
Any browser that supports getUserMedia + Canvas 2D — Chrome, Firefox, Safari 11+, Edge. Works on Android and iOS (Safari requires the playsinline attribute on the video element, which is set).
camera_to_ascii/
└── index.html # entire app — HTML + CSS + JS, no dependencies