Character Encodings¶
This guide explains how each of dapple's seven renderers encodes bitmap pixels into terminal output. Understanding the encoding helps you choose the right renderer and tune its parameters.
Braille Encoding¶
Range: U+2800--U+28FF (256 codepoints) Cell: 2 wide x 4 tall = 8 binary pixels per character
Unicode braille characters encode a 2x4 dot grid directly into the codepoint. The base character is U+2800 (blank braille pattern). Each of the 8 dots corresponds to one bit:
Position: Bit index:
col 0 col 1 col 0 col 1
[0] [3] bit 0 bit 3
[1] [4] bit 1 bit 4
[2] [5] bit 2 bit 5
[6] [7] bit 6 bit 7
The codepoint is U+2800 + (sum of set bits). For example, dots 1 and 4 set bits 0 and 3, giving U+2800 + 1 + 8 = U+2809.
The algorithm¶
BRAILLE_BASE = 0x2800
# Mapping: (row, col) -> bit index
DOT_MAP = [
(0, 0, 0), (1, 0, 1), (2, 0, 2), (3, 0, 6),
(0, 1, 3), (1, 1, 4), (2, 1, 5), (3, 1, 7),
]
def region_to_braille(region_4x2, threshold=0.5):
"""Convert a 4x2 pixel region to a braille character."""
code = 0
for row, col, bit in DOT_MAP:
if region_4x2[row, col] > threshold:
code |= 1 << bit
return chr(BRAILLE_BASE + code)
Each pixel is a binary decision: above threshold = dot on, below = dot off. This means braille output is inherently binary. To encode grayscale information, use preprocessing (dithering) before rendering, or use braille's color modes to tint each character.
Color modes¶
"none": plain Unicode characters, no escape codes. Works everywhere."grayscale": 256-color ANSI foreground (codes 232--255, 24 gray levels). The average brightness of the 2x4 region sets the gray level."truecolor": 24-bit ANSI foreground. If RGB colors are available, the average color of the region is used. Otherwise, falls back to gray.
Quadrants and Sextants¶
The two-color problem¶
Block characters split each cell into sub-regions. A 2x2 cell has 4 sub-pixels, giving 2^4 = 16 possible patterns. A 2x3 cell has 6 sub-pixels, giving 2^3 = 64 possible patterns. But each character position can display at most two colors: foreground and background.
The renderer must decide which pixels are "foreground" and which are "background," then pick the two colors that best represent the region.
Quadrants: 16 patterns¶
The 16 quadrant block characters use bit positions TL=8, TR=4, BL=2, BR=1:
Pattern 0b0000: " " (empty) Pattern 0b1000: "▘" (top-left)
Pattern 0b0001: "▗" (bottom-right) Pattern 0b1001: "▚" (diagonal)
Pattern 0b0010: "▖" (bottom-left) Pattern 0b1010: "▌" (left half)
Pattern 0b0011: "▄" (lower half) Pattern 0b1011: "▙" (missing TR)
Pattern 0b0100: "▝" (top-right) Pattern 0b1100: "▀" (upper half)
Pattern 0b0101: "▐" (right half) Pattern 0b1101: "▜" (missing BL)
Pattern 0b0110: "▞" (diagonal) Pattern 0b1110: "▛" (missing BR)
Pattern 0b0111: "▟" (missing TL) Pattern 0b1111: "█" (full block)
Sextants: 64 patterns¶
Sextant characters (U+1FB00--U+1FB3B) encode a 2x3 grid. Three special patterns use existing block characters:
| Pattern | Character | Description |
|---|---|---|
| 0 (empty) | (space) |
All cells off |
| 21 (left half) | ▌ (U+258C) |
Cells 0, 2, 4 |
| 42 (right half) | ▐ (U+2590) |
Cells 1, 3, 5 |
| 63 (full) | █ (U+2588) |
All cells on |
The remaining 60 patterns are assigned to U+1FB00 through U+1FB3B, skipping the two special values.
Foreground/background color selection¶
The algorithm for both quadrants and sextants is the same:
def render_block(block_pixels, block_colors=None):
"""Render a single 2x2 or 2x3 block."""
# 1. Compute per-pixel luminance
if block_colors is not None:
lum = 0.299 * R + 0.587 * G + 0.114 * B
else:
lum = block_pixels # grayscale bitmap IS the luminance
# 2. Find brightest and darkest pixels
fg_lum = lum.max()
bg_lum = lum.min()
# 3. Threshold at the midpoint
threshold = (fg_lum + bg_lum) / 2
# 4. Build bit pattern
pattern = 0
for each pixel:
if lum[pixel] > threshold:
pattern |= bit_weight[pixel]
# 5. Select foreground color from brightest pixel,
# background color from darkest pixel
fg_color = color_of_brightest_pixel
bg_color = color_of_darkest_pixel
# 6. Emit ANSI: set fg, set bg, write character
return f"{fg_escape}{bg_escape}{CHAR_TABLE[pattern]}"
Vectorized rendering¶
In practice, dapple does not loop pixel-by-pixel. The bitmap is reshaped using numpy operations:
# Reshape (H, W) into (rows, cols, pixels_per_cell)
# For quadrants: reshape to (rows, 2, cols, 2) then transpose
block_data = bitmap[:rows*2, :cols*2].reshape(rows, 2, cols, 2)
block_data = block_data.transpose(0, 2, 1, 3).reshape(rows, cols, 4)
# Vectorized max/min/threshold across all blocks simultaneously
fg = block_data.max(axis=2)
bg = block_data.min(axis=2)
thresh = (fg + bg) / 2
patterns = ((block_data > thresh[..., None]) * BIT_WEIGHTS).sum(axis=2)
This processes the entire image in a handful of numpy calls rather than a Python loop per pixel.
ASCII¶
Cell: 1 wide x 2 tall = 2 pixels per character
Character ramp: .:-=+*#%@ (10 levels, dark to bright)
The simplest encoding. Two vertically adjacent pixels are averaged (for aspect ratio correction), and the resulting brightness is mapped to a character from the ramp.
CHARSET = " .:-=+*#%@"
def brightness_to_char(brightness, charset=CHARSET):
"""Map a 0.0-1.0 brightness to a character."""
index = int(brightness * (len(charset) - 0.001))
return charset[index]
The 1x2 cell compensates for terminal characters being roughly twice as tall as they are wide. Without this, images would appear vertically stretched.
ASCII output uses no escape codes and works on any terminal, any font, any connection. This makes it the universal fallback.
Available ramps¶
| Name | Characters | Levels |
|---|---|---|
CHARSET_STANDARD |
.:-=+*#%@ |
10 |
CHARSET_DETAILED |
.'`^",:;Il!i><~+_-?][}{1)(|\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$ |
70 |
CHARSET_BLOCKS |
░▒▓█ |
5 |
CHARSET_SIMPLE |
.oO@ |
5 |
More levels in the ramp means finer brightness gradations. The detailed ramp produces smoother gradients but requires a font where all 70 characters have distinguishable visual density.
Sixel¶
Protocol: DEC VT340 (1984) Cell: 1x1 (true pixel output) Color: palette-based, up to 256 colors
Sixel encodes bitmaps as escape sequences that the terminal interprets as pixel data. The name comes from "six elements" -- each character encodes a column of 6 vertical pixels.
Encoding¶
A sixel character is in the range 0x3F (?) to 0x7E (~). The character value minus 0x3F gives a 6-bit pattern where bit 0 is the top pixel and bit 5 is the bottom:
Character = chr(0x3F + pattern)
pattern bits:
bit 0 = top pixel
bit 1
bit 2
bit 3
bit 4
bit 5 = bottom pixel
Per-color painting model¶
Sixel does not encode pixels left-to-right like a framebuffer. Instead, for each 6-pixel-tall band:
- Select a color (
#n). - Paint all columns that use that color in this band.
- Carriage return (
$) to go back to the start of the band. - Select the next color and repeat.
- Line feed (
-) to advance to the next 6-pixel band.
This per-color painting model means each band is painted multiple times, once per active color.
Run-length encoding¶
Repeated sixel characters can be compressed:
dapple uses RLE when a run exceeds 3 characters.
Palette definition¶
Colors are defined at the start of the sequence:
Full sequence structure¶
ESC P q <- DCS start
#0;2;0;0;0 <- define color 0 as black
#1;2;100;0;0 <- define color 1 as red
...
#0 ?~?~?~ $ <- paint color 0 in band, carriage return
#1 ~~~??? $ <- paint color 1 in band, carriage return
- <- next 6-pixel band
...
ESC \ <- string terminator
Quantization¶
dapple uses uniform color quantization: each RGB channel is divided into cbrt(max_colors) levels, producing a uniform color cube. For grayscale, up to 64 gray levels are used.
Kitty¶
Protocol: Kitty graphics protocol (modern) Cell: 1x1 (true pixel output) Color: 24-bit RGB (or 8-bit grayscale via PNG)
The Kitty protocol transmits image data as base64-encoded payloads inside escape sequences. It supports PNG, raw RGB, and raw RGBA formats.
Escape sequence structure¶
Key parameters:
| Key | Value | Meaning |
|---|---|---|
a |
T |
Action: transmit and display |
f |
100 |
Format: PNG |
f |
24 |
Format: raw RGB |
f |
32 |
Format: raw RGBA |
o |
z |
Compression: zlib |
s |
W |
Source width (for raw formats) |
v |
H |
Source height (for raw formats) |
c |
N |
Display width in columns |
r |
N |
Display height in rows |
m |
1 |
More data chunks follow |
m |
0 |
Last (or only) chunk |
Chunking¶
Base64 data is split into chunks of up to 4096 bytes. The first chunk carries all parameters; continuation chunks only carry m=<0|1>:
# First chunk
ESC_G a=T,f=100,m=1; <base64 chunk 1> ESC \
# Continuation
ESC_G m=1; <base64 chunk 2> ESC \
# Final chunk
ESC_G m=0; <base64 chunk N> ESC \
PNG vs raw formats¶
- PNG (
f=100): compressed, smallest payload. dapple uses PIL if available, otherwise a minimal built-in PNG encoder (zlib-compressed, no filtering). - RGB (
f=24): uncompressed pixel data,W * H * 3bytes. Can be zlib-compressed witho=z. - RGBA (
f=32): same as RGB but with alpha channel,W * H * 4bytes.
PNG is the default because it produces the smallest output and is universally supported by Kitty-compatible terminals.
Fingerprint¶
Cell: configurable (default 8x16) Method: glyph correlation matching Color: none (grayscale only)
Fingerprint is an experimental renderer that finds the Unicode character whose rendered appearance most closely matches each region of the input bitmap.
The algorithm¶
def find_best_glyph(input_region, glyph_bitmaps, glyphs):
"""Find the glyph that best matches the input region."""
# input_region: flattened (cell_width * cell_height,) vector
# glyph_bitmaps: (N, cell_width * cell_height) array
# Compute MSE between input and each glyph
diff = input_region - glyph_bitmaps # broadcasts to (N, pixels)
distances = (diff ** 2).mean(axis=1) # (N,) MSE per glyph
# Pick the closest glyph
best_idx = distances.argmin()
return glyphs[best_idx]
Glyph preparation¶
On first use, each candidate character is rendered to a small bitmap using PIL's ImageDraw.text. The glyph bitmap has white background with black text, inverted so ink = 1.0 and background = 0.0. All glyph bitmaps are stacked into a single numpy array for vectorized distance computation.
The glyph cache is keyed by (glyph_set, cell_width, cell_height, font_path) and persists for the process lifetime.
Font dependence¶
The output is determined by how the font renders each character at the given cell size. Different fonts produce different "best match" selections for the same input. A monospace font with clean, distinct glyph shapes tends to produce better results than a proportional font.
dapple tries to load DejaVu Sans Mono or Consolas, falling back to PIL's built-in bitmap font.
Distance metrics¶
- MSE (mean squared error):
mean((input - glyph)^2). Penalizes large differences heavily. Default. - MAE (mean absolute error):
mean(|input - glyph|). More tolerant of outliers.
Vectorized matching¶
Rather than looping over each cell, dapple extracts all cells at once via numpy reshape/transpose, then computes all distances in a single broadcast operation:
# regions: (R, P) -- R cells, P pixels each
# glyph_bitmaps: (G, P) -- G glyphs, P pixels each
diff = regions[:, None, :] - glyph_bitmaps[None, :, :] # (R, G, P)
distances = (diff ** 2).mean(axis=2) # (R, G)
best_indices = distances.argmin(axis=1) # (R,)
This makes fingerprint rendering practical even for large images and extended glyph sets.