sextants 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sextants-0.1.1/LICENSE ADDED
@@ -0,0 +1,30 @@
1
+ MIT License
2
+
3
+ sextants is a derivative work of "image2mode7" / "image2teletext" by
4
+ Kieran Connell (https://github.com/kieranhj/image2mode7). The image-to-
5
+ teletext conversion algorithm — including the per-row dynamic-programming
6
+ solver, error model, and preprocessing pipeline — originates in that
7
+ project and is used here under the terms of its MIT licence, reproduced
8
+ below. Restructuring, packaging, and subsequent modifications are by the
9
+ sextants authors.
10
+
11
+ Copyright (c) 2020 Kieran Connell
12
+ Copyright (c) 2026 Robert Smallshire
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: sextants
3
+ Version: 0.1.1
4
+ Summary: Convert raster images to teletext / viewdata block-mosaic graphics via an optimal per-row dynamic-programming solver
5
+ Keywords: teletext,viewdata,videotex,mode7,saa5050,mosaic,sextants,bbc-micro
6
+ Author: Robert Smallshire
7
+ Author-email: Robert Smallshire <robert@smallshire.org.uk>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Requires-Dist: numpy>=2.0
14
+ Requires-Dist: pillow>=10.0
15
+ Requires-Dist: click>=8.1
16
+ Requires-Dist: tqdm>=4.66
17
+ Requires-Dist: numba>=0.60 ; extra == 'fast'
18
+ Requires-Python: >=3.11
19
+ Project-URL: Homepage, https://github.com/rob-smallshire/sextants
20
+ Provides-Extra: fast
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sextants
24
+
25
+ Convert raster images (JPEG, PNG, …) to **teletext / viewdata block-mosaic
26
+ graphics** — the 40×25 character page rendered by the SAA5050 and its relatives
27
+ (BBC Micro Mode 7, Ceefax/Oracle teletext, Prestel viewdata, CEPT videotex).
28
+
29
+ The name comes from Unicode's own term for the 2×3 sub-pixel mosaic cell these
30
+ displays draw — the *sextants* of the "Symbols for Legacy Computing" block
31
+ (U+1FB00…). Each cell is six sub-pixels sharing a two-colour sub-palette, and a
32
+ colour change is an in-band control code that costs a whole cell. Encoding an
33
+ image well is therefore a constrained optimisation, not a simple resample —
34
+ which is what this library does, row by row, with an optimal dynamic-programming
35
+ solver.
36
+
37
+ ## Attribution
38
+
39
+ **sextants is a derivative work of [image2mode7 / image2teletext] by
40
+ Kieran Connell.** The conversion algorithm at its heart — the per-row
41
+ dynamic-programming solver, the sub-pixel error model, and the preprocessing
42
+ pipeline — is Kieran's work, used here under its MIT licence. This project
43
+ repackages that code as an installable, `src`-layout Python library with a split
44
+ module structure, a Click CLI, and a test suite, and is the basis for further
45
+ development. Please see [`LICENSE`](LICENSE) for the full notice.
46
+
47
+ [image2mode7 / image2teletext]: https://github.com/kieranhj/image2mode7
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ uv add sextants # or: pip install sextants
53
+ ```
54
+
55
+ Optional numba acceleration for the DP solver:
56
+
57
+ ```sh
58
+ uv add "sextants[fast]"
59
+ ```
60
+
61
+ ## Library usage
62
+
63
+ ```python
64
+ from PIL import Image
65
+ from sextants import convert, render_preview
66
+
67
+ page = convert(Image.open("photo.jpg"), preset="photo") # 1000-byte teletext page
68
+ Path("photo.bin").write_bytes(page) # load at &7C00 on a BBC Micro
69
+
70
+ render_preview(page).save("preview.png") # what it looks like on-screen
71
+ ```
72
+
73
+ `convert(img, preset=None, **overrides)` is the preset-aware entry point;
74
+ `convert_image(...)` exposes every knob directly. Presets bundle sensible
75
+ preprocessing options for common sources:
76
+
77
+ | Preset | For |
78
+ | --- | --- |
79
+ | `photo` | Portraits, landscapes, general photos |
80
+ | `clean` | Safe universal default; light denoise + snap for unknown/BBS images |
81
+ | `smooth` | Noisy JPEGs, soft gradients |
82
+ | `vivid` / `graphic` / `flat` | Punchy colour, logos/cartoons, bold posterised |
83
+ | `retro` / `art` | Authentic Ceefax look, hand-crafted teletext-art look |
84
+ | `level1` | Source is already a Mode 7 / Level 1 teletext image |
85
+ | `dark` / `tv` / `crt` | Exposure lift; LCD (PAR 1.2); CRT (PAR 1.22) |
86
+
87
+ ## Command line
88
+
89
+ ```sh
90
+ sextants photo.jpg -o photo.bin --preset photo
91
+ sextants photo.jpg --preview preview.png --url # also print an edit.tf URL
92
+ sextants --help
93
+ ```
94
+
95
+ The output is a raw 1000-byte page (25 rows × 40 bytes of teletext character
96
+ codes). It loads directly at `&7C00` on a BBC Micro, or paste the `--url` into
97
+ [edit.tf](http://edit.tf).
98
+
99
+ ## Development
100
+
101
+ ```sh
102
+ uv sync # create the environment
103
+ uv run pytest # run the test suite
104
+ ```
105
+
106
+ ## Licence
107
+
108
+ MIT — see [`LICENSE`](LICENSE). Original algorithm © 2020 Kieran Connell;
109
+ packaging and modifications © 2026 Robert Smallshire.
@@ -0,0 +1,87 @@
1
+ # sextants
2
+
3
+ Convert raster images (JPEG, PNG, …) to **teletext / viewdata block-mosaic
4
+ graphics** — the 40×25 character page rendered by the SAA5050 and its relatives
5
+ (BBC Micro Mode 7, Ceefax/Oracle teletext, Prestel viewdata, CEPT videotex).
6
+
7
+ The name comes from Unicode's own term for the 2×3 sub-pixel mosaic cell these
8
+ displays draw — the *sextants* of the "Symbols for Legacy Computing" block
9
+ (U+1FB00…). Each cell is six sub-pixels sharing a two-colour sub-palette, and a
10
+ colour change is an in-band control code that costs a whole cell. Encoding an
11
+ image well is therefore a constrained optimisation, not a simple resample —
12
+ which is what this library does, row by row, with an optimal dynamic-programming
13
+ solver.
14
+
15
+ ## Attribution
16
+
17
+ **sextants is a derivative work of [image2mode7 / image2teletext] by
18
+ Kieran Connell.** The conversion algorithm at its heart — the per-row
19
+ dynamic-programming solver, the sub-pixel error model, and the preprocessing
20
+ pipeline — is Kieran's work, used here under its MIT licence. This project
21
+ repackages that code as an installable, `src`-layout Python library with a split
22
+ module structure, a Click CLI, and a test suite, and is the basis for further
23
+ development. Please see [`LICENSE`](LICENSE) for the full notice.
24
+
25
+ [image2mode7 / image2teletext]: https://github.com/kieranhj/image2mode7
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ uv add sextants # or: pip install sextants
31
+ ```
32
+
33
+ Optional numba acceleration for the DP solver:
34
+
35
+ ```sh
36
+ uv add "sextants[fast]"
37
+ ```
38
+
39
+ ## Library usage
40
+
41
+ ```python
42
+ from PIL import Image
43
+ from sextants import convert, render_preview
44
+
45
+ page = convert(Image.open("photo.jpg"), preset="photo") # 1000-byte teletext page
46
+ Path("photo.bin").write_bytes(page) # load at &7C00 on a BBC Micro
47
+
48
+ render_preview(page).save("preview.png") # what it looks like on-screen
49
+ ```
50
+
51
+ `convert(img, preset=None, **overrides)` is the preset-aware entry point;
52
+ `convert_image(...)` exposes every knob directly. Presets bundle sensible
53
+ preprocessing options for common sources:
54
+
55
+ | Preset | For |
56
+ | --- | --- |
57
+ | `photo` | Portraits, landscapes, general photos |
58
+ | `clean` | Safe universal default; light denoise + snap for unknown/BBS images |
59
+ | `smooth` | Noisy JPEGs, soft gradients |
60
+ | `vivid` / `graphic` / `flat` | Punchy colour, logos/cartoons, bold posterised |
61
+ | `retro` / `art` | Authentic Ceefax look, hand-crafted teletext-art look |
62
+ | `level1` | Source is already a Mode 7 / Level 1 teletext image |
63
+ | `dark` / `tv` / `crt` | Exposure lift; LCD (PAR 1.2); CRT (PAR 1.22) |
64
+
65
+ ## Command line
66
+
67
+ ```sh
68
+ sextants photo.jpg -o photo.bin --preset photo
69
+ sextants photo.jpg --preview preview.png --url # also print an edit.tf URL
70
+ sextants --help
71
+ ```
72
+
73
+ The output is a raw 1000-byte page (25 rows × 40 bytes of teletext character
74
+ codes). It loads directly at `&7C00` on a BBC Micro, or paste the `--url` into
75
+ [edit.tf](http://edit.tf).
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ uv sync # create the environment
81
+ uv run pytest # run the test suite
82
+ ```
83
+
84
+ ## Licence
85
+
86
+ MIT — see [`LICENSE`](LICENSE). Original algorithm © 2020 Kieran Connell;
87
+ packaging and modifications © 2026 Robert Smallshire.
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "sextants"
3
+ version = "0.1.1"
4
+ description = "Convert raster images to teletext / viewdata block-mosaic graphics via an optimal per-row dynamic-programming solver"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ keywords = [
10
+ "teletext",
11
+ "viewdata",
12
+ "videotex",
13
+ "mode7",
14
+ "saa5050",
15
+ "mosaic",
16
+ "sextants",
17
+ "bbc-micro",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Multimedia :: Graphics",
23
+ ]
24
+ dependencies = [
25
+ "numpy>=2.0",
26
+ "pillow>=10.0",
27
+ "click>=8.1",
28
+ "tqdm>=4.66",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Robert Smallshire"
33
+ email = "robert@smallshire.org.uk"
34
+
35
+ [project.optional-dependencies]
36
+ fast = ["numba>=0.60"]
37
+
38
+ [project.scripts]
39
+ sextants = "sextants.cli:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/rob-smallshire/sextants"
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.12.3,<0.13.0"]
46
+ build-backend = "uv_build"
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8.0",
51
+ "bump-my-version>=0.28",
52
+ ]
53
+
54
+ [tool.bumpversion]
55
+ current_version = "0.1.1"
56
+ commit = true
57
+ tag = true
58
+ tag_name = "v{new_version}"
59
+ message = "Bump sextants to {new_version}"
60
+
61
+ [[tool.bumpversion.files]]
62
+ filename = "pyproject.toml"
63
+ regex = true
64
+ search = '^version = "{current_version}"'
65
+ replace = 'version = "{new_version}"'
66
+
67
+ [[tool.bumpversion.files]]
68
+ filename = "src/sextants/__init__.py"
69
+ search = '__version__ = "{current_version}"'
70
+ replace = '__version__ = "{new_version}"'
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "sextants"
3
+ version = "0.1.1"
4
+ description = "Convert raster images to teletext / viewdata block-mosaic graphics via an optimal per-row dynamic-programming solver"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Robert Smallshire", email = "robert@smallshire.org.uk" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ keywords = ["teletext", "viewdata", "videotex", "mode7", "saa5050", "mosaic", "sextants", "bbc-micro"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Multimedia :: Graphics",
17
+ ]
18
+ dependencies = [
19
+ "numpy>=2.0",
20
+ "pillow>=10.0",
21
+ "click>=8.1",
22
+ "tqdm>=4.66",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ fast = ["numba>=0.60"]
27
+
28
+ [project.scripts]
29
+ sextants = "sextants.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/rob-smallshire/sextants"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.12.3,<0.13.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=8.0",
41
+ "bump-my-version>=0.28",
42
+ ]
43
+
44
+ # Releasing: `uv run bump-my-version bump patch|minor|major` edits the version
45
+ # in both places it lives, commits and tags `v<version>`.
46
+ [tool.bumpversion]
47
+ current_version = "0.1.1"
48
+ commit = true
49
+ tag = true
50
+ tag_name = "v{new_version}"
51
+ message = "Bump sextants to {new_version}"
52
+
53
+ [[tool.bumpversion.files]]
54
+ filename = "pyproject.toml"
55
+ # Anchored to the line start so it matches the [project] version only, not the
56
+ # current_version key in this [tool.bumpversion] block (which bumps itself).
57
+ regex = true
58
+ search = '^version = "{current_version}"'
59
+ replace = 'version = "{new_version}"'
60
+
61
+ [[tool.bumpversion.files]]
62
+ filename = "src/sextants/__init__.py"
63
+ search = '__version__ = "{current_version}"'
64
+ replace = '__version__ = "{new_version}"'
@@ -0,0 +1,32 @@
1
+ """sextants — convert raster images to teletext / viewdata block-mosaic graphics.
2
+
3
+ The public API mirrors the pipeline: :func:`convert` (preset-aware, the usual
4
+ entry point), :func:`convert_image` (full keyword control), :func:`preprocess_image`
5
+ (the preprocessing stage alone), and :func:`render_preview` (render a solved page
6
+ back to an image). ``PRESETS`` holds the named option bundles.
7
+
8
+ This is a derivative of Kieran Connell's *image2mode7* / *image2teletext*
9
+ (MIT). See the README and LICENSE for attribution.
10
+ """
11
+
12
+ from .constants import COLOR_RGB, MODE7_WIDTH, MODE7_HEIGHT
13
+ from .presets import PRESETS
14
+ from .preprocess import preprocess_image
15
+ from .convert import convert, convert_image, to_edittf_url, to_zxnet_url
16
+ from .render import render_preview
17
+
18
+ __version__ = "0.1.1"
19
+
20
+ __all__ = [
21
+ "convert",
22
+ "convert_image",
23
+ "preprocess_image",
24
+ "render_preview",
25
+ "to_edittf_url",
26
+ "to_zxnet_url",
27
+ "PRESETS",
28
+ "COLOR_RGB",
29
+ "MODE7_WIDTH",
30
+ "MODE7_HEIGHT",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,130 @@
1
+ """Command-line interface for sextants.
2
+
3
+ A thin Click wrapper over :func:`sextants.convert.convert_image`, writing the
4
+ 1000-byte page to a ``.bin`` and optionally a preview PNG / editor URL.
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from .constants import MODE7_BLACK_BG
13
+ from .convert import convert_image, to_edittf_url, to_zxnet_url
14
+ from .presets import PRESETS
15
+ from .render import render_preview
16
+
17
+
18
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
19
+ @click.argument("input_image", type=click.Path(exists=True, dir_okay=False, path_type=Path))
20
+ @click.option("-o", "--output", type=click.Path(dir_okay=False, path_type=Path),
21
+ help="Output .bin file (default: <input>.bin).")
22
+ @click.option("--preview", "preview_path", type=click.Path(dir_okay=False, path_type=Path),
23
+ metavar="PNG", help="Save a preview PNG of the rendered output.")
24
+ @click.option("--url", is_flag=True, help="Print an edit.tf URL for the output.")
25
+ @click.option("--zxnet", is_flag=True, help="Print a ZXNet teletext editor URL for the output.")
26
+ @click.option("--preset", type=click.Choice(sorted(PRESETS)),
27
+ help="Named bundle of options for common sources. Explicit flags override it.")
28
+ @click.option("--nohold", is_flag=True, help="Disable Hold Graphics optimisation.")
29
+ @click.option("--nofill", is_flag=True, help="Disable New Background optimisation.")
30
+ @click.option("--sep", is_flag=True, help="Enable Separated Graphics mode (experimental).")
31
+ @click.option("--greedy", is_flag=True,
32
+ help="Use the fast greedy solver + refinement instead of the full DP.")
33
+ @click.option("--luma", is_flag=True,
34
+ help="Use perceptual luminance weighting (ITU-R BT.601) for the error metric.")
35
+ @click.option("--linear", is_flag=True,
36
+ help="Linearise source pixels from sRGB to linear light before squaring error.")
37
+ @click.option("--dither", is_flag=True,
38
+ help="Apply Floyd-Steinberg dithering at sub-pixel level after resize.")
39
+ @click.option("--filter", "filter_", default="bilinear",
40
+ type=click.Choice(["bilinear", "lanczos", "bicubic", "nearest", "cimg"]),
41
+ help="Resampling filter for image resize (default: bilinear).")
42
+ @click.option("--par", type=float, default=1.2, metavar="RATIO",
43
+ help="Pixel aspect ratio of target display (1.0 square, 1.2 LCD, 1.22 CRT).")
44
+ @click.option("--sharpen-radius", type=float, default=1.0, metavar="R",
45
+ help="Unsharp-mask blur radius on the resized sub-pixel image (default: 1.0).")
46
+ @click.option("--sharpen-amount", type=int, default=0, metavar="PCT",
47
+ help="Unsharp-mask strength percent (default: 0 = off).")
48
+ @click.option("--sharpen-threshold", type=int, default=0, metavar="T",
49
+ help="Unsharp-mask threshold: min per-channel difference before sharpening.")
50
+ @click.option("--gamma", type=float, default=1.0, metavar="G",
51
+ help="Power-law tone adjustment before resize (>1 brightens, <1 darkens).")
52
+ @click.option("--contrast", type=float, default=1.0, metavar="C",
53
+ help="Contrast enhancement factor before resize (1.0 = off).")
54
+ @click.option("--saturation", type=float, default=1.0, metavar="S",
55
+ help="Colour saturation factor before resize (1.0 = off).")
56
+ @click.option("--posterize", type=int, default=0, metavar="BITS",
57
+ help="Posterise to BITS bits/channel before resize (0 = off, 1-7).")
58
+ @click.option("--smooth", type=int, default=0, metavar="N",
59
+ help="Merge colour runs shorter than N cells after solving (0 = off).")
60
+ @click.option("--snap", type=int, default=0, metavar="T",
61
+ help="Snap pixels within RGB distance T of a palette colour before dithering.")
62
+ @click.option("--snap-palette", is_flag=True,
63
+ help="Snap every (quantised) colour region unconditionally to the nearest palette colour.")
64
+ @click.option("--bg-flatten", type=int, default=0, metavar="T",
65
+ help="Flatten a border-connected background within distance T before resize (0 = off).")
66
+ @click.option("--median", type=int, default=0, metavar="RADIUS",
67
+ help="Median filter of (2*RADIUS+1)^2 pixels before resize (0 = off).")
68
+ @click.option("--quant", type=int, default=0, metavar="N",
69
+ help="Pre-quantise to N diverse colours after resize (0 = off).")
70
+ @click.option("--direct-sample", is_flag=True,
71
+ help="Bypass resize: quantise at full resolution and point-sample sub-pixel centres.")
72
+ @click.option("--edge-weight", type=float, default=1.0, metavar="W",
73
+ help="Silhouette-edge saliency weight (1.0 = off; try 2.0-5.0).")
74
+ def main(input_image, output, preview_path, url, zxnet, preset,
75
+ nohold, nofill, sep, greedy, luma, linear, dither, filter_, par,
76
+ sharpen_radius, sharpen_amount, sharpen_threshold, gamma, contrast,
77
+ saturation, posterize, smooth, snap, snap_palette, bg_flatten,
78
+ median, quant, direct_sample, edge_weight):
79
+ """Convert INPUT_IMAGE to a 40x25 teletext page (1000-byte .bin)."""
80
+ # Start from the preset (if any), then let explicitly-passed flags win.
81
+ # Click can't tell a default from an explicit value, so we only override a
82
+ # preset key when the corresponding flag differs from its documented default.
83
+ kwargs = dict(PRESETS.get(preset, {}))
84
+
85
+ explicit = {
86
+ "use_hold": not nohold if nohold else None,
87
+ "use_fill": not nofill if nofill else None,
88
+ "use_sep": sep or None,
89
+ "greedy": greedy or None,
90
+ "luma": luma or None,
91
+ "linear": linear or None,
92
+ "dither": dither or None,
93
+ "filter": filter_ if filter_ != "bilinear" else None,
94
+ "par": par if par != 1.2 else None,
95
+ "sharpen_radius": sharpen_radius if sharpen_radius != 1.0 else None,
96
+ "sharpen_amount": sharpen_amount or None,
97
+ "sharpen_threshold": sharpen_threshold or None,
98
+ "gamma": gamma if gamma != 1.0 else None,
99
+ "contrast": contrast if contrast != 1.0 else None,
100
+ "saturation": saturation if saturation != 1.0 else None,
101
+ "posterize": posterize or None,
102
+ "smooth": smooth or None,
103
+ "snap": snap or None,
104
+ "snap_palette": snap_palette or None,
105
+ "bg_flatten": bg_flatten or None,
106
+ "median": median or None,
107
+ "quant_colors": quant or None,
108
+ "direct_sample": direct_sample or None,
109
+ "edge_weight": edge_weight if edge_weight != 1.0 else None,
110
+ }
111
+ kwargs.update({k: v for k, v in explicit.items() if v is not None})
112
+
113
+ output_path = output or input_image.with_suffix(".bin")
114
+ page = convert_image(input_image, **kwargs)
115
+
116
+ output_path.write_bytes(page)
117
+ click.echo(f"Written {len(page)} bytes -> {output_path}")
118
+
119
+ if preview_path:
120
+ render_preview(page).save(preview_path)
121
+ click.echo(f"Preview saved -> {preview_path}")
122
+ if url:
123
+ click.echo(to_edittf_url(page))
124
+ if zxnet:
125
+ click.echo(to_zxnet_url(page))
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.setrecursionlimit(10000)
130
+ main()
@@ -0,0 +1,70 @@
1
+ """Teletext / Mode 7 constants: palette, control codes, sub-pixel geometry.
2
+
3
+ These describe the SAA5050 display model shared by the whole teletext /
4
+ viewdata / videotex family, not any single machine.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ MODE7_WIDTH = 40
11
+ MODE7_HEIGHT = 25
12
+ MODE7_MAX_SIZE = MODE7_WIDTH * MODE7_HEIGHT
13
+
14
+ # Maximum pixel dimensions of the image area
15
+ MODE7_PIXEL_W = 78 # (MODE7_WIDTH - 1) * 2
16
+ MODE7_PIXEL_H = 75 # MODE7_HEIGHT * 3
17
+
18
+ # Column 0 of each row is used for the initial graphics colour code;
19
+ # image content starts at column 1.
20
+ FRAME_FIRST_COLUMN = 1
21
+
22
+ # Control-code byte values (as stored in the raw .bin)
23
+ MODE7_BLANK = 32 # 0x20 - space / all-off graphics char
24
+ MODE7_BLACK_BG = 156 # 0x9C
25
+ MODE7_NEW_BG = 157 # 0x9D
26
+ MODE7_HOLD_GFX = 158 # 0x9E
27
+ MODE7_RELEASE_GFX = 159 # 0x9F
28
+ MODE7_GFX_COLOUR = 144 # 0x90 (add 1-7 for colours red..white)
29
+ MODE7_CONTIG_GFX = 153 # 0x99
30
+ MODE7_SEP_GFX = 154 # 0x9A
31
+
32
+ SEP_FG_FACTOR = 128 # blending factor for separated graphics (0-255)
33
+
34
+ # Perceptual luminance weights for RGB error (ITU-R BT.601)
35
+ # Human vision is ~6× more sensitive to green than blue; weighting the error
36
+ # metric accordingly makes the DP prioritise brightness accuracy over hue.
37
+ LUMA_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float32)
38
+
39
+ # Lookup table: sRGB byte value (0-255) → linearised value scaled back to 0-255.
40
+ # The Teletext palette contains only 0 and 255, which map to 0 and 255 unchanged,
41
+ # so only source pixel values need this correction.
42
+ # Formula: c = v/255; linear = c/12.92 if c<=0.04045 else ((c+0.055)/1.055)^2.4
43
+ _c = np.arange(256, dtype=np.float64) / 255.0
44
+ _SRGB_LUT = np.where(_c <= 0.04045, _c / 12.92,
45
+ ((_c + 0.055) / 1.055) ** 2.4).astype(np.float32) * 255.0
46
+ del _c
47
+
48
+ # State is a 15-bit integer: (sep:1)(last_gfx:7)(hold:1)(bg:3)(fg:3)
49
+ MAX_STATE = 1 << 15 # 32768
50
+
51
+ # Teletext colour palette: index → (R, G, B)
52
+ COLOR_RGB = [
53
+ ( 0, 0, 0), # 0 black
54
+ (255, 0, 0), # 1 red
55
+ ( 0, 255, 0), # 2 green
56
+ (255, 255, 0), # 3 yellow
57
+ ( 0, 0, 255), # 4 blue
58
+ (255, 0, 255), # 5 magenta
59
+ ( 0, 255, 255), # 6 cyan
60
+ (255, 255, 255), # 7 white
61
+ ]
62
+
63
+ # Bit-positions within a graphics character for the 6 sub-pixels:
64
+ # [TL TR] bit 0 (1) bit 1 (2)
65
+ # [ML MR] bit 2 (4) bit 3 (8)
66
+ # [BL BR] bit 4 (16) bit 6 (64) ← bit 5 is always 1 (0x20 base)
67
+ GFX_PIXEL_BITS = [1, 2, 4, 8, 16, 64]
68
+
69
+
70
+ _PALETTE_NP = np.array(COLOR_RGB, dtype=np.float32) # (8, 3)