Chris Almaguer Data visualization design

Case study - Tooling & type

A camera that can only draw with type

ASCII iOS Camera converts the live camera feed into a grid of glyphs, at thirty frames a second, on the phone. Ten styles, from a braille dot matrix to a one-bit ramp. It is on the App Store, and the two things that decided how it looks were both measurement mistakes.

iOS app SwiftUI App Store
View on the App Store
In play. A recording straight off the phone: live camera in, glyphs out, swiping through the styles, with freeze, record, save and upload one thumb away.

01 - The idea

Brightness in, glyphs out

Every frame is sampled down to a grid, each cell reduced to one brightness value, and each value mapped to the glyph that carries about that much ink. That is the entire idea. Everything interesting is in the two decisions it hides: which glyphs, in what order, and how big a cell actually is.

Ten styles ship, and they do not all sample the same way. A ramp style reads one sample per cell. Quadrant blocks read two by two. Braille reads two by four, which is why it holds the most detail and became the launch default. The conversion core is a separate Swift package with no camera or interface code in it at all, so it can be unit tested against known inputs rather than judged by eye.

02 - The first measurement bug

A monospace cell is not as wide as you assume

The grid needs to know how wide a character is relative to its height. I had that as a constant: 0.58. It looked right. It was wrong. SF Mono's real advance ratio is about 0.618, and that six percent difference stretched every single frame horizontally and clipped the right edge of the image.

It survived to a public build because a stretched ASCII portrait still looks like an ASCII portrait. Nothing about it announces the error. The fix was to stop assuming and measure the advance at runtime, per style, since the styles do not all use the same face.

This is the same failure as the color tool on this site, in a different medium: a plausible constant, never checked, quietly wrong in every output it touched.

My best test shots

ASCII iOS Camera output. A standing figure in high-contrast glyphs, the background dissolving into sparse punctuation.
ASCII iOS Camera output. A parked car in the line-stroke style, panel edges picked out as continuous strokes.
ASCII iOS Camera output. A row of cars in a dense ramp, reflections reading as brightness steps across the grid.

03 - The second measurement bug

The ramp has to be ordered by ink, not by instinct

A glyph ramp is a scale: the darkest character at one end, the lightest at the other. The punctuation ramp had dense and light glyphs interleaved, so a shadow could render heavier than the midtone beside it. Tone reversed inside its own scale, which is the visual equivalent of a chart axis that runs backwards in the middle.

Two related corrections came with it. Luminance moved to the Rec.709 coefficients, since the earlier ones weight green differently and shift every mapping. And the digits ramp gained a leading blank, because without one a true black rendered as a field of small ones rather than as nothing.

There is an ordered dither underneath all of it, so neighboring cells at the same brightness do not all snap to the same glyph and band the image.

04 - The ramps as shipped

Every character, per style

Each ramp runs dark to bright, because more ink on a black screen reads as more light. All ten open with a blank, so true black renders as nothing rather than as a carpet of the lightest glyph. Two of them carry no ramp at all: braille and quadrant encode sub-cell detail in the shape of the glyph rather than in a position on a scale.

The note on each row is that style's tuned ink weight and tone gamma. Ramps built from thin marks top out structurally dimmer than block styles and spend their lower half on nearly invisible glyphs, so they render at a heavier weight and get more midtone lift. The block styles reach solid white on their own and need neither.

1 Braille
⠀ ⣿ The empty and full cells of the braille block, U+2800 to U+28FF. Eight dots per cell, each an independently dithered sub-sample, so any of the 256 glyphs between those two can appear. Two by four samples per character is the finest texture of any mode, which is why it became the launch default. Regular, gamma 1.25
2 Halftone
␣ . o O 0 8 @ A dot that grows with brightness, blank through to the densest ASCII glyph. The print halftone look, across the full contrast range. Semibold, gamma 1.15
3 Quadrant
␣ ▗ ▖ ▄ ▝ ▐ ▞ ▟ ▘ ▚ ▌ ▙ ▀ ▜ ▛ █ Two by two sub-blocks. Each corner is thresholded to lit or unlit and the resulting four bit pattern picks the glyph, so these sixteen are ordered by which corners are lit, not by how much ink they carry. Regular, gamma 1.00
4 Punctuation
␣ ` . ' , : ; " - ! $ The ramp that was out of order: the semicolon, one of the densest marks here, sat in the shadow slots while the comma and period sat in the midtones. Semibold, gamma 1.40
5 Classic
␣ . : - = + * # % @ Medium, gamma 1.30
6 Bar
␣ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ Eighth blocks: the one ramp where each glyph is effectively a bar chart of its own value. Regular, gamma 1.00
7 Lines
␣ ^ : ; | l i ! ¡ 1 I j " ] ( ) \ / Vertical strokes and slashes only, sorted light to heavy, so the texture stays linear instead of turning into a field of dots. Semibold, gamma 1.35
8 Power
␣ ₁ ₇ ₄ ₂ ₃ ₅ ₉ ₆ ₀ ₈ 1 7 4 2 3 5 9 6 0 8 Subscript digits as the light tier, the same digits full size as the dense tier. One is a single thin stroke and eight has two enclosed loops, which sets the order within each tier. This was the only ramp that shipped without a leading blank, so black rendered as a field of small ones. Semibold, gamma 1.30
9 Detail
␣ ` . ' , : ; " ~ - + ? x v z c n o a k X Z # & 8 % @ $ Twenty eight levels, curated down from the classic seventy character ramp. Nearly three times the steps of the others, which matters most in the dark end, where a short ramp jumps straight from blank to a visible glyph. Medium, gamma 1.30
10 One-bit
␣ @ Pure threshold. With the ordered dither on it reads as a newspaper halftone; with it off, a stark silhouette. Semibold, gamma 1.10

␣ marks the blank at level zero. Styles 1 and 3 are shown as the glyph set they draw from, not as a ramp, since neither maps a cell to a single position on a scale.

05 - Impact

What shipping it actually cost

It is approved and on the App Store, which is the part that is easy to say. The work that got it there was mostly unglamorous. Recording was capturing at point resolution instead of pixel resolution, which on a 3x device is nine times fewer pixels than the screen was showing. Encoding sat on the main thread and made the interface stutter while recording, so it moved to its own queue and now drops frames under pressure rather than queueing them.

And saved videos came out upside down, from a missing coordinate flip that had been there since the first native build and shipped in two of them.

The interface ended up monochrome: white text, grey sliders, inverted toggles. The first version had green text and system-blue controls, which competed with an image made entirely of black and white characters. The controls stopped being decoration and went back to being controls.


Color Safety Lens

The same mistake in color: a plausible constant, never measured, wrong everywhere.

Next