Metadata-Version: 2.4
Name: sextants
Version: 0.5.0
Summary: Convert raster images to teletext / viewdata block-mosaic graphics via an optimal per-row dynamic-programming solver
Keywords: teletext,viewdata,videotex,mode7,saa5050,mosaic,sextants,bbc-micro
Author: Robert Smallshire
Author-email: Robert Smallshire <robert@smallshire.org.uk>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Classifier: Topic :: Multimedia :: Graphics
Requires-Dist: numpy>=2.0
Requires-Dist: pillow>=10.0
Requires-Dist: click>=8.1
Requires-Dist: tqdm>=4.66
Requires-Dist: numba>=0.61 ; extra == 'fast'
Requires-Python: >=3.13
Project-URL: Homepage, https://github.com/rob-smallshire/sextants
Provides-Extra: fast
Description-Content-Type: text/markdown

<!--
  This file is generated by scripts/generate_readme.py from
  scripts/readme_template.md.j2. Do not edit README.md by hand — edit the
  template and the showcase spec (scripts/showcase.toml), then run:
    uv run --group docs python scripts/generate_readme.py
-->
# 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`](LICENSE) for the full notice.

[image2mode7 / image2teletext]: https://github.com/kieranhj/image2mode7

## 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](https://commons.wikimedia.org/).

**The National Parks Preserve Wild Life** — `graphic` preset

![The National Parks Preserve Wild Life](gallery/national-parks-wildlife.png)

<sub>Source: J. Hirt for Work Projects Administration Federal Art Project, New York City, *The National Parks Preserve Wild Life* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:The_national_parks_preserve_wild_life,_WPA_poster,_ca._1938.jpg).</sub>

**Yellowstone National Park** — `graphic` preset

![Yellowstone National Park](gallery/yellowstone.png)

<sub>Source: National Park Service, *Yellowstone National Park* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Yellowstone_Natl_Park_poster_1938.jpg).</sub>

**Zion National Park** — `graphic` preset

![Zion National Park](gallery/zion.png)

<sub>Source: National Park Service, *Zion National Park* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Zion_National_Park_poster_1938.jpg).</sub>

**Lassen Volcanic National Park** — `graphic` preset

![Lassen Volcanic National Park](gallery/lassen-volcanic.png)

<sub>Source: National Park Service, *Lassen Volcanic National Park* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Lassen_Volcanic_Natl_Park_poster_1938.jpg).</sub>

### 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

![The Blue Marble (Earth from Apollo 17)](gallery/blue-marble.png)

<sub>Source: NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans, *The Blue Marble (Earth from Apollo 17)* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg).</sub>

**Jupiter** — `photo` preset

![Jupiter](gallery/jupiter.png)

<sub>Source: NASA, ESA, and A. Simon (Goddard Space Flight Center), *Jupiter* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Jupiter_and_its_shrunken_Great_Red_Spot.jpg).</sub>

### Fragments

Smaller-than-a-page rectangles from `convert_fragment` (see below):

**an 18×22 tile (CONTAIN fit)** — `graphic` preset, `18×22` `contain`

![an 18×22 tile (CONTAIN fit)](gallery/wildlife-tile.png)

<sub>Source: J. Hirt for Work Projects Administration Federal Art Project, New York City, *The National Parks Preserve Wild Life* (public domain) — [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:The_national_parks_preserve_wild_life,_WPA_poster,_ca._1938.jpg).</sub>

## Install

```sh
uv add sextants           # or: pip install sextants
```

Optional numba acceleration for the DP solver:

```sh
uv add "sextants[fast]"
```

## Library usage

```python
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`](src/sextants/options.py) (tone, colour, palette, resize) and
[`SolveOptions`](src/sextants/options.py) (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:

```python
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`](src/sextants/options.py) 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

```sh
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](http://edit.tf). Fragment mode writes the raw `W×H` tile
unless `--place`/`--preview`/`--url` ask for a full page.

## Development

```sh
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`):

```sh
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`](LICENSE). Original algorithm © 2020 Kieran Connell;
packaging and modifications © 2026 Robert Smallshire. Showcase source images are
public domain, via Wikimedia Commons.
