title: Sparkline Pixel Art weight: 60
Sparkline Pixel Art — Rendering Algorithms & Verification
This document captures the architecture, design decisions, optimal-split algorithm, scanning traversal, and testing harness for the Cati sparkline rendering mode.
1. Overview & Modes
Sparkline mode displays scalar gradients and pixel grids in the terminal by
mapping each terminal cell’s 4×8 source block to the Unicode glyph and two
colours that minimise reconstruction error.
Cati provides two sparkline-family modes:
| Mode | Visual Representation | Growth Direction | Character Set |
|---|---|---|---|
Vertical (spark/vert) | ▂▃▄▅▆▇█ | Bottom-to-Top (Upward) | U+2581 – U+2588 |
Quad (spark/quad) | vertical spark blocks plus ▘▝▖▗▀▄▌▐▚▞▛▜▙▟█ | Best 2D mask | fractional block + quad block candidates |
spark/quad is the only spark mode currently exposed in the main interactive
render-mode cycle. spark/vert remains in the library and test suite as a
useful scalar baseline.
Removed Modes
Earlier versions included spark/upper, spark/right, and spark/left.
spark/upper and spark/right were redundant foreground/background inversions.
spark/left was removed because it produced weak visual results under the
current 4×8 sparkline geometry. Horizontal 1/8 blocks need an 8×8 base grid
to be represented as cleanly as vertical eighths.
2. The Optimal Split Algorithm
To render a terminal cell, Cati analyzes its corresponding source pixel block of size $W_c \times H_c$. In spark/vert, it selects the character index bestK (0..7) and colors (barColor and emptyColor) that minimize the Total Squared Error (SSE) between the reconstructed cell and the source pixels.
For each possible split level ci (0 to 7):
- Division: The cell’s pixels are split into two regions: the “bar” (covering $\frac{ci+1}{8}$ of the cell) and the “empty” space (covering the remaining $\frac{7-ci}{8}$).
- Color Averaging: The average RGB color of the bar region becomes the candidate
fgAvg, and the average RGB color of the empty region becomes the candidatebgAvg. - Error Calculation: The sum of squared Euclidean distances in RGB space is computed between each pixel in the block and its assigned region’s average color: $$\text{SSE} = \sum_{p \in \text{bar}} (p - fgAvg)^2 + \sum_{p \in \text{empty}} (p - bgAvg)^2$$
- Minimization: The level
ciyielding the lowest SSE is chosen asbestK.
spark/quad Candidate Masks
spark/quad generalizes the same SSE idea from one-dimensional split levels to
two-dimensional candidate masks. For each terminal cell it:
- Evaluates the vertical sparkline masks.
- Evaluates quad, half, full, and space masks on the same
4×8block. - Averages source pixels inside the mask to get foreground colour.
- Averages source pixels outside the mask to get background colour.
- Reconstructs the block and selects the rune with the lowest SSE.
Quad candidates are upsampled to 4×8: each quadrant covers a 2×4 rectangle.
This keeps spark/quad in the sparkline geometry family and avoids changing the
pure quadblock renderer.
Tiebreaker: prefer non-splitting characters
When two candidates have equal primary SSE, a secondary tiebreaker is applied to avoid artifacts on solid-colour regions.
Split penalty is 0 if at most one of (FG colour, BG colour) would emit an
ANSI colour sequence (i.e. at least one region is transparent / empty), and 1
if both regions need a colour sequence. For a fully opaque solid-colour block:
█(full block): the background region is empty (bgN = 0) →bgAvg.A = 0→ only FG sequence needed →splitPenalty = 0▁–▇(partial vertical): both regions are opaque →splitPenalty = 1
So █ wins all ties on uniform blocks, producing clean single-colour output
with one sequence per cell instead of two. For mixed blocks the penalty is
irrelevant because SSE differs.
Transparent-pixel cost is a separate primary-tier mechanism: any transparent
source pixel that falls inside a coloured region accumulates
transparentPixelCost = 3 × 255² per pixel added to the SSE. This forces
candidates that extend colour into transparent rows to lose to candidates that
leave those rows empty, overriding what a pure RGB SSE would prefer.
Half-cell fit: where the transparent rows come from
A terminal char is two stacked half-cells (CellH/2 px each). When the
aspect-preserving scaled height ends mid-cell, imgutil.FitDims snaps the
partial last row to the nearest half-cell boundary so it maps onto a
representable glyph:
- nearer a top half-cell → keep
CellH/2rows of content and append exactlyCellH/2transparent rows (extH = CellH/2). The last char renders as a clean upper-half block▀— FG = content colour, BG region fully transparent so no BG sequence is emitted. - nearer a full cell → round up to a full content cell (
extH = 0); the last char is an ordinary content row.
Snapping to the nearest half-cell (rather than keeping the raw remainder and
padding CellH − rem transparent rows) is essential: a mid-cell remainder such
as 6 content + 2 transparent rows matches no block glyph, so the selector would
fall back to quadrant/diagonal chars (▌ ▚ ▘) whose colour bleeds into the
transparent area — a garbled bottom row in RenderOpts. The snap guarantees
extH ∈ {0, CellH/2}, upholding the half-char transparency invariant.
Resolution-independent — the snap is shared by all render modes. Every mode
is built so that CellW / (AspectX · CellH) = 1/2 (halfblock 1/(1·2), quad
2/(2·2), spark 4/(1·8)), so the continuous display height in char rows,
srcH · cols / (2 · srcW), is identical regardless of mode. FitDims makes the
half-cell decision from that continuous ratio (carried as exact integer
hNum/hDen), not from a height already floored to integer pixels. Flooring
first would discard up to ~½ a char at 2 px/char (halfblock, quad) but almost
nothing at 8 px/char (spark), so the modes would disagree on the bottom-row
geometry for the same source and width (halfblock/quad rendering “too short”).
Deciding in the shared continuous unit makes all modes land on the same rows and
the same bottom-row fill. See TestFitDimsUnifiedGeometry.
3. Pixel Scanning Traversal & Pitfalls
The legacy 1D split logic requires that the pixel array passed to the error minimization function is segmented along the split line. This introduces a critical traversal requirement:
- Vertical Spark Mode (
Vertical): Must scan pixels in row-major order (row 0, row 1, …, row H-1). A horizontal split boundary in 1D then maps to a horizontal boundary dividing the top and bottom rows of the cell block. - Quad Combo Mode (
Quad): Uses explicit 2D masks instead of scan-order-dependent splits. - Cropped image bounds: Interactive panning passes cropped
SubImagevalues into the renderer. These images may have non-zeroBounds().Min. Sparkline sampling must addb.Min.X/b.Min.Ywhen derivingx0,x1,y0, andy1; sampling from relative(0,0)coordinates reads out-of-bounds black pixels and makes the background appear to pan while the image stays pinned. - Rendering reconstruction:
sparkline.RenderToImagemust share the same cell selection and mask semantics asRenderOpts. The app uses it for SSIM and other quality metrics, so changing glyph masks requires updating both ANSI rendering and image reconstruction together. - Display-size contract: The
4×8sparkline footprint is a renderer-local glyph grid, not permission to shrink the visible terminal cell rectangle. Interactive viewport construction expands spark crops to the footprint required by the sharedsrc px/cellzoom model, then validates the emitted cell size. A small32×32source at fit/1:1 must render as32×16cells, not silently become8×4cells just because one spark cell analyzes a4×8block.
Warning
Reintroducing horizontal 1/8 block modes under
4×8geometry will be approximate. Use an8×8spark-family geometry first if exact horizontal eighths become important.
4. Verification & The Test Helper Suite
The testhelper package (internal/sparkline/testhelper/) provides automated
validation and visualization of all Cati renderers. It exposes three generator
functions that create source images on the fly so no static binaries need to be
committed to the repo for these test cases.
The renderer now has worker-aware copies of RenderOpts and RenderToImage.
The serial functions remain the baseline implementation; the worker copies are
used by the CLI when -j/--jobs > 1 so the existing output path stays stable
while the parallel path is isolated for comparison and consolidation later.
The spark glyph candidate tables are also prebuilt once, and the mask lookup
for reconstructed cells is kept map-free, so the hot path does not rebuild
per-cell helper state.
When the source or destination is already *image.RGBA, the renderer uses
direct pixel access instead of generic image.Color sampling/writes, which
keeps the benchmarked path allocation-free in cell selection and nearly flat in
image reconstruction.
Current sparkline-family modes are spark/vert, spark/quad, spark/sextant,
and spark/best. The shipped sextant renderer is separate and kept
intentionally narrow as sextant/2x3 (xs). The candidate-scoring sparkline
mode is:
spark/bestexhaustively scores the combined quad + sextant candidate set.
(spark/geom, a cheap heuristic that picked between quad and sextant
candidates, was removed — spark/best covers the same candidate space at higher
quality.)
Generator functions
| Function | What it produces | Located under |
|---|---|---|
GenerateGradients | Horizontal + vertical blue→yellow gradients at 20×20, 4×4, 2×2, 1×1 | testdata/demo_horiz_NxN/, testdata/demo_verti_NxN/ |
GenerateFixtures | Solid-red 4×4 regression fixture | testdata/solid_red_4x4/ |
GenerateGeometrics | Four 20×20 geometric images (see below) | testdata/demo_*_20x20/ |
Geometric images (GenerateGeometrics):
| Subfolder | Description | Colours |
|---|---|---|
demo_diag_20x20 | 45° diagonal split (top-left vs bottom-right) | red / blue |
demo_circle_20x20 | Filled disc, radius 8, centred at (9.5, 9.5) | yellow / blue |
demo_checker_20x20 | Checkerboard with 4×4 px cells | red / blue |
demo_cross_20x20 | 4-pixel-wide cross centred on image | yellow / blue |
Pure saturated colours give each algorithm unambiguous ground truth at every cell boundary: a correct renderer must produce the source colour with no bleed across a hard edge.
Golden comparison
TestGoldenRenders (in cmd/golden_render_test.go) runs every combination of
(source image, char width, algorithm) and compares against a stored PNG.
Each golden is stored at a shared per-character block size derived from the LCM of all registered render modes’ cell geometries. For the current four modes the block is 12×24 px/char (aspect ratio 1:2, matching a real terminal cell):
| mode | cell W×H | kX, kY | block |
|---|---|---|---|
| halfblock | 1×2 | 12×12 | 12×24 ✓ |
| quad | 2×2 | 6×12 | 12×24 ✓ |
| spark | 4×8 | 3× 3 | 12×24 ✓ |
| sextant | 2×3 | 6× 8 | 12×24 ✓ |
Every coarser-resolution algorithm reaches the canvas by integer pixel
replication only — no NN resample. TestUnrepeatLossless asserts this
invariant: unrepeat(upscale(native)) == native for every mode.
TestGoldenBlockIntegerFactors asserts that blockW % CellW == 0 and
blockH % CellH == 0 for all modes.
Adding a new render mode with a different cell geometry will automatically
enlarge the block (computed by goldenCharBlock() from the live registry) so
that all integer-replication invariants are preserved.
TestCLIRender (in cmd/cli_render_test.go) does the same for ANSI terminal
output, storing .ansi golden files.
Run with -update to regenerate all goldens:
go test ./cmd/... -update
Interactive demo table
make demo-widths
make demo-darth
make demo-solder DEMO_WIDTH=80 DEMO_STEPS=3
Runs scripts/demo_widths.go (build-tag ignore, excluded from normal builds)
and prints demo renders in terminal tables. Multi-image runs group by render
mode with one image per column. Single-image runs group by image with
halfblock, quad/splithalf, and spark/quad side by side. -w selects the
maximum render width and -n selects how many 80% downscale steps to show
(default 2). Useful for a quick visual sanity check of all render modes after
algorithm changes.