Renderers¶
dapple ships seven renderers. Each converts a bitmap into terminal output using a different encoding strategy, from pure ASCII text to true pixel protocols.
Overview¶
| Renderer | Cell Size | Pixels/Char | Color | Requirements |
|---|---|---|---|---|
braille |
2 x 4 | 8 | none / grayscale / truecolor | Unicode braille support |
quadrants |
2 x 2 | 4 | 256-color or 24-bit RGB | Unicode block elements |
sextants |
2 x 3 | 6 | 256-color or 24-bit RGB | Unicode 13.0+ (sextant block elements) |
ascii |
1 x 2 | 2 | none | Any terminal |
sixel |
1 x 1 | 1 (pixel) | palette (up to 256 colors) | xterm -ti vt340, mlterm, foot, WezTerm |
kitty |
1 x 1 | 1 (pixel) | 24-bit RGB | Kitty, WezTerm, Ghostty |
fingerprint |
8 x 16 | 128 | none | PIL (pillow) for glyph rendering |
Cell size is width x height in pixels. A braille character covers a 2-wide by 4-tall region of the input bitmap. Smaller cells mean higher effective resolution relative to the terminal grid.
Choosing a Renderer¶
Is this running over SSH or in CI?
YES --> braille or ascii
NO --> Does the terminal support Kitty graphics protocol?
YES --> kitty
NO --> Does the terminal support Sixel?
YES --> sixel
NO --> Do you want color?
YES --> quadrants or sextants
NO --> braille
(or fingerprint for artistic output)
For automated selection, use auto_renderer() from dapple.auto. See the Auto-Detection guide.
The Renderer Protocol¶
All renderers implement a runtime_checkable Protocol:
from typing import Protocol, TextIO, runtime_checkable
import numpy as np
from numpy.typing import NDArray
@runtime_checkable
class Renderer(Protocol):
@property
def cell_width(self) -> int: ...
@property
def cell_height(self) -> int: ...
def render(
self,
bitmap: NDArray[np.floating],
colors: NDArray[np.floating] | None = None,
*,
dest: TextIO,
) -> None: ...
cell_width/cell_height: how many bitmap pixels each output character covers.render: writes output directly todest. Does not return a string.
Stream-Based Output¶
Renderers write to a TextIO stream rather than returning strings. This design enables:
- Streaming: output begins before the full image is processed.
- Memory efficiency: no intermediate string allocation for large outputs.
- Flexible destinations: stdout, files, StringIO, network sockets.
import sys
from io import StringIO
from dapple import Canvas, braille
canvas = Canvas(bitmap)
# Direct to stdout
braille.render(bitmap, colors=None, dest=sys.stdout)
# Capture to string
buf = StringIO()
braille.render(bitmap, colors=None, dest=buf)
text = buf.getvalue()
# Usually you go through Canvas.out() instead:
canvas.out(braille) # stdout
canvas.out(braille, "output.txt") # file
canvas.out(braille, buf) # stream
Frozen Dataclass Pattern¶
Every renderer is a frozen (immutable) dataclass with a __call__ method that creates a new instance with modified options.
from dapple import braille
# Default instance
braille.threshold # 0.5
braille.color_mode # "none"
# Create a variant
custom = braille(threshold=0.3, color_mode="truecolor")
custom.threshold # 0.3
custom.color_mode # "truecolor"
# Original is unchanged
braille.threshold # still 0.5
This pattern avoids mutable state. Each call returns a new renderer; the default singleton is never modified.
Braille¶
Unicode braille characters (U+2800--U+28FF) encode a 2x4 dot pattern into a single codepoint. Each character represents 8 binary pixels, giving the highest pseudo-pixel density of any character-based renderer.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
threshold |
float\|None |
0.5 |
Brightness cutoff for dot activation. None for auto (uses bitmap mean). |
color_mode |
str |
"none" |
"none", "grayscale" (256-color), or "truecolor" (24-bit RGB) |
Usage¶
from dapple import Canvas, braille
canvas = Canvas(bitmap)
# Plain braille (no color)
canvas.out(braille)
# Lower threshold -- more dots activated
canvas.out(braille(threshold=0.3))
# Auto threshold based on image mean
canvas.out(braille(threshold=None))
# Grayscale tinting (256-color ANSI)
canvas.out(braille(color_mode="grayscale"))
# Full RGB color per character
canvas.out(braille(color_mode="truecolor"))
How it works¶
The bitmap is divided into 4-row by 2-column blocks. Each pixel in the block is compared against the threshold. Activated pixels set bits in the braille codepoint offset (U+2800 + bit pattern). The bit layout follows the Unicode standard:
In color modes, the average brightness or average RGB of each block is used to set the ANSI foreground color for that character.
Quadrants¶
Quadrant block characters use 16 Unicode block elements to represent a 2x2 pixel region. Combined with ANSI foreground and background colors, each character conveys both shape and two distinct tones or colors.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
true_color |
bool |
True |
Use 24-bit RGB (True) or 256-color mode (False) |
grayscale |
bool |
False |
Force grayscale even when RGB colors are available |
Usage¶
from dapple import Canvas, quadrants
canvas = Canvas(bitmap, colors=rgb)
# Default: 24-bit true color
canvas.out(quadrants)
# 256-color mode (wider terminal compatibility)
canvas.out(quadrants(true_color=False))
# Force grayscale
canvas.out(quadrants(grayscale=True))
Character table¶
The 16 quadrant patterns, indexed by a 4-bit value (TL=8, TR=4, BL=2, BR=1):
| Pattern | Char | Description |
|---|---|---|
0000 |
|
Empty |
0001 |
▗ |
Bottom-right |
0010 |
▖ |
Bottom-left |
0011 |
▄ |
Lower half |
0100 |
▝ |
Top-right |
0101 |
▐ |
Right half |
0110 |
▞ |
Diagonal |
0111 |
▟ |
Missing TL |
1000 |
▘ |
Top-left |
1001 |
▚ |
Diagonal |
1010 |
▌ |
Left half |
1011 |
▙ |
Missing TR |
1100 |
▀ |
Upper half |
1101 |
▜ |
Missing BL |
1110 |
▛ |
Missing BR |
1111 |
█ |
Full block |
How it works¶
The bitmap is reshaped into 2x2 blocks. For each block, the renderer computes:
- The brightest and darkest pixel values (or colors).
- A threshold at the midpoint between them.
- A 4-bit pattern from which pixels exceed the threshold.
- ANSI foreground (bright color) and background (dark color) escape codes.
The output is the block character with foreground and background set to the two representative colors of that 2x2 region.
Sextants¶
Sextant block characters (U+1FB00--U+1FB3B plus standard block elements) represent a 2x3 pixel region -- 6 pixels per character. This provides 50% more vertical resolution than quadrants while using the same two-color foreground/background approach.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
true_color |
bool |
True |
Use 24-bit RGB or 256-color mode |
grayscale |
bool |
False |
Force grayscale even when RGB colors are available |
Usage¶
from dapple import Canvas, sextants
canvas = Canvas(bitmap, colors=rgb)
canvas.out(sextants)
canvas.out(sextants(true_color=False))
Requirements¶
Sextant characters were added in Unicode 13.0 (2020). Most modern terminal emulators and fonts support them, but older systems may render them as missing-glyph boxes.
ASCII¶
Maps pixel brightness to ASCII characters using a configurable character ramp. Uses 1x2 pixel sampling (one column wide, two rows tall) to correct for the typical 2:1 height-to-width aspect ratio of terminal characters.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
charset |
str |
" .:-=+*#%@" |
Characters from dark to bright |
invert |
bool |
False |
Invert brightness mapping |
Built-in charsets¶
from dapple.renderers.ascii import (
CHARSET_STANDARD, # " .:-=+*#%@" (10 levels)
CHARSET_DETAILED, # " .'`^\",:;Il!..." (70 levels)
CHARSET_BLOCKS, # " ░▒▓█" (5 levels, Unicode)
CHARSET_SIMPLE, # " .oO@" (5 levels)
)
Usage¶
from dapple import Canvas, ascii
canvas = Canvas(bitmap)
# Default charset
canvas.out(ascii)
# Minimal charset
canvas.out(ascii(charset=" .oO@"))
# Detailed charset for high-resolution output
canvas.out(ascii(charset=CHARSET_DETAILED))
# Inverted (bright background, dark foreground)
canvas.out(ascii(invert=True))
Why 1x2 cells¶
Terminal characters are roughly twice as tall as they are wide. Sampling two rows of pixels per output character keeps the aspect ratio correct. Without this correction, images would appear vertically stretched.
Sixel¶
Sixel is a bitmap graphics protocol from DEC (1984). Each "character" in the output encodes a 1x6 vertical pixel column. The terminal interprets the escape sequence and renders actual pixels. This is a true pixel renderer -- no character approximation.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
max_colors |
int |
256 |
Maximum colors in the quantized palette |
scale |
int |
1 |
Pixel scaling factor (2 = double size) |
Usage¶
from dapple import Canvas, sixel
canvas = Canvas(bitmap, colors=rgb)
# Default
canvas.out(sixel)
# Fewer colors (faster, smaller output)
canvas.out(sixel(max_colors=64))
# Scaled up (each pixel becomes 2x2)
canvas.out(sixel(scale=2))
Supported terminals¶
Sixel is supported by xterm (with -ti vt340 flag), mlterm, foot, WezTerm, mintty, contour, and yaft.
How it works¶
- Colors are quantized to a palette using uniform binning.
- The palette is defined in the DCS (Device Control String) header.
- For each 6-pixel-tall band, pixels are encoded per color: for each color in the palette, a mask is generated and encoded as sixel characters (0x3F + 6-bit pattern).
- Run-length encoding compresses repeated columns.
The output is wrapped in ESC P q ... ESC \ escape sequences.
Kitty¶
The Kitty graphics protocol is a modern standard for inline terminal images. It transmits image data as base64-encoded PNG, RGB, or RGBA, chunked into 4096-byte segments. This is a true pixel renderer with full 24-bit color.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
format |
str |
"png" |
"png", "rgb", or "rgba" |
compression |
bool |
True |
Use zlib compression for raw formats |
columns |
int\|None |
None |
Display width in terminal columns (None = native) |
rows |
int\|None |
None |
Display height in terminal rows (None = native) |
Usage¶
from dapple import Canvas, kitty
canvas = Canvas(bitmap, colors=rgb)
# Default: PNG format
canvas.out(kitty)
# Raw RGB (faster encoding, larger payload)
canvas.out(kitty(format="rgb"))
# Scale to fit 80 columns
canvas.out(kitty(columns=80))
Supported terminals¶
Kitty, WezTerm, Ghostty, and Konsole (partial support).
How it works¶
- The bitmap/colors are encoded as PNG (using PIL if available, otherwise a minimal built-in PNG encoder) or raw RGB/RGBA bytes.
- Raw formats can be zlib-compressed.
- The encoded data is base64-encoded and split into chunks of up to 4096 bytes.
- Each chunk is wrapped in
ESC _G <params>;data ESC \withm=1for continuation chunks andm=0for the final chunk.
Fingerprint¶
An experimental renderer that matches bitmap regions to the visually closest Unicode glyph. Instead of mapping brightness to characters (like ASCII), it pre-renders candidate glyphs to small bitmaps and finds the one with minimum mean squared error against each input region.
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
glyph_set |
str |
"basic" |
"basic", "blocks", "braille", or "extended" |
cell_width |
int |
8 |
Pixels per character horizontally |
cell_height |
int |
16 |
Pixels per character vertically |
metric |
str |
"mse" |
"mse" (mean squared error) or "mae" (mean absolute error) |
font_path |
str\|None |
None |
Path to a TTF/OTF font file for glyph rendering |
Glyph sets¶
| Set | Characters | Description |
|---|---|---|
"basic" |
95 | ASCII printable characters (0x20--0x7E) |
"blocks" |
~150 | Block elements, box drawing, quadrants |
"braille" |
256 | All braille patterns (U+2800--U+28FF) |
"extended" |
~500 | Combination of all the above |
Usage¶
from dapple import Canvas, fingerprint
canvas = Canvas(bitmap)
# Default: ASCII characters, 8x16 cells
canvas.out(fingerprint)
# Block elements for geometric output
canvas.out(fingerprint(glyph_set="blocks"))
# Smaller cells for higher resolution
canvas.out(fingerprint(cell_width=6, cell_height=12))
# Custom font
canvas.out(fingerprint(font_path="/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"))
How it works¶
- On first use, all glyphs in the selected set are rendered to small bitmaps using PIL's text drawing.
- The glyph bitmaps are stacked into a
(N, pixels_per_cell)array and cached. - The input bitmap is divided into cells and each cell is flattened to a vector.
- MSE (or MAE) distances are computed between each input cell and all glyph bitmaps using vectorized numpy operations.
- The glyph with minimum distance is selected for each cell.
The output is font-dependent: the same image rendered with different fonts or cell sizes produces different character selections.
Note: Fingerprint requires pillow for rendering glyph bitmaps. Install with
pip install pillow.
What Dapple Does and Does Not Do¶
dapple handles:
- Bitmap to terminal character conversion
- Color quantization and ANSI escape code generation
- Stream-based output to any TextIO destination
- Composition (hstack, vstack, overlay, crop)
- Preprocessing (contrast, dithering, sharpening, gamma)
dapple does not handle: - Image loading/decoding (that is in adapters, which depend on pillow) - Terminal size detection or automatic resizing to fit the terminal - Cursor positioning or TUI layout - Animation or interactive display - Image format encoding (beyond what is needed for sixel/kitty protocols)
For image loading, use the adapters. For CLI tools that handle resizing and display, see the extras (imgcat, funcat, pdfcat, etc.).