sextants
Convert raster images (JPEG, PNG, …) to teletext / viewdata block-mosaic graphics — the 40×25 character page rendered by the SAA5050 and its relatives (BBC Micro Mode 7, Ceefax/Oracle teletext, Prestel viewdata, CEPT videotex).
The name comes from Unicode's own term for the 2×3 sub-pixel mosaic cell these displays draw — the sextants of the "Symbols for Legacy Computing" block (U+1FB00…). Each cell is six sub-pixels sharing a two-colour sub-palette, and a colour change is an in-band control code that costs a whole cell. Encoding an image well is therefore a constrained optimisation, not a simple resample — which is what this library does, row by row, with an optimal dynamic-programming solver.
Attribution
sextants is a derivative work of image2mode7 / image2teletext by
Kieran Connell. The conversion algorithm at its heart — the per-row
dynamic-programming solver, the sub-pixel error model, and the preprocessing
pipeline — is Kieran's work, used here under its MIT licence. This project
repackages that code as an installable, src-layout Python library with a split
module structure, a Click CLI, and a test suite, and is the basis for further
development. Please see LICENSE for the full notice.
Gallery
Each pair shows the source image (left) and the sextants teletext rendering (right) — a 40×25 page of block mosaics from the eight-colour palette. Source images are public domain, via Wikimedia Commons.
The National Parks Preserve Wild Life — graphic preset
Source: J. Hirt for Work Projects Administration Federal Art Project, New York City, The National Parks Preserve Wild Life (public domain) — Wikimedia Commons.
Yellowstone National Park — graphic preset
Source: National Park Service, Yellowstone National Park (public domain) — Wikimedia Commons.
Zion National Park — graphic preset
Source: National Park Service, Zion National Park (public domain) — Wikimedia Commons.
Lassen Volcanic National Park — graphic preset
Source: National Park Service, Lassen Volcanic National Park (public domain) — Wikimedia Commons.
Photographs
Continuous-tone photographs are harder — teletext has no in-between shades and no skin or earth tones — but bold, high-contrast subjects still read well:
The Blue Marble (Earth from Apollo 17) — photo preset
Source: NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans, The Blue Marble (Earth from Apollo 17) (public domain) — Wikimedia Commons.
Jupiter — photo preset
Source: NASA, ESA, and A. Simon (Goddard Space Flight Center), Jupiter (public domain) — Wikimedia Commons.
Fragments
Smaller-than-a-page rectangles from convert_fragment (see below):
an 18×22 tile (CONTAIN fit) — graphic preset, 18×22 contain
Source: J. Hirt for Work Projects Administration Federal Art Project, New York City, The National Parks Preserve Wild Life (public domain) — Wikimedia Commons.
Install
uv add sextants # or: pip install sextants
Optional numba acceleration for the DP solver:
uv add "sextants[fast]"
Library usage
from pathlib import Path
from PIL import Image
from sextants import convert, convert_image, render_preview
from sextants import PreprocessOptions, SolveOptions
# Preset-aware entry point: a named bundle configures the whole pipeline.
page = convert(Image.open("photo.jpg"), preset="photo") # 1000-byte teletext page
Path("photo.bin").write_bytes(page) # load at &7C00 on a BBC Micro
render_preview(page).save("preview.png") # what it looks like on-screen
# Or drive every knob directly with the two typed option groups.
page = convert_image(
"logo.png",
preprocess=PreprocessOptions(quant_colors=8, snap=40, saturation=2.0),
solve=SolveOptions(edge_weight=3.0),
)
convert(image, preset=None, *, preprocess=None, solve=None) is the preset-aware
entry point; convert_image(image, preprocess=None, solve=None) takes the options
directly. Configuration lives in two frozen, validated dataclasses —
PreprocessOptions (tone, colour, palette, resize) and
SolveOptions (the per-row solver and its error
metric). Passing preprocess= or solve= to convert replaces that whole
stage's options; the preset's other stage still applies. Presets bundle sensible
options for common sources:
| Preset | For |
|---|---|
photo |
Portraits, landscapes, general photos |
clean |
Safe universal default; light denoise + snap for unknown/BBS images |
smooth |
Noisy JPEGs, soft gradients |
vivid / graphic / flat |
Punchy colour, logos/cartoons, bold posterised |
retro / art |
Authentic Ceefax look, hand-crafted teletext-art look |
level1 |
Source is already a Mode 7 / Level 1 teletext image |
dark / tv / crt |
Exposure lift; LCD (PAR 1.2); CRT (PAR 1.22) |
Fragments (sub-page rectangles)
convert_fragment produces a rectangle smaller than a full page — for tiling,
icons, or composing several images onto one page:
from sextants import convert_fragment, Fit
frag = convert_fragment("logo.png", width=12, height=8, fit=Fit.CONTAIN)
frag.width, frag.height # actual size (CONTAIN may shrink it)
page = bytearray(frag.to_page()) # centred on a blank 40x25 page, or…
frag.blit_into(page, col=5, row=3) # …composite at a chosen cell
A fragment is self-contained: column 0 of every row is a leading graphics-colour
control code and the last column is a trailing reset, so it drops onto a page
without its colour bleeding into neighbours (hence width includes those two
columns — minimum 4, so at least two content cells; minimum height 1). The
Fit mode controls aspect: CONTAIN preserves aspect
and may return a smaller rectangle, STRETCH fills the exact size (distorting),
and PAD letterboxes/pillarboxes to the exact size with a background colour.
Command line
sextants photo.jpg -o photo.bin --preset photo
sextants photo.jpg --preview preview.png --url # also print an edit.tf URL
# A 12x8 fragment; writes the raw 96-byte tile (CONTAIN may return fewer rows):
sextants logo.png --width 12 --height 8 --fit contain -o tile.bin
# Place it onto a full page at column 5, row 3 (writes 1000 bytes):
sextants logo.png --width 12 --height 8 --place 5,3 -o page.bin
sextants --help
The default output is a raw 1000-byte page (25 rows × 40 bytes of teletext
character codes). It loads directly at &7C00 on a BBC Micro, or paste the
--url into edit.tf. Fragment mode writes the raw W×H tile
unless --place/--preview/--url ask for a full page.
Development
uv sync # create the environment
uv run pytest # run the test suite
The README and its gallery are generated. After changing the showcase spec
(scripts/showcase.toml) or the template (scripts/readme_template.md.j2):
uv run --group docs python scripts/fetch_sources.py # download sources (once)
uv run --group docs python scripts/generate_readme.py # convert + rebuild README.md
Licence
MIT — see LICENSE. Original algorithm © 2020 Kieran Connell;
packaging and modifications © 2026 Robert Smallshire. Showcase source images are
public domain, via Wikimedia Commons.
Metadata
Release files for sextants 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sextants-0.5.0.tar.gz | 38.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sextants-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 83.7 kB
Release files / sextants-0.5.0.tar.gz
| Download URL | sextants-0.5.0.tar.gz |
|---|---|
| Size | 38.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ec95f445349847a624eaec0f2eb00b4aa05cb31cb5242819849ab746167185a6
|
|
BLAKE2b-256 checksum How to use checksums |
c4a7e0b0be315a20c9e8d1a5984044aea319c564e6cca0a4d2665c63e3086d75
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / sextants-0.5.0-py3-none-any.whl
| Download URL | sextants-0.5.0-py3-none-any.whl |
|---|---|
| Size | 45.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
86dd18c60ca7eb5e87769c292566b11e837733fccfb9cfcc4ff5248be0c2d8f0
|
|
BLAKE2b-256 checksum How to use checksums |
d0a275f0d3dc7a4357edc4dc296e2da09646c2f645dd993dbafbe2bb018034e8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|