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 +30 -0
- sextants-0.1.1/PKG-INFO +109 -0
- sextants-0.1.1/README.md +87 -0
- sextants-0.1.1/pyproject.toml +70 -0
- sextants-0.1.1/pyproject.toml.orig +64 -0
- sextants-0.1.1/src/sextants/__init__.py +32 -0
- sextants-0.1.1/src/sextants/cli.py +130 -0
- sextants-0.1.1/src/sextants/constants.py +70 -0
- sextants-0.1.1/src/sextants/convert.py +161 -0
- sextants-0.1.1/src/sextants/metrics.py +202 -0
- sextants-0.1.1/src/sextants/preprocess.py +315 -0
- sextants-0.1.1/src/sextants/presets.py +117 -0
- sextants-0.1.1/src/sextants/py.typed +0 -0
- sextants-0.1.1/src/sextants/render.py +103 -0
- sextants-0.1.1/src/sextants/solver.py +648 -0
- sextants-0.1.1/src/sextants/state.py +102 -0
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.
|
sextants-0.1.1/PKG-INFO
ADDED
|
@@ -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.
|
sextants-0.1.1/README.md
ADDED
|
@@ -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)
|