Working Python version for Commodore.
This commit is contained in:
commit
2a48f52979
51 changed files with 3095 additions and 0 deletions
125
README.md
Normal file
125
README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# c64view
|
||||||
|
|
||||||
|
Convert a modern image (PNG/JPG/GIF/BMP/WEBP) into a **Commodore 64 disk image**
|
||||||
|
(`.d64` / `.d71` / `.d81`) containing a self-contained viewer that displays the
|
||||||
|
picture on a real C64 or in an emulator. The converter works hard to preserve
|
||||||
|
quality within the VIC-II's tight colour and resolution limits.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Five display modes** (auto-selectable):
|
||||||
|
- **Hires** — 320×200, 2 colours per 8×8 cell. Best for sharp line art.
|
||||||
|
- **Multicolor** — 160×200, 1 shared background + 3 colours per 4×8 cell
|
||||||
|
(the classic "Koala" format). Best general-purpose photo mode.
|
||||||
|
- **FLI** — re-points the video matrix every scanline for per-line (4×1) colour.
|
||||||
|
- **Interlace** — two multicolor frames blended at 50 Hz for ~136 apparent colours.
|
||||||
|
- **Mono** — highest-resolution path: 320×200 matched by *luminance* to a colour
|
||||||
|
ramp, so detail is carried by intense dithering. Greyscale by default, or pick
|
||||||
|
any palette colour as the base for a tinted monochrome (black → blue → light
|
||||||
|
blue → white, etc.).
|
||||||
|
- **Perceptual conversion.** All colour decisions are made in CIELAB. Each screen
|
||||||
|
cell's colour set is chosen by an exhaustive, vectorised search that minimises
|
||||||
|
reproduction error; an *Intensive* mode additionally searches the global
|
||||||
|
background colour.
|
||||||
|
- **Selectable dithering** — ordered Bayer (default, artifact-free), Floyd–Steinberg,
|
||||||
|
Atkinson, and the larger Stucki / Jarvis error-diffusion kernels (smoothest
|
||||||
|
gradients, ideal for mono), or none — all constrained so a cell never shows a
|
||||||
|
colour it can't.
|
||||||
|
- **Explore variations.** One click renders every Mode × Palette × Dither
|
||||||
|
combination as a contact sheet (parallelised across CPU cores); pick the best,
|
||||||
|
then fine-tune brightness/contrast/saturation/gamma on that choice.
|
||||||
|
- **PAL / NTSC.** Choose the target video standard. Static and interlace viewers
|
||||||
|
work on both (interlace flips per frame, so it flickers at the standard's field
|
||||||
|
rate automatically); FLI ships a separately-timed viewer for each.
|
||||||
|
- **Run in VICE.** One click builds the disk and launches it in VICE in the chosen
|
||||||
|
standard (warp mode, except interlace which needs real-time for its flicker): it
|
||||||
|
lists the directory, then `LOAD"*",8,1` + `RUN` to show the picture.
|
||||||
|
- **On-disk info program.** When there's room, a colourful BASIC program is added
|
||||||
|
that prints the original name, dimensions, format, colour depth, oldest EXIF date,
|
||||||
|
file date, EXIF comment, when the C64 version was made, the host platform, and the
|
||||||
|
Linux distribution/version.
|
||||||
|
- **Self-contained viewers.** Each disk's first program embeds the picture and loads
|
||||||
|
in a single pass, so `LOAD"*",8,1` then `RUN` just works — no second disk access,
|
||||||
|
no emulator-config surprises.
|
||||||
|
- **Standard interchange files.** Multicolor exports also drop a `PICTURE.KOA`
|
||||||
|
(Koala) and hires a `PICTURE.ART` (OCP Art Studio) file for use in other C64 tools.
|
||||||
|
- **GUI and CLI.**
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.9+, with `numpy` and `Pillow`.
|
||||||
|
- `PyQt5` (GUI only).
|
||||||
|
- [`xa`](https://www.floodgap.com/retrotech/xa/) (xa65) — assembles the 6502 viewers.
|
||||||
|
- [VICE](https://vice-emu.sourceforge.io/)'s `c1541` — builds the disk images.
|
||||||
|
|
||||||
|
On Debian/Ubuntu:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install python3-numpy python3-pil python3-pyqt5 xa65 vice
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### GUI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m c64view.gui # or: c64view
|
||||||
|
```
|
||||||
|
|
||||||
|
Open an image, pick a mode / disk format / dithering, watch the live C64 preview,
|
||||||
|
then **Export**.
|
||||||
|
|
||||||
|
### Command line
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Multicolor picture onto a .d64, plus a preview PNG of how it will look:
|
||||||
|
python -m c64view.cli photo.jpg -m multicolor -o photo.d64 --preview photo.png
|
||||||
|
|
||||||
|
# Let the tool pick the best standard mode, write a .d81:
|
||||||
|
python -m c64view.cli photo.jpg -m auto -o photo.d81
|
||||||
|
|
||||||
|
# Best quality (slower) FLI with error-diffusion dithering:
|
||||||
|
python -m c64view.cli photo.jpg -m fli -d floyd --intensive -o photo.d64
|
||||||
|
|
||||||
|
# High-res greyscale with smooth Stucki dithering:
|
||||||
|
python -m c64view.cli photo.jpg -m mono -d stucki -o photo.d64
|
||||||
|
# ...or tinted monochrome in blue:
|
||||||
|
python -m c64view.cli photo.jpg -m mono --mono-base blue -d jarvis -o photo.d64
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `-m/--mode {auto,hires,multicolor,fli,interlace,mono}`,
|
||||||
|
`-f/--format {d64,d71,d81}`, `-p/--palette {colodore,pepto}`,
|
||||||
|
`-d/--dither {bayer,floyd,atkinson,stucki,jarvis,none}`,
|
||||||
|
`--mono-base {grayscale,<colour name>}`, `--video {pal,ntsc}`,
|
||||||
|
`-a/--aspect {fit,fill,stretch}`, `--intensive`,
|
||||||
|
`--brightness/--contrast/--saturation/--gamma`, `--preview`.
|
||||||
|
|
||||||
|
### On the C64 / in an emulator
|
||||||
|
|
||||||
|
Attach the disk to drive 8 and:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOAD"*",8,1
|
||||||
|
RUN
|
||||||
|
```
|
||||||
|
|
||||||
|
Press any key to return to BASIC (hires/multicolor). For FLI/interlace, reset to exit.
|
||||||
|
|
||||||
|
## Notes on the advanced modes
|
||||||
|
|
||||||
|
- **FLI** is timing-critical: the viewer runs a cycle-stable raster loop. Expect a
|
||||||
|
small settling artifact in the top rows (a well-known FLI characteristic) and the
|
||||||
|
leftmost few pixels reserved by the hardware "FLI bug".
|
||||||
|
- **Interlace** flickers at 25 Hz on a CRT; it looks best in an emulator or on an LCD.
|
||||||
|
- Multicolor and Hires are the universally safe, flicker-free choices.
|
||||||
|
|
||||||
|
## How conversion works
|
||||||
|
|
||||||
|
`c64view/convert/` holds one encoder per mode on top of a shared core
|
||||||
|
(`convert/base.py` + `dither.py`). The pipeline: prepare & resize the image to the
|
||||||
|
mode's pixel grid (`imageprep.py`), convert to CIELAB (`palette.py`), choose each
|
||||||
|
cell's legal colour set by exhaustive search, dither within those sets, then pack the
|
||||||
|
VIC-II bytes. `viewer/*.s` are the 6502 viewers (assembled by `xa`), combined with the
|
||||||
|
picture data and written to a disk image by `diskimage.py` / `exporter.py`.
|
||||||
3
c64view/__init__.py
Normal file
3
c64view/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""c64view -- convert modern images into Commodore 64 disk images with a viewer."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
BIN
c64view/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/basicgen.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/basicgen.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/cli.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/diskimage.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/diskimage.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/dither.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/dither.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/exporter.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/exporter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/gallery.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/gallery.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/gui.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/gui.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/imageprep.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/imageprep.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/imginfo.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/imginfo.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/__pycache__/palette.cpython-313.pyc
Normal file
BIN
c64view/__pycache__/palette.cpython-313.pyc
Normal file
Binary file not shown.
120
c64view/basicgen.py
Normal file
120
c64view/basicgen.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Generate a small, colourful Commodore 64 BASIC program (tokenised .PRG) that
|
||||||
|
prints image metadata. We emit the tokenised bytes directly -- token bytes for
|
||||||
|
the few keywords used (PRINT, POKE) and PETSCII for everything else -- so there
|
||||||
|
is no dependency on an external BASIC tokeniser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# BASIC V2 keyword tokens.
|
||||||
|
_PRINT = 0x99
|
||||||
|
_POKE = 0x97
|
||||||
|
_CHR = 0xC7 # CHR$( function token
|
||||||
|
_QUOTE = 0x22
|
||||||
|
|
||||||
|
# PETSCII control codes.
|
||||||
|
CLR = 0x93
|
||||||
|
RVON = 0x12
|
||||||
|
RVOFF = 0x92
|
||||||
|
# colours
|
||||||
|
WHITE, RED, GREEN, BLUE = 0x05, 0x1c, 0x1e, 0x1f
|
||||||
|
BLACK, ORANGE, BROWN = 0x90, 0x81, 0x95
|
||||||
|
LT_RED, DK_GREY, GREY, LT_GREEN = 0x96, 0x97, 0x98, 0x99
|
||||||
|
LT_BLUE, LT_GREY, PURPLE, YELLOW, CYAN = 0x9a, 0x9b, 0x9c, 0x9e, 0x9f
|
||||||
|
|
||||||
|
# label colours cycled down the screen (none black, all readable on black).
|
||||||
|
_LABEL_COLOURS = [CYAN, YELLOW, LT_GREEN, LT_RED, LT_BLUE, PURPLE, ORANGE, GREEN]
|
||||||
|
# per-character rainbow for the title (bright, distinct, no black).
|
||||||
|
_RAINBOW = [RED, ORANGE, YELLOW, LT_GREEN, GREEN, CYAN, LT_BLUE, BLUE, PURPLE,
|
||||||
|
LT_RED, WHITE]
|
||||||
|
|
||||||
|
_SCREEN_W = 40
|
||||||
|
_VALUE_COL = 11 # column where a value starts (= label field width)
|
||||||
|
_LINE_MAX = _SCREEN_W - 1 # keep lines < 40 so the screen never auto-wraps
|
||||||
|
_VALUE_W = _LINE_MAX - _VALUE_COL # printable value chars per line
|
||||||
|
|
||||||
|
|
||||||
|
def _petscii(text: str) -> bytes:
|
||||||
|
"""Map an ASCII string to printable PETSCII (upper-case glyph range)."""
|
||||||
|
out = bytearray()
|
||||||
|
for ch in str(text).upper():
|
||||||
|
b = ord(ch)
|
||||||
|
if b == _QUOTE:
|
||||||
|
b = 0x27 # avoid closing the BASIC string
|
||||||
|
if 0x20 <= b <= 0x5F:
|
||||||
|
out.append(b)
|
||||||
|
else:
|
||||||
|
out.append(0x2E) # '.'
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_str(inner: bytes) -> bytes:
|
||||||
|
return bytes([_PRINT, _QUOTE]) + inner + bytes([_QUOTE])
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(lines: list[tuple[int, bytes]]) -> bytes:
|
||||||
|
"""Link tokenised lines into a PRG (load address $0801)."""
|
||||||
|
cur = 0x0801
|
||||||
|
pieces = []
|
||||||
|
for num, toks in lines:
|
||||||
|
body = bytes([num & 0xFF, (num >> 8) & 0xFF]) + toks + b"\x00"
|
||||||
|
nxt = cur + 2 + len(body)
|
||||||
|
pieces.append(bytes([nxt & 0xFF, (nxt >> 8) & 0xFF]) + body)
|
||||||
|
cur = nxt
|
||||||
|
return bytes([0x01, 0x08]) + b"".join(pieces) + b"\x00\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def _rainbow_title(text: str) -> bytes:
|
||||||
|
"""Centred, per-character rainbow title (control codes don't take columns)."""
|
||||||
|
pad = max(0, (_SCREEN_W - len(text)) // 2)
|
||||||
|
out = bytes([CLR]) + _petscii(" " * pad)
|
||||||
|
for k, ch in enumerate(text):
|
||||||
|
out += bytes([_RAINBOW[k % len(_RAINBOW)]]) + _petscii(ch)
|
||||||
|
return bytes([_PRINT, _QUOTE]) + out + bytes([_QUOTE])
|
||||||
|
|
||||||
|
|
||||||
|
def _field_lines(label: str, value: str, colour: int) -> list[bytes]:
|
||||||
|
"""Word-/width-wrapped PRINT lines for one field; continuations are indented
|
||||||
|
to the value's start column so a long value lines up under itself."""
|
||||||
|
label_p = _petscii((label + ":").ljust(_VALUE_COL))
|
||||||
|
value = str(value)[:_VALUE_W * 4] # cap at four screen lines
|
||||||
|
chunks = [value[i:i + _VALUE_W] for i in range(0, len(value), _VALUE_W)] or [""]
|
||||||
|
|
||||||
|
out = [_print_str(bytes([colour]) + label_p + bytes([WHITE]) + _petscii(chunks[0]))]
|
||||||
|
indent = _petscii(" " * _VALUE_COL)
|
||||||
|
for chunk in chunks[1:]:
|
||||||
|
out.append(_print_str(bytes([WHITE]) + indent + _petscii(chunk)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_info_prg(fields: list[tuple[str, str]]) -> bytes:
|
||||||
|
"""Return a tokenised BASIC PRG that prints ``fields`` (label, value)."""
|
||||||
|
lines: list[tuple[int, bytes]] = []
|
||||||
|
num = 0
|
||||||
|
|
||||||
|
def add(toks: bytes):
|
||||||
|
nonlocal num
|
||||||
|
num += 10
|
||||||
|
lines.append((num, toks))
|
||||||
|
|
||||||
|
# border and background both black
|
||||||
|
add(bytes([_POKE]) + b"53280,0:" + bytes([_POKE]) + b"53281,0")
|
||||||
|
add(_rainbow_title("c64view picture info"))
|
||||||
|
add(bytes([_PRINT])) # blank line
|
||||||
|
|
||||||
|
for i, (label, value) in enumerate(fields):
|
||||||
|
col = _LABEL_COLOURS[i % len(_LABEL_COLOURS)]
|
||||||
|
for line in _field_lines(label, value, col):
|
||||||
|
add(line)
|
||||||
|
|
||||||
|
add(bytes([_PRINT]))
|
||||||
|
# PRINT " load "CHR$(34)"*"CHR$(34)",8,1 to view picture" -- CHR$(34) is the
|
||||||
|
# double-quote, which can't appear literally inside a BASIC string.
|
||||||
|
q = bytes([_CHR]) + _petscii("(34)")
|
||||||
|
add(bytes([_PRINT])
|
||||||
|
+ bytes([_QUOTE]) + bytes([GREY]) + _petscii(" load ") + bytes([_QUOTE])
|
||||||
|
+ q
|
||||||
|
+ bytes([_QUOTE]) + _petscii("*") + bytes([_QUOTE])
|
||||||
|
+ q
|
||||||
|
+ bytes([_QUOTE]) + _petscii(",8,1 to view picture") + bytes([_QUOTE]))
|
||||||
|
return _assemble(lines)
|
||||||
80
c64view/cli.py
Normal file
80
c64view/cli.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Headless entry point: convert an image, write a disk image and/or a preview PNG."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import imageprep
|
||||||
|
from .convert import MODES, convert_image, render_preview
|
||||||
|
from .palette import COLOR_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="c64view", description=__doc__)
|
||||||
|
p.add_argument("image", help="source image (png/jpg/gif/bmp/webp)")
|
||||||
|
p.add_argument("-o", "--output", help="disk image path (.d64/.d71/.d81)")
|
||||||
|
p.add_argument("-m", "--mode", default="auto",
|
||||||
|
choices=["auto", *MODES], help="C64 display mode")
|
||||||
|
p.add_argument("-f", "--format", default=None,
|
||||||
|
choices=["d64", "d71", "d81"],
|
||||||
|
help="disk format (default: from -o extension, else d64)")
|
||||||
|
p.add_argument("-p", "--palette", default="colodore",
|
||||||
|
choices=["colodore", "pepto"])
|
||||||
|
p.add_argument("-d", "--dither", default="bayer",
|
||||||
|
choices=["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"])
|
||||||
|
p.add_argument("--mono-base", default="grayscale",
|
||||||
|
choices=["grayscale", *COLOR_NAMES],
|
||||||
|
help="base colour for 'mono' mode (default greyscale)")
|
||||||
|
p.add_argument("-a", "--aspect", default="fit",
|
||||||
|
choices=["fit", "fill", "stretch"])
|
||||||
|
p.add_argument("--video", default="pal", choices=["pal", "ntsc"],
|
||||||
|
help="target video standard (affects the FLI viewer timing)")
|
||||||
|
p.add_argument("--intensive", action="store_true",
|
||||||
|
help="exhaustive background search + slower, higher-quality passes")
|
||||||
|
p.add_argument("--brightness", type=float, default=1.0)
|
||||||
|
p.add_argument("--contrast", type=float, default=1.0)
|
||||||
|
p.add_argument("--saturation", type=float, default=1.0)
|
||||||
|
p.add_argument("--gamma", type=float, default=1.0)
|
||||||
|
p.add_argument("--preview", help="also write a PNG preview to this path")
|
||||||
|
p.add_argument("--disk-name", default=None, help="disk + viewer name (PETSCII)")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
prep = imageprep.PrepOptions(
|
||||||
|
aspect=args.aspect, brightness=args.brightness, contrast=args.contrast,
|
||||||
|
saturation=args.saturation, gamma=args.gamma,
|
||||||
|
)
|
||||||
|
base_color = (None if args.mono_base == "grayscale"
|
||||||
|
else COLOR_NAMES.index(args.mono_base))
|
||||||
|
conv = convert_image(args.image, mode=args.mode, palette_name=args.palette,
|
||||||
|
dither_mode=args.dither, intensive=args.intensive,
|
||||||
|
prep_opt=prep, base_color=base_color)
|
||||||
|
print(f"mode={conv.mode} mean dE={conv.error:.2f} "
|
||||||
|
f"data={len(conv.data)}B extra={[f[0] for f in conv.extra_files]}")
|
||||||
|
|
||||||
|
if args.preview:
|
||||||
|
rgb = render_preview(conv, args.palette, scale=2)
|
||||||
|
Image.fromarray(rgb, "RGB").save(args.preview)
|
||||||
|
print(f"wrote preview {args.preview}")
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
from .exporter import export_disk
|
||||||
|
fmt = args.format
|
||||||
|
path = export_disk(conv, args.output, disk_format=fmt,
|
||||||
|
disk_name=args.disk_name, source_path=args.image,
|
||||||
|
video=args.video)
|
||||||
|
print(f"wrote disk image {path}")
|
||||||
|
|
||||||
|
if not args.output and not args.preview:
|
||||||
|
print("nothing to do: pass -o DISK and/or --preview PNG", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
81
c64view/convert/__init__.py
Normal file
81
c64view/convert/__init__.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Conversion dispatch + preview rendering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import imageprep, palette as pal
|
||||||
|
from . import base, hires, mono, multicolor
|
||||||
|
|
||||||
|
# mode name -> module
|
||||||
|
_MODULES = {
|
||||||
|
"hires": hires,
|
||||||
|
"multicolor": multicolor,
|
||||||
|
"mono": mono,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registered lazily so FLI/IFLI can be added without import cycles.
|
||||||
|
try:
|
||||||
|
from . import fli # noqa: E402
|
||||||
|
_MODULES["fli"] = fli
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from . import ifli # noqa: E402
|
||||||
|
_MODULES["interlace"] = ifli
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
MODES = list(_MODULES.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def convert_image(path_or_img, mode="multicolor", palette_name="colodore",
|
||||||
|
dither_mode="bayer", intensive=False,
|
||||||
|
prep_opt: imageprep.PrepOptions | None = None,
|
||||||
|
base_color=None) -> base.Conversion:
|
||||||
|
"""Prepare an image for ``mode`` and convert it. ``mode='auto'`` tries every
|
||||||
|
standard mode and returns the lowest-error result. ``base_color`` (palette
|
||||||
|
index, or None for grayscale) only applies to the ``mono`` mode."""
|
||||||
|
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
best = None
|
||||||
|
for m in ("multicolor", "hires"):
|
||||||
|
c = convert_image(path_or_img, m, palette_name, dither_mode, intensive, prep_opt)
|
||||||
|
if best is None or c.error < best.error:
|
||||||
|
best = c
|
||||||
|
return best
|
||||||
|
|
||||||
|
module = _MODULES[mode]
|
||||||
|
border_rgb = pal.get_palette(palette_name)[prep_opt.border_index]
|
||||||
|
img_rgb = imageprep.prepare(
|
||||||
|
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
|
||||||
|
prep_opt, border_rgb=border_rgb,
|
||||||
|
)
|
||||||
|
if mode == "mono":
|
||||||
|
return module.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||||
|
base_color=base_color)
|
||||||
|
return module.convert(img_rgb, palette_name, dither_mode, intensive)
|
||||||
|
|
||||||
|
|
||||||
|
def render_preview(conv: base.Conversion, palette_name="colodore",
|
||||||
|
scale: int = 2) -> np.ndarray:
|
||||||
|
"""Render the conversion's index image to a displayed-resolution RGB array.
|
||||||
|
|
||||||
|
Logical pixels are widened by the mode's pixel aspect (so multicolor pixels
|
||||||
|
are twice as wide), giving a uniform 320x200 base which is then integer-scaled.
|
||||||
|
"""
|
||||||
|
if conv.preview_rgb is not None:
|
||||||
|
rgb = conv.preview_rgb
|
||||||
|
if scale > 1:
|
||||||
|
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
prgb = pal.get_palette(palette_name).astype(np.uint8)
|
||||||
|
rgb = prgb[conv.index_image] # (H, W, 3)
|
||||||
|
xrep = int(round(conv.pixel_aspect))
|
||||||
|
if xrep > 1:
|
||||||
|
rgb = np.repeat(rgb, xrep, axis=1)
|
||||||
|
if scale > 1:
|
||||||
|
rgb = np.repeat(np.repeat(rgb, scale, axis=0), scale, axis=1)
|
||||||
|
return rgb
|
||||||
BIN
c64view/convert/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/base.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/fli.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/fli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/hires.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/hires.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/ifli.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/ifli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/mono.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/mono.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/convert/__pycache__/multicolor.cpython-313.pyc
Normal file
BIN
c64view/convert/__pycache__/multicolor.cpython-313.pyc
Normal file
Binary file not shown.
124
c64view/convert/base.py
Normal file
124
c64view/convert/base.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Shared machinery for every C64 display mode.
|
||||||
|
|
||||||
|
The hard part of "make this photo look good on a C64" is that each screen cell
|
||||||
|
may only show a handful of the 16 fixed colours. We solve that per cell with an
|
||||||
|
exhaustive, vectorised search over every legal colour combination, scored by
|
||||||
|
perceptual (CIELAB) reproduction error. The winning per-cell colour sets then
|
||||||
|
drive a constrained dither (see ``dither.py``) to produce the final index image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conversion:
|
||||||
|
"""Result of converting an image to one C64 display mode."""
|
||||||
|
mode: str
|
||||||
|
width: int # logical pixel width (160 or 320)
|
||||||
|
height: int # logical pixel height (200)
|
||||||
|
pixel_aspect: float # on-screen width of one logical pixel
|
||||||
|
index_image: np.ndarray # (H, W) uint8 palette indices, for preview
|
||||||
|
data: bytes = b"" # picture block that must reside from data_addr up
|
||||||
|
data_addr: int = 0x2000 # memory address where ``data`` must load
|
||||||
|
preview_rgb: np.ndarray = None # optional explicit preview (e.g. interlace blend)
|
||||||
|
extra_files: list = field(default_factory=list) # (cbm_name, full_prg_bytes)
|
||||||
|
viewer: str = "" # viewer key (see viewer/assemble.py)
|
||||||
|
error: float = 0.0 # mean per-pixel CIELAB error
|
||||||
|
meta: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def cells_lab(img_lab: np.ndarray, cell_w: int, cell_h: int):
|
||||||
|
"""Reshape (H,W,3) -> (n_cells, cell_w*cell_h, 3) plus (rows, cols)."""
|
||||||
|
H, W, _ = img_lab.shape
|
||||||
|
rows, cols = H // cell_h, W // cell_w
|
||||||
|
a = img_lab.reshape(rows, cell_h, cols, cell_w, 3)
|
||||||
|
a = a.transpose(0, 2, 1, 3, 4).reshape(rows * cols, cell_h * cell_w, 3)
|
||||||
|
return a, rows, cols
|
||||||
|
|
||||||
|
|
||||||
|
def cell_distance(cells: np.ndarray, palette_lab: np.ndarray) -> np.ndarray:
|
||||||
|
"""Squared CIELAB distance from every cell pixel to every palette colour.
|
||||||
|
|
||||||
|
cells: (n_cells, P, 3) -> (n_cells, P, 16)
|
||||||
|
"""
|
||||||
|
return np.sum((cells[:, :, None, :] - palette_lab[None, None, :, :]) ** 2, axis=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def best_global_color(img_lab: np.ndarray, palette_lab: np.ndarray) -> int:
|
||||||
|
"""Palette index closest, on average, to the whole image (good bg seed)."""
|
||||||
|
flat = img_lab.reshape(-1, 3)
|
||||||
|
d = np.sum((flat[:, None, :] - palette_lab[None, :, :]) ** 2, axis=-1)
|
||||||
|
return int(np.argmin(d.mean(axis=0)))
|
||||||
|
|
||||||
|
|
||||||
|
def select_cell_sets(dist: np.ndarray, available, n_free: int, fixed=()):
|
||||||
|
"""Pick, per cell, the ``n_free`` palette colours (plus any ``fixed`` ones)
|
||||||
|
that minimise nearest-colour reproduction error.
|
||||||
|
|
||||||
|
dist: (n_cells, P, 16) squared distances (from ``cell_distance``).
|
||||||
|
Returns (sets, errors): sets is (n_cells, len(fixed)+n_free) palette indices,
|
||||||
|
errors is (n_cells,) summed squared error of the winning set.
|
||||||
|
"""
|
||||||
|
n_cells = dist.shape[0]
|
||||||
|
fixed = list(fixed)
|
||||||
|
combos = list(combinations(sorted(available), n_free))
|
||||||
|
best_err = np.full(n_cells, np.inf)
|
||||||
|
best_combo = np.zeros((n_cells, n_free), dtype=np.int64)
|
||||||
|
|
||||||
|
if fixed:
|
||||||
|
fixed_min = dist[:, :, fixed].min(axis=2) # (n_cells, P)
|
||||||
|
for combo in combos:
|
||||||
|
idx = list(combo)
|
||||||
|
m = dist[:, :, idx].min(axis=2) # (n_cells, P)
|
||||||
|
if fixed:
|
||||||
|
m = np.minimum(m, fixed_min)
|
||||||
|
err = m.sum(axis=1)
|
||||||
|
better = err < best_err
|
||||||
|
best_err = np.where(better, err, best_err)
|
||||||
|
best_combo[better] = idx
|
||||||
|
|
||||||
|
if fixed:
|
||||||
|
fixed_arr = np.tile(np.array(fixed, dtype=np.int64), (n_cells, 1))
|
||||||
|
sets = np.concatenate([fixed_arr, best_combo], axis=1)
|
||||||
|
else:
|
||||||
|
sets = best_combo
|
||||||
|
return sets, best_err
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_background(dist: np.ndarray, n_free: int, candidates=range(16)):
|
||||||
|
"""Choose the single shared background colour (multicolor/FLI) that minimises
|
||||||
|
total image error, returning (bg_index, sets, errors)."""
|
||||||
|
best_total = np.inf
|
||||||
|
best = None
|
||||||
|
for bg in candidates:
|
||||||
|
avail = [i for i in range(16) if i != bg]
|
||||||
|
sets, errors = select_cell_sets(dist, avail, n_free, fixed=(bg,))
|
||||||
|
total = errors.sum()
|
||||||
|
if total < best_total:
|
||||||
|
best_total = total
|
||||||
|
best = (bg, sets, errors)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def per_pixel_allowed(sets: np.ndarray, rows: int, cols: int,
|
||||||
|
cell_w: int, cell_h: int, H: int, W: int) -> np.ndarray:
|
||||||
|
"""Expand per-cell colour sets to an (H, W, K) per-pixel allowed-index table."""
|
||||||
|
yy, xx = np.indices((H, W))
|
||||||
|
cell_idx = (yy // cell_h) * cols + (xx // cell_w)
|
||||||
|
return sets[cell_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def prg(load_addr: int, data: bytes) -> bytes:
|
||||||
|
"""Wrap raw bytes as a CBM PRG (little-endian load address prefix)."""
|
||||||
|
return bytes([load_addr & 0xFF, (load_addr >> 8) & 0xFF]) + bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_error(index_image: np.ndarray, img_lab: np.ndarray, palette_lab: np.ndarray) -> float:
|
||||||
|
"""Mean CIELAB delta-E between the chosen indices and the source image."""
|
||||||
|
chosen = palette_lab[index_image]
|
||||||
|
return float(np.sqrt(np.sum((chosen - img_lab) ** 2, axis=-1)).mean())
|
||||||
142
c64view/convert/fli.py
Normal file
142
c64view/convert/fli.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""FLI (Flexible Line Interpretation) multicolor mode.
|
||||||
|
|
||||||
|
A stable raster routine re-points the VIC video matrix every scanline, so the two
|
||||||
|
screen-RAM-derived colours gain per-line (4x1) resolution while the colour-RAM
|
||||||
|
colour stays per-cell (4x8) and the background is global. Per 4x1 strip the
|
||||||
|
displayable colours are therefore {background, colourRAM(cell), screen01(line),
|
||||||
|
screen10(line)} -- four colours that refresh every line, far more than plain
|
||||||
|
multicolor.
|
||||||
|
|
||||||
|
Memory layout of the appended data block (loads from $4000), matched to
|
||||||
|
viewer/fli.s:
|
||||||
|
$4000+L*$400 screen RAM for line L of each char row (L=0..7), 1000 bytes each
|
||||||
|
$6000 bitmap 8000 (VIC reads here, offset $2000 in bank 1)
|
||||||
|
$8000 colour RAM 1000 (viewer copies to $D800)
|
||||||
|
$83E8 background 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import dither, palette as pal
|
||||||
|
from . import base
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 160, 200
|
||||||
|
CELL_W, CELL_H = 4, 8
|
||||||
|
PIXEL_ASPECT = 2.0
|
||||||
|
DATA_ADDR = 0x4000
|
||||||
|
N_COLS, N_ROWS = 40, 25
|
||||||
|
N_CELLS = N_COLS * N_ROWS
|
||||||
|
|
||||||
|
|
||||||
|
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
|
||||||
|
plab = pal.palette_lab(palette_name)
|
||||||
|
img_lab = pal.srgb_to_lab(img_rgb)
|
||||||
|
|
||||||
|
# (n_cells, 8 rows, 4 px, 3): one 4x1 strip per (cell, line).
|
||||||
|
a = img_lab.reshape(N_ROWS, CELL_H, N_COLS, CELL_W, 3)
|
||||||
|
a = a.transpose(0, 2, 1, 3, 4).reshape(N_CELLS, CELL_H, CELL_W, 3)
|
||||||
|
dist = np.sum((a[:, :, :, None, :] - plab[None, None, None, :, :]) ** 2, axis=-1)
|
||||||
|
# dist: (n_cells, 8, 4, 16)
|
||||||
|
|
||||||
|
bg_candidates = range(16) if intensive else [base.best_global_color(img_lab, plab)]
|
||||||
|
best = None
|
||||||
|
for bg in bg_candidates:
|
||||||
|
c11, c01, c10, err = _solve(dist, bg)
|
||||||
|
if best is None or err < best[-1]:
|
||||||
|
best = (bg, c11, c01, c10, err)
|
||||||
|
bg, c11, c01, c10, _ = best
|
||||||
|
|
||||||
|
index_image = _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode)
|
||||||
|
data = _encode(index_image, bg, c11, c01, c10)
|
||||||
|
|
||||||
|
conv = base.Conversion(
|
||||||
|
mode="fli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||||
|
index_image=index_image, data=data, data_addr=DATA_ADDR, viewer="fli",
|
||||||
|
error=base.mean_error(index_image, img_lab, plab),
|
||||||
|
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
|
||||||
|
)
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
def _solve(dist, bg):
|
||||||
|
"""Pick per-cell colour-RAM colour c11 and per-line free colours c01,c10."""
|
||||||
|
n = dist.shape[0]
|
||||||
|
dbg = dist[:, :, :, bg] # (n,8,4)
|
||||||
|
|
||||||
|
# c11: the single shared colour that best complements bg across the whole cell.
|
||||||
|
cell_err = np.empty((16, n))
|
||||||
|
for c in range(16):
|
||||||
|
m = np.minimum(dbg, dist[:, :, :, c])
|
||||||
|
cell_err[c] = m.sum(axis=(1, 2))
|
||||||
|
cell_err[bg] = np.inf
|
||||||
|
c11 = np.argmin(cell_err, axis=0) # (n,)
|
||||||
|
|
||||||
|
# base error per strip using {bg, c11}.
|
||||||
|
dc11 = np.take_along_axis(dist, c11[:, None, None, None], axis=3)[..., 0] # (n,8,4)
|
||||||
|
sbase = np.minimum(dbg, dc11) # (n,8,4)
|
||||||
|
|
||||||
|
# per strip (cell,line) choose the best 2 free colours.
|
||||||
|
best_err = np.full((n, 8), np.inf)
|
||||||
|
c01 = np.zeros((n, 8), dtype=np.int64)
|
||||||
|
c10 = np.zeros((n, 8), dtype=np.int64)
|
||||||
|
for x, y in combinations(range(16), 2):
|
||||||
|
e = np.minimum(np.minimum(sbase, dist[:, :, :, x]), dist[:, :, :, y]).sum(axis=2)
|
||||||
|
better = e < best_err
|
||||||
|
best_err = np.where(better, e, best_err)
|
||||||
|
c01 = np.where(better, x, c01)
|
||||||
|
c10 = np.where(better, y, c10)
|
||||||
|
|
||||||
|
total = best_err.sum()
|
||||||
|
return c11, c01, c10, float(total)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_map(bg, c11, c01, c10):
|
||||||
|
"""(H, W, 4) per-pixel allowed palette indices."""
|
||||||
|
yy, xx = np.indices((HEIGHT, WIDTH))
|
||||||
|
ci = (yy // CELL_H) * N_COLS + (xx // CELL_W)
|
||||||
|
r = yy % CELL_H
|
||||||
|
allowed = np.empty((HEIGHT, WIDTH, 4), dtype=np.int64)
|
||||||
|
allowed[:, :, 0] = bg
|
||||||
|
allowed[:, :, 1] = c11[ci]
|
||||||
|
allowed[:, :, 2] = c01[ci, r]
|
||||||
|
allowed[:, :, 3] = c10[ci, r]
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _quantize(img_lab, plab, bg, c11, c01, c10, dither_mode):
|
||||||
|
allowed = _allowed_map(bg, c11, c01, c10)
|
||||||
|
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(index_image, bg, c11, c01, c10):
|
||||||
|
bitmap = np.zeros(8000, dtype=np.uint8)
|
||||||
|
screens = [np.zeros(1000, dtype=np.uint8) for _ in range(8)]
|
||||||
|
colram = np.zeros(1000, dtype=np.uint8)
|
||||||
|
|
||||||
|
for cr in range(N_ROWS):
|
||||||
|
for cc in range(N_COLS):
|
||||||
|
ci = cr * N_COLS + cc
|
||||||
|
colram[ci] = c11[ci] & 0x0F
|
||||||
|
base_addr = cr * 320 + cc * 8
|
||||||
|
for r in range(8):
|
||||||
|
a01, a10 = int(c01[ci, r]), int(c10[ci, r])
|
||||||
|
screens[r][ci] = ((a01 & 0x0F) << 4) | (a10 & 0x0F)
|
||||||
|
lut = {int(bg): 0b00, int(c11[ci]): 0b11, a01: 0b01, a10: 0b10}
|
||||||
|
row = index_image[cr * 8 + r, cc * 4:cc * 4 + 4]
|
||||||
|
byte = 0
|
||||||
|
for x in range(4):
|
||||||
|
byte = (byte << 2) | lut.get(int(row[x]), 0b00)
|
||||||
|
bitmap[base_addr + r] = byte
|
||||||
|
|
||||||
|
block = bytearray()
|
||||||
|
for r in range(8):
|
||||||
|
block += bytes(screens[r]) + bytes(24) # pad each screen to 1K
|
||||||
|
block += bytes(bitmap) # $6000
|
||||||
|
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
|
||||||
|
block += bytes(colram) # $8000
|
||||||
|
block += bytes([int(bg) & 0x0F]) # $83E8
|
||||||
|
return bytes(block)
|
||||||
64
c64view/convert/hires.py
Normal file
64
c64view/convert/hires.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Hires bitmap mode: 320x200, two colours per 8x8 cell.
|
||||||
|
|
||||||
|
Data file layout (PRG, load $2000), matched to viewer/hires.s:
|
||||||
|
$2000 bitmap 8000 bytes (VIC reads here directly)
|
||||||
|
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import dither, palette as pal
|
||||||
|
from . import base
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 320, 200
|
||||||
|
CELL_W, CELL_H = 8, 8
|
||||||
|
PIXEL_ASPECT = 1.0
|
||||||
|
DATA_LOAD = 0x2000
|
||||||
|
|
||||||
|
|
||||||
|
def convert(img_rgb: np.ndarray, palette_name="colodore",
|
||||||
|
dither_mode="bayer", intensive=False) -> base.Conversion:
|
||||||
|
plab = pal.palette_lab(palette_name)
|
||||||
|
img_lab = pal.srgb_to_lab(img_rgb)
|
||||||
|
|
||||||
|
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
|
||||||
|
dist = base.cell_distance(cells, plab)
|
||||||
|
sets, _ = base.select_cell_sets(dist, range(16), n_free=2)
|
||||||
|
|
||||||
|
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||||
|
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
bitmap, screen = _encode(index_image, sets, rows, cols)
|
||||||
|
payload = bytes(bitmap) + bytes(screen)
|
||||||
|
|
||||||
|
conv = base.Conversion(
|
||||||
|
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||||
|
index_image=index_image, data=payload, viewer="hires",
|
||||||
|
error=base.mean_error(index_image, img_lab, plab),
|
||||||
|
meta={"palette": palette_name, "dither": dither_mode},
|
||||||
|
)
|
||||||
|
# Standard OCP Art Studio hires file (load $2000): bitmap, screen, border.
|
||||||
|
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(index_image, sets, rows, cols):
|
||||||
|
"""Build the 8000-byte bitmap and 1000-byte screen RAM."""
|
||||||
|
bitmap = np.zeros(8000, dtype=np.uint8)
|
||||||
|
screen = np.zeros(1000, dtype=np.uint8)
|
||||||
|
for cr in range(rows):
|
||||||
|
for cc in range(cols):
|
||||||
|
ci = cr * cols + cc
|
||||||
|
bg_col, fg_col = int(sets[ci, 0]), int(sets[ci, 1])
|
||||||
|
screen[ci] = ((fg_col & 0x0F) << 4) | (bg_col & 0x0F)
|
||||||
|
base_addr = cr * 320 + cc * 8
|
||||||
|
block = index_image[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
|
||||||
|
for r in range(8):
|
||||||
|
row = block[r]
|
||||||
|
byte = 0
|
||||||
|
for x in range(8):
|
||||||
|
byte = (byte << 1) | (1 if row[x] == fg_col else 0)
|
||||||
|
bitmap[base_addr + r] = byte
|
||||||
|
return bitmap, screen
|
||||||
151
c64view/convert/ifli.py
Normal file
151
c64view/convert/ifli.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Interlace mode: two multicolor frames shown on alternating fields (50Hz each).
|
||||||
|
|
||||||
|
The eye averages the two frames, so each pixel can show the blend of its colour
|
||||||
|
in frame A and frame B -- up to ~136 distinct apparent colours (16 base + 120
|
||||||
|
pairs). Frame A is an ordinary multicolor conversion; frame B targets the
|
||||||
|
*residual* (2*target - A) so that (A+B)/2 reconstructs the original. Both frames
|
||||||
|
share the global background and the colour-RAM colour per cell (the only VIC state
|
||||||
|
the viewer cannot cheaply swap per frame), and differ in bitmap + screen RAM.
|
||||||
|
|
||||||
|
Memory layout of the appended data (loads from $2000), matched to viewer/interlace.s:
|
||||||
|
$2000 bitmap A 8000 (bank 0, VIC reads here)
|
||||||
|
$3F40 screen A 1000 (copied to $0400)
|
||||||
|
$4400 screen B 1000 (bank 1 video matrix, in place)
|
||||||
|
$6000 bitmap B 8000 (bank 1, VIC reads here)
|
||||||
|
$8000 colour RAM 1000 (shared, copied to $D800)
|
||||||
|
$83E8 background 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import dither, palette as pal
|
||||||
|
from . import base
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 160, 200
|
||||||
|
CELL_W, CELL_H = 4, 8
|
||||||
|
PIXEL_ASPECT = 2.0
|
||||||
|
DATA_ADDR = 0x2000
|
||||||
|
N_COLS, N_ROWS = 40, 25
|
||||||
|
N_CELLS = N_COLS * N_ROWS
|
||||||
|
|
||||||
|
|
||||||
|
def convert(img_rgb, palette_name="colodore", dither_mode="bayer", intensive=False):
|
||||||
|
plab = pal.palette_lab(palette_name)
|
||||||
|
prgb = pal.get_palette(palette_name)
|
||||||
|
img_lab = pal.srgb_to_lab(img_rgb)
|
||||||
|
|
||||||
|
# ---- frame A: ordinary multicolor (bg + 3 free per cell) ----
|
||||||
|
cellsA, _, _ = base.cells_lab(img_lab, CELL_W, CELL_H)
|
||||||
|
distA = base.cell_distance(cellsA, plab)
|
||||||
|
if intensive:
|
||||||
|
bg, setsA, _ = base.optimize_background(distA, n_free=3)
|
||||||
|
else:
|
||||||
|
bg = base.best_global_color(img_lab, plab)
|
||||||
|
avail = [i for i in range(16) if i != bg]
|
||||||
|
setsA, _ = base.select_cell_sets(distA, avail, n_free=3, fixed=(bg,))
|
||||||
|
# colour-RAM colour (shared by both frames) = third free colour of A.
|
||||||
|
c11 = setsA[:, 3].astype(np.int64)
|
||||||
|
|
||||||
|
allowedA = base.per_pixel_allowed(setsA, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||||
|
idxA = dither.quantize(img_lab, allowedA, plab, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
# ---- frame B: match residual 2*target - A in linear light ----
|
||||||
|
lin_target = pal.srgb_to_linear(img_rgb)
|
||||||
|
lin_A = pal.srgb_to_linear(prgb[idxA])
|
||||||
|
resid = np.clip(2.0 * lin_target - lin_A, 0.0, 1.0)
|
||||||
|
resid_srgb = pal.linear_to_srgb(resid)
|
||||||
|
resid_lab = pal.srgb_to_lab(resid_srgb)
|
||||||
|
|
||||||
|
setsB = _solve_frameB(resid_lab, plab, bg, c11)
|
||||||
|
allowedB = base.per_pixel_allowed(setsB, N_ROWS, N_COLS, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||||
|
idxB = dither.quantize(resid_lab, allowedB, plab, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
# ---- blended preview (linear average -> sRGB, widened to display aspect) ----
|
||||||
|
blend_lin = (pal.srgb_to_linear(prgb[idxA]) + pal.srgb_to_linear(prgb[idxB])) / 2.0
|
||||||
|
blend = pal.linear_to_srgb(blend_lin)
|
||||||
|
preview = np.repeat(blend, int(round(PIXEL_ASPECT)), axis=1)
|
||||||
|
blend_lab = pal.srgb_to_lab(blend)
|
||||||
|
error = float(np.sqrt(np.sum((blend_lab - img_lab) ** 2, axis=-1)).mean())
|
||||||
|
|
||||||
|
data = _encode(idxA, idxB, setsA, setsB, bg, c11)
|
||||||
|
|
||||||
|
return base.Conversion(
|
||||||
|
mode="interlace", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||||
|
index_image=idxA, data=data, data_addr=DATA_ADDR, viewer="interlace",
|
||||||
|
preview_rgb=preview, error=error,
|
||||||
|
meta={"palette": palette_name, "dither": dither_mode, "background": int(bg)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_frameB(resid_lab, plab, bg, c11):
|
||||||
|
"""Per cell, pick the 2 free colours for frame B given shared {bg, c11[cell]}."""
|
||||||
|
cells, _, _ = base.cells_lab(resid_lab, CELL_W, CELL_H)
|
||||||
|
dist = base.cell_distance(cells, plab) # (n, P, 16)
|
||||||
|
dbg = dist[:, :, bg] # (n, P)
|
||||||
|
dc11 = np.take_along_axis(dist, c11[:, None, None], axis=2)[:, :, 0]
|
||||||
|
sbase = np.minimum(dbg, dc11)
|
||||||
|
|
||||||
|
n = dist.shape[0]
|
||||||
|
best = np.full(n, np.inf)
|
||||||
|
b1 = np.zeros(n, dtype=np.int64)
|
||||||
|
b2 = np.zeros(n, dtype=np.int64)
|
||||||
|
for x, y in combinations(range(16), 2):
|
||||||
|
e = np.minimum(np.minimum(sbase, dist[:, :, x]), dist[:, :, y]).sum(axis=1)
|
||||||
|
better = e < best
|
||||||
|
best = np.where(better, e, best)
|
||||||
|
b1 = np.where(better, x, b1)
|
||||||
|
b2 = np.where(better, y, b2)
|
||||||
|
|
||||||
|
bg_arr = np.full(n, bg, dtype=np.int64)
|
||||||
|
return np.stack([bg_arr, b1, b2, c11], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_frame(index_image, screen_assign, colram_assign, bg, get_lut):
|
||||||
|
"""Build (bitmap, screen) for one frame. ``get_lut`` maps cell index -> dict."""
|
||||||
|
bitmap = np.zeros(8000, dtype=np.uint8)
|
||||||
|
screen = np.zeros(1000, dtype=np.uint8)
|
||||||
|
for cr in range(N_ROWS):
|
||||||
|
for cc in range(N_COLS):
|
||||||
|
ci = cr * N_COLS + cc
|
||||||
|
hi, lo, lut = get_lut(ci)
|
||||||
|
screen[ci] = ((hi & 0x0F) << 4) | (lo & 0x0F)
|
||||||
|
base_addr = cr * 320 + cc * 8
|
||||||
|
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
|
||||||
|
for r in range(8):
|
||||||
|
byte = 0
|
||||||
|
for x in range(4):
|
||||||
|
byte = (byte << 2) | lut.get(int(block[r, x]), 0b00)
|
||||||
|
bitmap[base_addr + r] = byte
|
||||||
|
return bitmap, screen
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(idxA, idxB, setsA, setsB, bg, c11):
|
||||||
|
def lutA(ci):
|
||||||
|
cc11 = int(c11[ci])
|
||||||
|
a01, a10 = int(setsA[ci, 1]), int(setsA[ci, 2])
|
||||||
|
return a01, a10, {int(bg): 0b00, a01: 0b01, a10: 0b10, cc11: 0b11}
|
||||||
|
|
||||||
|
def lutB(ci):
|
||||||
|
cc11 = int(c11[ci])
|
||||||
|
b01, b10 = int(setsB[ci, 1]), int(setsB[ci, 2])
|
||||||
|
return b01, b10, {int(bg): 0b00, b01: 0b01, b10: 0b10, cc11: 0b11}
|
||||||
|
|
||||||
|
bitmapA, screenA = _pack_frame(idxA, None, None, bg, lutA)
|
||||||
|
bitmapB, screenB = _pack_frame(idxB, None, None, bg, lutB)
|
||||||
|
colram = (c11 & 0x0F).astype(np.uint8)
|
||||||
|
|
||||||
|
block = bytearray()
|
||||||
|
block += bytes(bitmapA) # $2000
|
||||||
|
block += bytes(screenA) # $3F40
|
||||||
|
block += bytes(0x4400 - (0x3F40 + 1000)) # pad to $4400
|
||||||
|
block += bytes(screenB) # $4400
|
||||||
|
block += bytes(0x6000 - (0x4400 + 1000)) # pad to $6000
|
||||||
|
block += bytes(bitmapB) # $6000
|
||||||
|
block += bytes(0x8000 - (0x6000 + 8000)) # pad to $8000
|
||||||
|
block += bytes(colram) # $8000
|
||||||
|
block += bytes([int(bg) & 0x0F]) # $83E8
|
||||||
|
return bytes(block)
|
||||||
77
c64view/convert/mono.py
Normal file
77
c64view/convert/mono.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Monochrome / grayscale mode -- the highest-resolution path.
|
||||||
|
|
||||||
|
Renders at hires (320x200) but matches the image by *luminance* to a small ramp
|
||||||
|
of palette colours, so detail is carried entirely by spatial dithering. With the
|
||||||
|
grayscale ramp (black -> dark grey -> grey -> light grey -> white) this gives a
|
||||||
|
proper greyscale photo; pick any base colour and the ramp becomes that hue's
|
||||||
|
shades (e.g. black -> blue -> light blue -> white) for a tinted monochrome.
|
||||||
|
|
||||||
|
Output is ordinary hires-format data, so it reuses the hires viewer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import dither, palette as pal
|
||||||
|
from . import base, hires
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 320, 200
|
||||||
|
CELL_W, CELL_H = 8, 8
|
||||||
|
PIXEL_ASPECT = 1.0
|
||||||
|
DATA_LOAD = 0x2000
|
||||||
|
|
||||||
|
# Luminance-ordered grey ramp: black, dark grey, grey, light grey, white.
|
||||||
|
GRAY_RAMP = [0, 11, 12, 15, 1]
|
||||||
|
# A few palette colours have a lighter sibling, giving a richer tinted ramp.
|
||||||
|
SIBLINGS = {2: 10, 10: 2, 5: 13, 13: 5, 6: 14, 14: 6, 8: 9, 9: 8}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ramp(base_color, plab):
|
||||||
|
"""Return palette indices (luminance-sorted) used to render the image."""
|
||||||
|
if base_color is None or base_color in (0, 1, 11, 12, 15):
|
||||||
|
ramp = list(GRAY_RAMP)
|
||||||
|
else:
|
||||||
|
ramp = {0, 1, base_color}
|
||||||
|
if base_color in SIBLINGS:
|
||||||
|
ramp.add(SIBLINGS[base_color])
|
||||||
|
ramp = list(ramp)
|
||||||
|
ramp.sort(key=lambda i: plab[i, 0]) # by Lab lightness
|
||||||
|
return ramp
|
||||||
|
|
||||||
|
|
||||||
|
def convert(img_rgb, palette_name="colodore", dither_mode="floyd",
|
||||||
|
intensive=False, base_color=None):
|
||||||
|
plab = pal.palette_lab(palette_name)
|
||||||
|
|
||||||
|
# Work purely in luminance: collapse image and palette to (L, 0, 0).
|
||||||
|
L_pix = pal.srgb_to_lab(img_rgb)[..., 0]
|
||||||
|
img_mono = np.zeros((HEIGHT, WIDTH, 3))
|
||||||
|
img_mono[..., 0] = L_pix
|
||||||
|
plab_mono = np.zeros((16, 3))
|
||||||
|
plab_mono[:, 0] = plab[:, 0]
|
||||||
|
|
||||||
|
ramp = build_ramp(base_color, plab)
|
||||||
|
n_free = min(2, len(ramp))
|
||||||
|
|
||||||
|
cells, rows, cols = base.cells_lab(img_mono, CELL_W, CELL_H)
|
||||||
|
dist = base.cell_distance(cells, plab_mono)
|
||||||
|
sets, _ = base.select_cell_sets(dist, ramp, n_free=n_free)
|
||||||
|
if n_free == 1: # pad to 2 colours per cell for hires
|
||||||
|
sets = np.concatenate([sets, sets], axis=1)
|
||||||
|
|
||||||
|
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||||
|
index_image = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
bitmap, screen = hires._encode(index_image, sets, rows, cols)
|
||||||
|
payload = bytes(bitmap) + bytes(screen)
|
||||||
|
|
||||||
|
conv = base.Conversion(
|
||||||
|
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||||
|
index_image=index_image, data=payload, data_addr=DATA_LOAD, viewer="hires",
|
||||||
|
error=base.mean_error(index_image, img_mono, plab_mono),
|
||||||
|
meta={"palette": palette_name, "dither": dither_mode,
|
||||||
|
"base_color": base_color, "ramp": ramp},
|
||||||
|
)
|
||||||
|
conv.extra_files = [("picture.art", base.prg(0x2000, payload + b"\x00"))]
|
||||||
|
return conv
|
||||||
80
c64view/convert/multicolor.py
Normal file
80
c64view/convert/multicolor.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Multicolor bitmap mode ("Koala"): 160x200, one shared background plus three
|
||||||
|
freely chosen colours per 4x8 cell.
|
||||||
|
|
||||||
|
Data file layout (PRG, load $2000), matched to viewer/multicolor.s:
|
||||||
|
$2000 bitmap 8000 bytes (VIC reads here directly)
|
||||||
|
$3F40 screen RAM 1000 bytes (viewer copies to $0400)
|
||||||
|
$4328 colour RAM 1000 bytes (viewer copies to $D800)
|
||||||
|
$4710 background 1 byte (viewer writes to $D021)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .. import dither, palette as pal
|
||||||
|
from . import base
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 160, 200
|
||||||
|
CELL_W, CELL_H = 4, 8
|
||||||
|
PIXEL_ASPECT = 2.0
|
||||||
|
DATA_LOAD = 0x2000
|
||||||
|
|
||||||
|
# bit-pair -> colour source: 01 screen hi nibble, 10 screen lo nibble, 11 colour RAM
|
||||||
|
_SLOT_BITS = {1: 0b01, 2: 0b10, 3: 0b11}
|
||||||
|
|
||||||
|
|
||||||
|
def convert(img_rgb: np.ndarray, palette_name="colodore",
|
||||||
|
dither_mode="bayer", intensive=False) -> base.Conversion:
|
||||||
|
plab = pal.palette_lab(palette_name)
|
||||||
|
img_lab = pal.srgb_to_lab(img_rgb)
|
||||||
|
|
||||||
|
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
|
||||||
|
dist = base.cell_distance(cells, plab)
|
||||||
|
|
||||||
|
if intensive:
|
||||||
|
bg, sets, _ = base.optimize_background(dist, n_free=3)
|
||||||
|
else:
|
||||||
|
bg = base.best_global_color(img_lab, plab)
|
||||||
|
avail = [i for i in range(16) if i != bg]
|
||||||
|
sets, _ = base.select_cell_sets(dist, avail, n_free=3, fixed=(bg,))
|
||||||
|
|
||||||
|
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
|
||||||
|
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||||
|
|
||||||
|
bitmap, screen, colram = _encode(index_image, sets, bg, rows, cols)
|
||||||
|
# This block also *is* a Koala body: bitmap, screen, colram, background.
|
||||||
|
payload = bytes(bitmap) + bytes(screen) + bytes(colram) + bytes([bg])
|
||||||
|
|
||||||
|
conv = base.Conversion(
|
||||||
|
mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||||
|
index_image=index_image, data=payload, viewer="multicolor",
|
||||||
|
error=base.mean_error(index_image, img_lab, plab),
|
||||||
|
meta={"palette": palette_name, "dither": dither_mode, "background": bg},
|
||||||
|
)
|
||||||
|
# Standard "Koala Painter" file (load $6000) for use in other C64 art tools.
|
||||||
|
conv.extra_files = [("picture.koa", base.prg(0x6000, payload))]
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(index_image, sets, bg, rows, cols):
|
||||||
|
bitmap = np.zeros(8000, dtype=np.uint8)
|
||||||
|
screen = np.zeros(1000, dtype=np.uint8)
|
||||||
|
colram = np.zeros(1000, dtype=np.uint8)
|
||||||
|
for cr in range(rows):
|
||||||
|
for cc in range(cols):
|
||||||
|
ci = cr * cols + cc
|
||||||
|
# sets[ci] = [bg, c1, c2, c3]; assign the three non-bg colours to slots.
|
||||||
|
c1, c2, c3 = int(sets[ci, 1]), int(sets[ci, 2]), int(sets[ci, 3])
|
||||||
|
screen[ci] = ((c1 & 0x0F) << 4) | (c2 & 0x0F)
|
||||||
|
colram[ci] = c3 & 0x0F
|
||||||
|
color_to_bits = {bg: 0b00, c1: 0b01, c2: 0b10, c3: 0b11}
|
||||||
|
base_addr = cr * 320 + cc * 8
|
||||||
|
block = index_image[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4]
|
||||||
|
for r in range(8):
|
||||||
|
row = block[r]
|
||||||
|
byte = 0
|
||||||
|
for x in range(4):
|
||||||
|
byte = (byte << 2) | color_to_bits.get(int(row[x]), 0b00)
|
||||||
|
bitmap[base_addr + r] = byte
|
||||||
|
return bitmap, screen, colram
|
||||||
145
c64view/diskimage.py
Normal file
145
c64view/diskimage.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Build .d64/.d71/.d81 Commodore disk images with VICE's `c1541`.
|
||||||
|
|
||||||
|
We shell out to `c1541` rather than re-implementing CBM-DOS: it is battle-tested
|
||||||
|
and handles BAM/directory layout for all three formats correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
FORMATS = {
|
||||||
|
"d64": ("d64", 174848), # 35 tracks, 664 blocks free
|
||||||
|
"d71": ("d71", 349696), # double sided
|
||||||
|
"d81": ("d81", 819200), # 3.5" 800K
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-format usable data budget (blocks * 254), conservative.
|
||||||
|
BLOCKS_FREE = {"d64": 664, "d71": 1328, "d81": 3160}
|
||||||
|
|
||||||
|
|
||||||
|
class DiskError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def have_c1541() -> bool:
|
||||||
|
return shutil.which("c1541") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# x64sc is the accurate C64 emulator; x64 is the faster fallback.
|
||||||
|
VICE_EMULATORS = ["x64sc", "x64"]
|
||||||
|
|
||||||
|
|
||||||
|
def vice_emulator() -> str | None:
|
||||||
|
for exe in VICE_EMULATORS:
|
||||||
|
path = shutil.which(exe)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def have_vice() -> bool:
|
||||||
|
return vice_emulator() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def launch_in_vice(disk_path: str, warp: bool = True, standard: str = "pal"):
|
||||||
|
"""Open VICE on ``disk_path`` (drive 8), list the directory, then run the viewer.
|
||||||
|
|
||||||
|
Types ``LOAD"$",8`` / ``LIST`` / ``LOAD"*",8,1`` / ``RUN`` via the keyboard
|
||||||
|
buffer. BASIC commands must be *lower* case here: VICE maps lower-case ASCII
|
||||||
|
to the PETSCII keyword range, and "\\n" is the RETURN key. Runs detached so
|
||||||
|
the GUI stays responsive. ``warp`` should be False for the interlace mode,
|
||||||
|
whose 50 Hz field-flip flickers too fast under warp.
|
||||||
|
"""
|
||||||
|
exe = vice_emulator()
|
||||||
|
if not exe:
|
||||||
|
raise DiskError(
|
||||||
|
"VICE (x64sc) was not found on PATH.\n"
|
||||||
|
"Install it with: sudo apt install vice (Debian/Ubuntu)")
|
||||||
|
keys = 'load"$",8\nlist\nload"*",8,1\nrun\n'
|
||||||
|
# -default keeps the device config predictable so LOAD"*" reads the attached
|
||||||
|
# image rather than a host-filesystem virtual device; -warp runs full speed.
|
||||||
|
cmd = [exe, "-default", "-ntsc" if standard == "ntsc" else "-pal"]
|
||||||
|
if warp:
|
||||||
|
cmd.append("-warp")
|
||||||
|
cmd += ["-8", os.path.abspath(disk_path), "-keybuf", keys]
|
||||||
|
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||||
|
|
||||||
|
|
||||||
|
def petscii_name(text: str, maxlen: int = 16) -> str:
|
||||||
|
"""Sanitise a host string into a legal CBM filename.
|
||||||
|
|
||||||
|
Letters are forced to lower case: c1541 maps lower-case ASCII into the
|
||||||
|
standard PETSCII letter range ($41-$5A), which renders as clean letters in a
|
||||||
|
C64 directory listing, whereas upper-case ASCII lands in the shifted range
|
||||||
|
that displays oddly.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for ch in text.lower():
|
||||||
|
if ch.isalnum() or ch in " -+.":
|
||||||
|
out.append(ch)
|
||||||
|
name = "".join(out).strip() or "c64view"
|
||||||
|
return name[:maxlen]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_from_path(path: str, override: str | None) -> str:
|
||||||
|
if override:
|
||||||
|
if override not in FORMATS:
|
||||||
|
raise DiskError(f"unknown disk format: {override}")
|
||||||
|
return override
|
||||||
|
ext = os.path.splitext(path)[1].lower().lstrip(".")
|
||||||
|
return ext if ext in FORMATS else "d64"
|
||||||
|
|
||||||
|
|
||||||
|
def build_disk(path: str, disk_format: str, disk_name: str, disk_id: str,
|
||||||
|
files: list[tuple[str, bytes]]) -> str:
|
||||||
|
"""Create ``path`` of ``disk_format`` containing ``files`` (cbm_name, prg_bytes).
|
||||||
|
|
||||||
|
The first file in the list lands first in the directory, so ``LOAD"*",8,1``
|
||||||
|
loads it -- make that the viewer.
|
||||||
|
"""
|
||||||
|
if not have_c1541():
|
||||||
|
raise DiskError(
|
||||||
|
"VICE's 'c1541' tool was not found on PATH.\n"
|
||||||
|
"Install it with: sudo apt install vice (Debian/Ubuntu)\n"
|
||||||
|
"or build from https://vice-emu.sourceforge.io/")
|
||||||
|
|
||||||
|
total = sum(len(b) for _, b in files)
|
||||||
|
budget = BLOCKS_FREE[disk_format] * 254
|
||||||
|
if total > budget:
|
||||||
|
raise DiskError(
|
||||||
|
f"data ({total} bytes) exceeds {disk_format} capacity (~{budget} bytes); "
|
||||||
|
f"use a larger disk format")
|
||||||
|
|
||||||
|
vtype = FORMATS[disk_format][0]
|
||||||
|
name = petscii_name(disk_name)
|
||||||
|
did = petscii_name(disk_id, 2) or "01"
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cmd = ["c1541", "-format", f"{name},{did}", vtype, path]
|
||||||
|
for i, (cbm, data) in enumerate(files):
|
||||||
|
host = os.path.join(td, f"f{i}.prg")
|
||||||
|
with open(host, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
cmd += ["-write", host, petscii_name(cbm)]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
errors="replace")
|
||||||
|
# c1541 prints a harmless "OPENCBM ... libopencbm.so" warning; only fail
|
||||||
|
# if the image was not actually produced.
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise DiskError(f"c1541 failed:\n{proc.stdout}{proc.stderr}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def directory(path: str) -> str:
|
||||||
|
"""Return the disk directory listing (for verification)."""
|
||||||
|
proc = subprocess.run(["c1541", "-attach", path, "-dir"],
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
return proc.stdout
|
||||||
136
c64view/dither.py
Normal file
136
c64view/dither.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Palette-constrained dithering.
|
||||||
|
|
||||||
|
Every routine takes the working image in CIELAB plus a per-pixel table of the
|
||||||
|
palette indices that pixel is *allowed* to use (because the VIC-II only lets a
|
||||||
|
given screen cell show a small set of colours), and returns an (H,W) image of
|
||||||
|
chosen palette indices (0..15). Because the allowed set is per-pixel, error that
|
||||||
|
diffuses across a cell boundary is automatically re-clamped to the neighbour
|
||||||
|
cell's own colours -- exactly the constraint real C64 hardware imposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
DITHER_MODES = ["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"]
|
||||||
|
|
||||||
|
|
||||||
|
def bayer_matrix(n: int) -> np.ndarray:
|
||||||
|
"""Normalised (0..1) Bayer threshold matrix of size n x n (n power of two)."""
|
||||||
|
if n == 1:
|
||||||
|
return np.array([[0.0]])
|
||||||
|
smaller = bayer_matrix(n // 2)
|
||||||
|
m = np.block([
|
||||||
|
[4 * smaller + 0, 4 * smaller + 2],
|
||||||
|
[4 * smaller + 3, 4 * smaller + 1],
|
||||||
|
])
|
||||||
|
return m / (n * n)
|
||||||
|
|
||||||
|
|
||||||
|
def _gather_colors(palette_lab: np.ndarray, allowed: np.ndarray) -> np.ndarray:
|
||||||
|
# allowed: (H,W,K) palette indices -> (H,W,K,3) Lab
|
||||||
|
return palette_lab[allowed]
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_ordered(img_lab, allowed, palette_lab, strength=1.0, n=8):
|
||||||
|
"""Ordered (Bayer) dithering between the two best colours of each pixel's set.
|
||||||
|
|
||||||
|
For every pixel we find its nearest and second-nearest allowed colour, project
|
||||||
|
the pixel onto the segment between them, and use the Bayer threshold to decide
|
||||||
|
which of the two to emit -- giving smooth ordered blends without ever leaving
|
||||||
|
the cell's legal colour set.
|
||||||
|
"""
|
||||||
|
H, W, _ = img_lab.shape
|
||||||
|
colors = _gather_colors(palette_lab, allowed) # (H,W,K,3)
|
||||||
|
d = np.sum((img_lab[:, :, None, :] - colors) ** 2, axis=-1) # (H,W,K)
|
||||||
|
|
||||||
|
i1 = np.argmin(d, axis=-1)
|
||||||
|
d2 = np.array(d)
|
||||||
|
np.put_along_axis(d2, i1[..., None], np.inf, axis=-1)
|
||||||
|
i2 = np.argmin(d2, axis=-1)
|
||||||
|
|
||||||
|
yy, xx = np.indices((H, W))
|
||||||
|
c1 = colors[yy, xx, i1] # (H,W,3)
|
||||||
|
c2 = colors[yy, xx, i2]
|
||||||
|
seg = c2 - c1
|
||||||
|
seg_len2 = np.sum(seg * seg, axis=-1) + 1e-9
|
||||||
|
t = np.sum((img_lab - c1) * seg, axis=-1) / seg_len2 # projection 0..1
|
||||||
|
t = np.clip(t * strength, 0.0, 1.0)
|
||||||
|
|
||||||
|
thr = bayer_matrix(n)
|
||||||
|
thr_full = thr[yy % n, xx % n]
|
||||||
|
pick2 = t > thr_full
|
||||||
|
chosen = np.where(pick2, i2, i1)
|
||||||
|
return np.take_along_axis(allowed, chosen[..., None], axis=-1)[..., 0]
|
||||||
|
|
||||||
|
|
||||||
|
def _quantize_diffusion(img_lab, allowed, palette_lab, kernel, divisor):
|
||||||
|
"""Generic serpentine error-diffusion constrained to per-pixel allowed sets."""
|
||||||
|
H, W, _ = img_lab.shape
|
||||||
|
work = img_lab.astype(np.float64).copy()
|
||||||
|
out = np.zeros((H, W), dtype=np.int64)
|
||||||
|
pal = palette_lab
|
||||||
|
for y in range(H):
|
||||||
|
cols = range(W) if (y % 2 == 0) else range(W - 1, -1, -1)
|
||||||
|
flip = 1 if (y % 2 == 0) else -1
|
||||||
|
for x in cols:
|
||||||
|
allow = allowed[y, x]
|
||||||
|
cand = pal[allow]
|
||||||
|
diff = cand - work[y, x]
|
||||||
|
k = int(allow[np.argmin(np.sum(diff * diff, axis=-1))])
|
||||||
|
out[y, x] = k
|
||||||
|
err = work[y, x] - pal[k]
|
||||||
|
for dx, dy, w in kernel:
|
||||||
|
nx, ny = x + dx * flip, y + dy
|
||||||
|
if 0 <= nx < W and 0 <= ny < H:
|
||||||
|
work[ny, nx] += err * (w / divisor)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# (dx, dy, weight) relative to current pixel, assuming left-to-right scan.
|
||||||
|
_FLOYD = [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)]
|
||||||
|
_ATKINSON = [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)]
|
||||||
|
# Larger kernels spread error further -> smoother gradients (best for grayscale).
|
||||||
|
_STUCKI = [(1, 0, 8), (2, 0, 4),
|
||||||
|
(-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), (2, 1, 2),
|
||||||
|
(-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1)]
|
||||||
|
_JARVIS = [(1, 0, 7), (2, 0, 5),
|
||||||
|
(-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), (2, 1, 3),
|
||||||
|
(-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_floyd(img_lab, allowed, palette_lab):
|
||||||
|
return _quantize_diffusion(img_lab, allowed, palette_lab, _FLOYD, 16)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_atkinson(img_lab, allowed, palette_lab):
|
||||||
|
return _quantize_diffusion(img_lab, allowed, palette_lab, _ATKINSON, 8)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_stucki(img_lab, allowed, palette_lab):
|
||||||
|
return _quantize_diffusion(img_lab, allowed, palette_lab, _STUCKI, 42)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_jarvis(img_lab, allowed, palette_lab):
|
||||||
|
return _quantize_diffusion(img_lab, allowed, palette_lab, _JARVIS, 48)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_none(img_lab, allowed, palette_lab):
|
||||||
|
colors = _gather_colors(palette_lab, allowed)
|
||||||
|
d = np.sum((img_lab[:, :, None, :] - colors) ** 2, axis=-1)
|
||||||
|
i1 = np.argmin(d, axis=-1)
|
||||||
|
return np.take_along_axis(allowed, i1[..., None], axis=-1)[..., 0]
|
||||||
|
|
||||||
|
|
||||||
|
def quantize(img_lab, allowed, palette_lab, mode="bayer"):
|
||||||
|
if mode == "bayer":
|
||||||
|
return quantize_ordered(img_lab, allowed, palette_lab)
|
||||||
|
if mode == "floyd":
|
||||||
|
return quantize_floyd(img_lab, allowed, palette_lab)
|
||||||
|
if mode == "atkinson":
|
||||||
|
return quantize_atkinson(img_lab, allowed, palette_lab)
|
||||||
|
if mode == "stucki":
|
||||||
|
return quantize_stucki(img_lab, allowed, palette_lab)
|
||||||
|
if mode == "jarvis":
|
||||||
|
return quantize_jarvis(img_lab, allowed, palette_lab)
|
||||||
|
return quantize_none(img_lab, allowed, palette_lab)
|
||||||
51
c64view/exporter.py
Normal file
51
c64view/exporter.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Orchestrate: converted image -> self-contained viewer -> Commodore disk image."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from . import basicgen, diskimage, imginfo
|
||||||
|
from .convert.base import Conversion
|
||||||
|
from .viewer.assemble import SOURCES, build_viewer_prg
|
||||||
|
|
||||||
|
|
||||||
|
def export_disk(conv: Conversion, output_path: str,
|
||||||
|
disk_format: str | None = None,
|
||||||
|
disk_name: str | None = None,
|
||||||
|
include_graphic_file: bool = True,
|
||||||
|
source_path: str | None = None,
|
||||||
|
video: str = "pal") -> str:
|
||||||
|
"""Write ``conv`` to a disk image at ``output_path``.
|
||||||
|
|
||||||
|
The disk's first (bootable) file is a self-contained viewer that already
|
||||||
|
embeds the picture, so ``LOAD"*",8,1`` then ``RUN`` displays it. When
|
||||||
|
``include_graphic_file`` is set, the picture is also written in a standard
|
||||||
|
interchange format (e.g. Koala) for use in other C64 art tools. When
|
||||||
|
``source_path`` is given and there is room, a colourful BASIC "info" program
|
||||||
|
describing the original image is added too. Returns the absolute path written.
|
||||||
|
"""
|
||||||
|
fmt = diskimage.fmt_from_path(output_path, disk_format)
|
||||||
|
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||||||
|
name = diskimage.petscii_name(disk_name or stem or "c64view")
|
||||||
|
|
||||||
|
# Timing-sensitive viewers (FLI) have an NTSC variant; others run on both.
|
||||||
|
vkey = conv.viewer
|
||||||
|
if video == "ntsc" and f"{conv.viewer}_ntsc" in SOURCES:
|
||||||
|
vkey = f"{conv.viewer}_ntsc"
|
||||||
|
viewer_prg = build_viewer_prg(vkey, conv.data, conv.data_addr)
|
||||||
|
files: list[tuple[str, bytes]] = [(name, viewer_prg)]
|
||||||
|
if include_graphic_file:
|
||||||
|
files.extend(conv.extra_files)
|
||||||
|
|
||||||
|
if source_path:
|
||||||
|
# Best effort: never let the info file break a successful export.
|
||||||
|
try:
|
||||||
|
info = basicgen.build_info_prg(imginfo.gather(source_path))
|
||||||
|
budget = diskimage.BLOCKS_FREE[fmt] * 254
|
||||||
|
info_name = "info" if name != "info" else "picinfo"
|
||||||
|
if sum(len(b) for _, b in files) + len(info) <= budget:
|
||||||
|
files.append((info_name, info))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return diskimage.build_disk(output_path, fmt, name, "01", files)
|
||||||
29
c64view/gallery.py
Normal file
29
c64view/gallery.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Render every Mode x Palette x Dither variation of an image, for the GUI's
|
||||||
|
"Explore variations" contact sheet. Kept import-light (no Qt) so it can run in
|
||||||
|
ProcessPoolExecutor worker processes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import imageprep
|
||||||
|
from .convert import MODES, render_preview, convert_image
|
||||||
|
|
||||||
|
PALETTES = ["colodore", "pepto"]
|
||||||
|
DITHERS = ["bayer", "floyd", "atkinson", "none"]
|
||||||
|
|
||||||
|
# (mode, palette, dither) for every concrete mode (auto is excluded on purpose).
|
||||||
|
COMBOS = [(m, p, d) for m in MODES for p in PALETTES for d in DITHERS]
|
||||||
|
|
||||||
|
|
||||||
|
def render_variation(args):
|
||||||
|
"""Worker entry point. ``args`` = (path, mode, palette, dither, prep_kwargs).
|
||||||
|
|
||||||
|
Returns (mode, palette, dither, error, rgb) where rgb is the displayed-
|
||||||
|
resolution (320x200) preview as a uint8 HxWx3 array.
|
||||||
|
"""
|
||||||
|
path, mode, palette, dither, prep_kwargs = args
|
||||||
|
prep = imageprep.PrepOptions(**prep_kwargs)
|
||||||
|
conv = convert_image(path, mode=mode, palette_name=palette,
|
||||||
|
dither_mode=dither, intensive=False, prep_opt=prep)
|
||||||
|
rgb = render_preview(conv, palette, scale=1)
|
||||||
|
return (mode, palette, dither, conv.error, rgb)
|
||||||
490
c64view/gui.py
Normal file
490
c64view/gui.py
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
"""PyQt5 GUI: open an image, tune the conversion with a live C64 preview, export a disk image."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
from . import gallery, imageprep
|
||||||
|
from .convert import MODES, convert_image, render_preview
|
||||||
|
from .diskimage import FORMATS, have_c1541, have_vice, launch_in_vice
|
||||||
|
from .exporter import export_disk
|
||||||
|
from .palette import COLOR_NAMES
|
||||||
|
from .viewer.assemble import have_xa
|
||||||
|
|
||||||
|
MODE_CHOICES = ["auto", *MODES]
|
||||||
|
DITHER_CHOICES = ["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"]
|
||||||
|
PALETTE_CHOICES = ["colodore", "pepto"]
|
||||||
|
ASPECT_CHOICES = ["fit", "fill", "stretch"]
|
||||||
|
BASE_CHOICES = ["grayscale", *COLOR_NAMES] # for mono mode
|
||||||
|
|
||||||
|
|
||||||
|
def numpy_to_pixmap(rgb: np.ndarray) -> QtGui.QPixmap:
|
||||||
|
rgb = np.ascontiguousarray(rgb)
|
||||||
|
h, w, _ = rgb.shape
|
||||||
|
img = QtGui.QImage(rgb.data, w, h, 3 * w, QtGui.QImage.Format_RGB888)
|
||||||
|
return QtGui.QPixmap.fromImage(img.copy())
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertWorker(QtCore.QThread):
|
||||||
|
"""Runs one conversion off the UI thread."""
|
||||||
|
done = QtCore.pyqtSignal(object) # Conversion
|
||||||
|
failed = QtCore.pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, path, params):
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
p = self.params
|
||||||
|
prep = imageprep.PrepOptions(
|
||||||
|
aspect=p["aspect"], brightness=p["brightness"],
|
||||||
|
contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"],
|
||||||
|
)
|
||||||
|
conv = convert_image(
|
||||||
|
self.path, mode=p["mode"], palette_name=p["palette"],
|
||||||
|
dither_mode=p["dither"], intensive=p["intensive"], prep_opt=prep,
|
||||||
|
base_color=p["base_color"],
|
||||||
|
)
|
||||||
|
self.done.emit(conv)
|
||||||
|
except Exception:
|
||||||
|
self.failed.emit(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryWorker(QtCore.QThread):
|
||||||
|
"""Renders every Mode x Palette x Dither variation across CPU cores."""
|
||||||
|
result = QtCore.pyqtSignal(int, str, str, str, float, object)
|
||||||
|
progress = QtCore.pyqtSignal(int, int)
|
||||||
|
finished_all = QtCore.pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, path, prep_kwargs):
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.prep_kwargs = prep_kwargs
|
||||||
|
self._cancel = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
combos = gallery.COMBOS
|
||||||
|
args = [(self.path, m, p, d, self.prep_kwargs) for (m, p, d) in combos]
|
||||||
|
total = len(combos)
|
||||||
|
done = 0
|
||||||
|
try:
|
||||||
|
with ProcessPoolExecutor() as ex:
|
||||||
|
futs = {ex.submit(gallery.render_variation, a): i
|
||||||
|
for i, a in enumerate(args)}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
if self._cancel:
|
||||||
|
ex.shutdown(wait=False, cancel_futures=True)
|
||||||
|
break
|
||||||
|
i = futs[fut]
|
||||||
|
try:
|
||||||
|
m, p, d, err, rgb = fut.result()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
done += 1
|
||||||
|
self.result.emit(i, m, p, d, err, rgb)
|
||||||
|
self.progress.emit(done, total)
|
||||||
|
finally:
|
||||||
|
self.finished_all.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class VariationsDialog(QtWidgets.QDialog):
|
||||||
|
"""Contact sheet of every Mode x Palette x Dither combination to pick from."""
|
||||||
|
|
||||||
|
def __init__(self, path, prep_kwargs, parent=None, cached=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Explore variations -- pick the best looking one")
|
||||||
|
self.resize(940, 680)
|
||||||
|
self.choice = None
|
||||||
|
self.cells = {}
|
||||||
|
self.best = (float("inf"), None)
|
||||||
|
self.collected = {} # index -> (m, p, d, err, rgb)
|
||||||
|
self.worker = None
|
||||||
|
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
self.info = QtWidgets.QLabel("Rendering variations... click any thumbnail to choose it")
|
||||||
|
outer.addWidget(self.info)
|
||||||
|
|
||||||
|
scroll = QtWidgets.QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
inner = QtWidgets.QWidget()
|
||||||
|
self.grid = QtWidgets.QGridLayout(inner)
|
||||||
|
scroll.setWidget(inner)
|
||||||
|
outer.addWidget(scroll, 1)
|
||||||
|
|
||||||
|
cols = 4
|
||||||
|
for i, (m, p, d) in enumerate(gallery.COMBOS):
|
||||||
|
btn = QtWidgets.QToolButton()
|
||||||
|
btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
|
||||||
|
btn.setIconSize(QtCore.QSize(200, 125))
|
||||||
|
btn.setText(f"{m} - {p} - {d}\n(rendering...)")
|
||||||
|
btn.setEnabled(False)
|
||||||
|
btn.clicked.connect(lambda _checked, idx=i: self._choose(idx))
|
||||||
|
self.cells[i] = btn
|
||||||
|
self.grid.addWidget(btn, i // cols, i % cols)
|
||||||
|
|
||||||
|
btns = QtWidgets.QHBoxLayout()
|
||||||
|
btns.addStretch(1)
|
||||||
|
cancel = QtWidgets.QPushButton("Cancel")
|
||||||
|
cancel.clicked.connect(self.reject)
|
||||||
|
btns.addWidget(cancel)
|
||||||
|
outer.addLayout(btns)
|
||||||
|
|
||||||
|
if cached and len(cached) == len(gallery.COMBOS):
|
||||||
|
for i, vals in cached.items():
|
||||||
|
self._populate(i, *vals)
|
||||||
|
self._on_done() # show "best" marker + final message
|
||||||
|
else:
|
||||||
|
self.worker = GalleryWorker(path, prep_kwargs)
|
||||||
|
self.worker.result.connect(self._on_result)
|
||||||
|
self.worker.progress.connect(self._on_progress)
|
||||||
|
self.worker.finished_all.connect(self._on_done)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _populate(self, i, m, p, d, err, rgb):
|
||||||
|
btn = self.cells[i]
|
||||||
|
pm = numpy_to_pixmap(rgb).scaled(200, 125, QtCore.Qt.KeepAspectRatio,
|
||||||
|
QtCore.Qt.SmoothTransformation)
|
||||||
|
btn.setIcon(QtGui.QIcon(pm))
|
||||||
|
btn.setText(f"{m} - {p} - {d}\ndE {err:.1f}")
|
||||||
|
btn.setEnabled(True)
|
||||||
|
btn.setProperty("combo", (m, p, d))
|
||||||
|
self.collected[i] = (m, p, d, err, rgb)
|
||||||
|
if err < self.best[0]:
|
||||||
|
self.best = (err, i)
|
||||||
|
|
||||||
|
def _on_result(self, i, m, p, d, err, rgb):
|
||||||
|
self._populate(i, m, p, d, err, rgb)
|
||||||
|
|
||||||
|
def _on_progress(self, done, total):
|
||||||
|
self.info.setText(f"Rendering variations... {done}/{total} "
|
||||||
|
f"-- click any thumbnail to choose it")
|
||||||
|
|
||||||
|
def _on_done(self):
|
||||||
|
msg = "Click the variation you like best."
|
||||||
|
if self.best[1] is not None:
|
||||||
|
m, p, d = self.cells[self.best[1]].property("combo")
|
||||||
|
self.cells[self.best[1]].setText(
|
||||||
|
self.cells[self.best[1]].text() + " *best*")
|
||||||
|
msg += f" (lowest error: {m} / {p} / {d})"
|
||||||
|
self.info.setText(msg)
|
||||||
|
|
||||||
|
def _choose(self, i):
|
||||||
|
self.choice = self.cells[i].property("combo")
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def reject(self):
|
||||||
|
if self.worker:
|
||||||
|
self.worker.cancel()
|
||||||
|
self.worker.wait(2000)
|
||||||
|
super().reject()
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QtWidgets.QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("c64view -- image to Commodore 64 disk")
|
||||||
|
self.resize(980, 620)
|
||||||
|
|
||||||
|
self.source_path = None
|
||||||
|
self.last_conv = None
|
||||||
|
self.worker = None
|
||||||
|
self.pending = False
|
||||||
|
self._gallery_key = None # cache key for Explore variations
|
||||||
|
self._gallery_results = None # cached {index: (m, p, d, err, rgb)}
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._check_tools()
|
||||||
|
|
||||||
|
# ---- UI construction ----
|
||||||
|
def _build_ui(self):
|
||||||
|
central = QtWidgets.QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
root = QtWidgets.QHBoxLayout(central)
|
||||||
|
|
||||||
|
# previews
|
||||||
|
previews = QtWidgets.QVBoxLayout()
|
||||||
|
self.src_label = self._make_preview("Original")
|
||||||
|
self.c64_label = self._make_preview("C64 preview")
|
||||||
|
previews.addWidget(self.src_label["box"])
|
||||||
|
previews.addWidget(self.c64_label["box"])
|
||||||
|
root.addLayout(previews, 3)
|
||||||
|
|
||||||
|
# controls
|
||||||
|
panel = QtWidgets.QVBoxLayout()
|
||||||
|
root.addLayout(panel, 1)
|
||||||
|
|
||||||
|
open_btn = QtWidgets.QPushButton("Open image...")
|
||||||
|
open_btn.clicked.connect(self.open_image)
|
||||||
|
panel.addWidget(open_btn)
|
||||||
|
|
||||||
|
self.explore_btn = QtWidgets.QPushButton("Explore variations...")
|
||||||
|
self.explore_btn.setToolTip(
|
||||||
|
"Render every Mode x Palette x Dither combination, pick the best,\n"
|
||||||
|
"then fine-tune brightness/contrast/etc.")
|
||||||
|
self.explore_btn.clicked.connect(self.explore_variations)
|
||||||
|
self.explore_btn.setEnabled(False)
|
||||||
|
panel.addWidget(self.explore_btn)
|
||||||
|
|
||||||
|
form = QtWidgets.QFormLayout()
|
||||||
|
self.mode_cb = self._combo(MODE_CHOICES, "multicolor")
|
||||||
|
self.format_cb = self._combo(list(FORMATS.keys()), "d64")
|
||||||
|
self.palette_cb = self._combo(PALETTE_CHOICES, "colodore")
|
||||||
|
self.dither_cb = self._combo(DITHER_CHOICES, "bayer")
|
||||||
|
self.base_cb = self._combo(BASE_CHOICES, "grayscale")
|
||||||
|
self.video_cb = self._combo(["pal", "ntsc"], "pal")
|
||||||
|
self.aspect_cb = self._combo(ASPECT_CHOICES, "fit")
|
||||||
|
form.addRow("Mode", self.mode_cb)
|
||||||
|
form.addRow("Disk format", self.format_cb)
|
||||||
|
form.addRow("Palette", self.palette_cb)
|
||||||
|
form.addRow("Dither", self.dither_cb)
|
||||||
|
form.addRow("Mono base", self.base_cb)
|
||||||
|
form.addRow("Video", self.video_cb)
|
||||||
|
form.addRow("Aspect", self.aspect_cb)
|
||||||
|
panel.addLayout(form)
|
||||||
|
|
||||||
|
self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)")
|
||||||
|
self.intensive_cb.stateChanged.connect(self.schedule_convert)
|
||||||
|
panel.addWidget(self.intensive_cb)
|
||||||
|
|
||||||
|
self.sliders = {}
|
||||||
|
for key, lo, hi in [("brightness", 50, 200), ("contrast", 50, 200),
|
||||||
|
("saturation", 0, 200), ("gamma", 50, 200)]:
|
||||||
|
panel.addLayout(self._slider(key, lo, hi))
|
||||||
|
|
||||||
|
for cb in (self.mode_cb, self.format_cb, self.palette_cb, self.dither_cb,
|
||||||
|
self.base_cb, self.aspect_cb):
|
||||||
|
cb.currentIndexChanged.connect(self.schedule_convert)
|
||||||
|
|
||||||
|
panel.addStretch(1)
|
||||||
|
self.vice_btn = QtWidgets.QPushButton("Run in VICE")
|
||||||
|
self.vice_btn.setToolTip(
|
||||||
|
"Build a temporary disk, open it in VICE, list the directory,\n"
|
||||||
|
'then LOAD"*",8,1 and RUN the viewer.')
|
||||||
|
self.vice_btn.clicked.connect(self.run_in_vice)
|
||||||
|
self.vice_btn.setEnabled(False)
|
||||||
|
panel.addWidget(self.vice_btn)
|
||||||
|
|
||||||
|
self.export_btn = QtWidgets.QPushButton("Export disk image...")
|
||||||
|
self.export_btn.clicked.connect(self.export)
|
||||||
|
self.export_btn.setEnabled(False)
|
||||||
|
panel.addWidget(self.export_btn)
|
||||||
|
|
||||||
|
self.status = self.statusBar()
|
||||||
|
self._temp_disks = []
|
||||||
|
|
||||||
|
def _make_preview(self, title):
|
||||||
|
box = QtWidgets.QGroupBox(title)
|
||||||
|
lay = QtWidgets.QVBoxLayout(box)
|
||||||
|
label = QtWidgets.QLabel("(no image)")
|
||||||
|
label.setAlignment(QtCore.Qt.AlignCenter)
|
||||||
|
label.setMinimumSize(320, 200)
|
||||||
|
label.setStyleSheet("background:#202020;color:#888;")
|
||||||
|
lay.addWidget(label)
|
||||||
|
return {"box": box, "label": label}
|
||||||
|
|
||||||
|
def _combo(self, items, default):
|
||||||
|
cb = QtWidgets.QComboBox()
|
||||||
|
cb.addItems(items)
|
||||||
|
if default in items:
|
||||||
|
cb.setCurrentText(default)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def _slider(self, key, lo, hi):
|
||||||
|
row = QtWidgets.QHBoxLayout()
|
||||||
|
row.addWidget(QtWidgets.QLabel(key[:4]))
|
||||||
|
s = QtWidgets.QSlider(QtCore.Qt.Horizontal)
|
||||||
|
s.setRange(lo, hi)
|
||||||
|
s.setValue(100)
|
||||||
|
s.sliderReleased.connect(self.schedule_convert)
|
||||||
|
self.sliders[key] = s
|
||||||
|
row.addWidget(s)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _check_tools(self):
|
||||||
|
missing = []
|
||||||
|
if not have_xa():
|
||||||
|
missing.append("xa (xa65 assembler)")
|
||||||
|
if not have_c1541():
|
||||||
|
missing.append("c1541 (VICE)")
|
||||||
|
if missing:
|
||||||
|
QtWidgets.QMessageBox.warning(
|
||||||
|
self, "Missing tools",
|
||||||
|
"Export needs these tools on PATH:\n " + "\n ".join(missing) +
|
||||||
|
"\n\nInstall: sudo apt install xa65 vice")
|
||||||
|
|
||||||
|
# ---- params ----
|
||||||
|
def params(self):
|
||||||
|
base = self.base_cb.currentText()
|
||||||
|
return {
|
||||||
|
"mode": self.mode_cb.currentText(),
|
||||||
|
"palette": self.palette_cb.currentText(),
|
||||||
|
"dither": self.dither_cb.currentText(),
|
||||||
|
"base_color": None if base == "grayscale" else COLOR_NAMES.index(base),
|
||||||
|
"aspect": self.aspect_cb.currentText(),
|
||||||
|
"intensive": self.intensive_cb.isChecked(),
|
||||||
|
"brightness": self.sliders["brightness"].value() / 100.0,
|
||||||
|
"contrast": self.sliders["contrast"].value() / 100.0,
|
||||||
|
"saturation": self.sliders["saturation"].value() / 100.0,
|
||||||
|
"gamma": self.sliders["gamma"].value() / 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- actions ----
|
||||||
|
def open_image(self):
|
||||||
|
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||||
|
self, "Open image", "",
|
||||||
|
"Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All files (*)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self.source_path = path
|
||||||
|
self.explore_btn.setEnabled(True)
|
||||||
|
pm = QtGui.QPixmap(path)
|
||||||
|
self.src_label["label"].setPixmap(
|
||||||
|
pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio,
|
||||||
|
QtCore.Qt.SmoothTransformation))
|
||||||
|
self.schedule_convert()
|
||||||
|
|
||||||
|
def explore_variations(self):
|
||||||
|
if not self.source_path:
|
||||||
|
return
|
||||||
|
p = self.params()
|
||||||
|
prep_kwargs = dict(aspect=p["aspect"], brightness=p["brightness"],
|
||||||
|
contrast=p["contrast"], saturation=p["saturation"],
|
||||||
|
gamma=p["gamma"])
|
||||||
|
# Reuse cached results when the source and prep adjustments are unchanged.
|
||||||
|
key = (self.source_path, tuple(sorted(prep_kwargs.items())))
|
||||||
|
cached = self._gallery_results if self._gallery_key == key else None
|
||||||
|
dlg = VariationsDialog(self.source_path, prep_kwargs, self, cached=cached)
|
||||||
|
result = dlg.exec_()
|
||||||
|
if len(dlg.collected) == len(gallery.COMBOS):
|
||||||
|
self._gallery_key = key
|
||||||
|
self._gallery_results = dict(dlg.collected)
|
||||||
|
if result == QtWidgets.QDialog.Accepted and dlg.choice:
|
||||||
|
mode, palette, dither = dlg.choice
|
||||||
|
# apply choice, then let the focused live preview + tuning take over
|
||||||
|
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
|
||||||
|
cb.blockSignals(True)
|
||||||
|
self.mode_cb.setCurrentText(mode)
|
||||||
|
self.palette_cb.setCurrentText(palette)
|
||||||
|
self.dither_cb.setCurrentText(dither)
|
||||||
|
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
|
||||||
|
cb.blockSignals(False)
|
||||||
|
self.status.showMessage(
|
||||||
|
f"Chosen: {mode} / {palette} / {dither}. "
|
||||||
|
f"Now fine-tune brightness/contrast/saturation/gamma.")
|
||||||
|
self.schedule_convert()
|
||||||
|
|
||||||
|
def schedule_convert(self):
|
||||||
|
if not self.source_path:
|
||||||
|
return
|
||||||
|
if self.worker and self.worker.isRunning():
|
||||||
|
self.pending = True # coalesce: re-run when current finishes
|
||||||
|
return
|
||||||
|
self.status.showMessage("Converting...")
|
||||||
|
self.worker = ConvertWorker(self.source_path, self.params())
|
||||||
|
self.worker.done.connect(self.on_converted)
|
||||||
|
self.worker.failed.connect(self.on_failed)
|
||||||
|
self.worker.finished.connect(self._worker_finished)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _worker_finished(self):
|
||||||
|
if self.pending:
|
||||||
|
self.pending = False
|
||||||
|
self.schedule_convert()
|
||||||
|
|
||||||
|
def on_converted(self, conv):
|
||||||
|
self.last_conv = conv
|
||||||
|
rgb = render_preview(conv, conv.meta.get("palette", "colodore"), scale=2)
|
||||||
|
pm = numpy_to_pixmap(rgb)
|
||||||
|
self.c64_label["label"].setPixmap(
|
||||||
|
pm.scaled(self.c64_label["label"].size(), QtCore.Qt.KeepAspectRatio,
|
||||||
|
QtCore.Qt.SmoothTransformation))
|
||||||
|
self.export_btn.setEnabled(True)
|
||||||
|
self.vice_btn.setEnabled(have_vice())
|
||||||
|
self.status.showMessage(
|
||||||
|
f"{conv.mode}: mean dE {conv.error:.1f} "
|
||||||
|
f"(palette {conv.meta.get('palette')}, dither {conv.meta.get('dither')})")
|
||||||
|
|
||||||
|
def on_failed(self, msg):
|
||||||
|
self.status.showMessage("Conversion failed")
|
||||||
|
QtWidgets.QMessageBox.critical(self, "Conversion failed", msg)
|
||||||
|
|
||||||
|
def export(self):
|
||||||
|
if not self.last_conv:
|
||||||
|
return
|
||||||
|
fmt = self.format_cb.currentText()
|
||||||
|
suggested = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
|
||||||
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||||||
|
self, "Export disk image", f"{suggested}.{fmt}",
|
||||||
|
f"Disk image (*.{fmt})")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
out = export_disk(self.last_conv, path, disk_format=fmt,
|
||||||
|
source_path=self.source_path,
|
||||||
|
video=self.video_cb.currentText())
|
||||||
|
self.status.showMessage(f"Wrote {out}")
|
||||||
|
QtWidgets.QMessageBox.information(
|
||||||
|
self, "Exported",
|
||||||
|
f"Wrote {out}\n\nIn an emulator or on a C64:\n"
|
||||||
|
f' LOAD"*",8,1 then RUN')
|
||||||
|
except Exception:
|
||||||
|
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
|
||||||
|
|
||||||
|
def run_in_vice(self):
|
||||||
|
if not self.last_conv:
|
||||||
|
return
|
||||||
|
if not have_vice():
|
||||||
|
QtWidgets.QMessageBox.warning(
|
||||||
|
self, "VICE not found",
|
||||||
|
"The VICE emulator (x64sc) was not found on PATH.\n"
|
||||||
|
"Install it with: sudo apt install vice")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import tempfile
|
||||||
|
fmt = self.format_cb.currentText()
|
||||||
|
stem = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix=f"{stem}_")
|
||||||
|
os.close(fd)
|
||||||
|
standard = self.video_cb.currentText()
|
||||||
|
export_disk(self.last_conv, path, disk_format=fmt, disk_name=stem,
|
||||||
|
source_path=self.source_path, video=standard)
|
||||||
|
self._temp_disks.append(path)
|
||||||
|
# interlace flickers at the field rate; warp would make it too fast.
|
||||||
|
warp = self.last_conv.mode != "interlace"
|
||||||
|
launch_in_vice(path, warp=warp, standard=standard)
|
||||||
|
self.status.showMessage(
|
||||||
|
f"Launched VICE ({standard}): directory, then LOAD\"*\",8,1 + RUN."
|
||||||
|
+ ("" if warp else " (no warp for interlace)"))
|
||||||
|
except Exception:
|
||||||
|
QtWidgets.QMessageBox.critical(self, "Run in VICE failed",
|
||||||
|
traceback.format_exc())
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
for path in self._temp_disks:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
app = QtWidgets.QApplication(argv if argv is not None else sys.argv)
|
||||||
|
win = MainWindow()
|
||||||
|
win.show()
|
||||||
|
return app.exec_()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
78
c64view/imageprep.py
Normal file
78
c64view/imageprep.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Load a modern image and prepare it for a given C64 display mode.
|
||||||
|
|
||||||
|
The output is always a plain HxWx3 uint8 sRGB numpy array sized to the mode's
|
||||||
|
*logical* pixel grid (e.g. 160x200 for multicolor, 320x200 for hires). The 2:1
|
||||||
|
multicolor pixel aspect is handled here so the source image is never visually
|
||||||
|
squashed: we resize to the displayed shape and then sub-sample to the logical
|
||||||
|
grid, which keeps circles round.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageEnhance, ImageOps
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PrepOptions:
|
||||||
|
aspect: str = "fit" # "fit" (letterbox), "fill" (crop), "stretch"
|
||||||
|
brightness: float = 1.0 # 1.0 = unchanged
|
||||||
|
contrast: float = 1.0
|
||||||
|
saturation: float = 1.0
|
||||||
|
gamma: float = 1.0 # <1 brightens midtones, >1 darkens
|
||||||
|
border_index: int = 0 # palette index used for letterbox bars
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_enhancements(img: Image.Image, opt: PrepOptions) -> Image.Image:
|
||||||
|
if opt.brightness != 1.0:
|
||||||
|
img = ImageEnhance.Brightness(img).enhance(opt.brightness)
|
||||||
|
if opt.contrast != 1.0:
|
||||||
|
img = ImageEnhance.Contrast(img).enhance(opt.contrast)
|
||||||
|
if opt.saturation != 1.0:
|
||||||
|
img = ImageEnhance.Color(img).enhance(opt.saturation)
|
||||||
|
if opt.gamma != 1.0:
|
||||||
|
lut = [min(255, int((i / 255.0) ** opt.gamma * 255 + 0.5)) for i in range(256)]
|
||||||
|
img = img.point(lut * 3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
path_or_img,
|
||||||
|
logical_w: int,
|
||||||
|
logical_h: int,
|
||||||
|
pixel_aspect: float,
|
||||||
|
opt: PrepOptions,
|
||||||
|
border_rgb=(0, 0, 0),
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Return an (logical_h, logical_w, 3) uint8 sRGB array.
|
||||||
|
|
||||||
|
``pixel_aspect`` is width/height of one logical pixel on screen (2.0 for
|
||||||
|
multicolor, 1.0 for hires). ``border_rgb`` fills letterbox bars for "fit".
|
||||||
|
"""
|
||||||
|
if isinstance(path_or_img, Image.Image):
|
||||||
|
img = path_or_img
|
||||||
|
else:
|
||||||
|
img = Image.open(path_or_img)
|
||||||
|
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||||
|
img = _apply_enhancements(img, opt)
|
||||||
|
|
||||||
|
# Displayed target shape (square display pixels): the logical grid stretched
|
||||||
|
# by the pixel aspect ratio horizontally.
|
||||||
|
disp_w = int(round(logical_w * pixel_aspect))
|
||||||
|
disp_h = logical_h
|
||||||
|
|
||||||
|
if opt.aspect == "stretch":
|
||||||
|
fitted = img.resize((disp_w, disp_h), Image.LANCZOS)
|
||||||
|
elif opt.aspect == "fill":
|
||||||
|
fitted = ImageOps.fit(img, (disp_w, disp_h), Image.LANCZOS, centering=(0.5, 0.5))
|
||||||
|
else: # "fit" -> letterbox
|
||||||
|
scaled = img.copy()
|
||||||
|
scaled.thumbnail((disp_w, disp_h), Image.LANCZOS)
|
||||||
|
fitted = Image.new("RGB", (disp_w, disp_h), tuple(int(c) for c in border_rgb))
|
||||||
|
fitted.paste(scaled, ((disp_w - scaled.width) // 2, (disp_h - scaled.height) // 2))
|
||||||
|
|
||||||
|
# Collapse displayed shape back to the logical grid (undo the aspect stretch).
|
||||||
|
logical = fitted.resize((logical_w, logical_h), Image.LANCZOS)
|
||||||
|
return np.asarray(logical, dtype=np.uint8)
|
||||||
142
c64view/imginfo.py
Normal file
142
c64view/imginfo.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Collect descriptive metadata about a source image for the on-disk BASIC
|
||||||
|
info program: name, dimensions, format, colour depth, EXIF dates/comments, the
|
||||||
|
file's own date, when the C64 version was made, and the host platform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
_DEPTH = {
|
||||||
|
"1": "1 bit mono", "L": "8 bit gray", "LA": "8 bit gray+a",
|
||||||
|
"P": "8 bit palette", "PA": "8 bit pal+a", "RGB": "24 bit rgb",
|
||||||
|
"RGBA": "32 bit rgba", "RGBX": "32 bit rgb", "CMYK": "32 bit cmyk",
|
||||||
|
"YCbCr": "24 bit ycc", "I": "32 bit int", "F": "32 bit float",
|
||||||
|
"I;16": "16 bit gray",
|
||||||
|
}
|
||||||
|
|
||||||
|
# EXIF tag ids.
|
||||||
|
_DATETIME = 306
|
||||||
|
_DATETIME_ORIGINAL = 36867
|
||||||
|
_DATETIME_DIGITIZED = 36868
|
||||||
|
_IMAGE_DESCRIPTION = 270
|
||||||
|
_USER_COMMENT = 37510
|
||||||
|
_XP_COMMENT = 40092
|
||||||
|
_EXIF_IFD = 0x8769
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(ts) -> str:
|
||||||
|
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exif_dt(value) -> datetime.datetime | None:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode("ascii", "ignore")
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(str(value).strip(), "%Y:%m:%d %H:%M:%S")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_comment(value) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
raw = value
|
||||||
|
# EXIF UserComment has an 8-byte charset prefix (ASCII / UNICODE / ...).
|
||||||
|
if raw[:8] in (b"ASCII\x00\x00\x00", b"\x00" * 8):
|
||||||
|
raw = raw[8:]
|
||||||
|
elif raw[:8].rstrip(b"\x00") == b"UNICODE":
|
||||||
|
try:
|
||||||
|
return raw[8:].decode("utf-16-be", "ignore").strip("\x00 ") or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for enc in ("utf-8", "utf-16-le", "latin-1"):
|
||||||
|
try:
|
||||||
|
text = raw.decode(enc, "ignore").strip("\x00 ")
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def gather(path: str) -> list[tuple[str, str]]:
|
||||||
|
"""Return an ordered list of (label, value) metadata strings."""
|
||||||
|
fields: list[tuple[str, str]] = []
|
||||||
|
fields.append(("name", os.path.basename(path)))
|
||||||
|
|
||||||
|
img = None
|
||||||
|
try:
|
||||||
|
img = Image.open(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if img is not None:
|
||||||
|
fields.append(("size", f"{img.width} x {img.height}"))
|
||||||
|
fields.append(("format", img.format or "?"))
|
||||||
|
fields.append(("color", _DEPTH.get(img.mode, img.mode)))
|
||||||
|
|
||||||
|
dates: list[datetime.datetime] = []
|
||||||
|
comment = None
|
||||||
|
if img is not None:
|
||||||
|
try:
|
||||||
|
exif = img.getexif()
|
||||||
|
sub = {}
|
||||||
|
try:
|
||||||
|
sub = exif.get_ifd(_EXIF_IFD)
|
||||||
|
except Exception:
|
||||||
|
sub = {}
|
||||||
|
for tag, src in ((_DATETIME, exif), (_DATETIME_ORIGINAL, sub),
|
||||||
|
(_DATETIME_DIGITIZED, sub)):
|
||||||
|
dt = _parse_exif_dt(src.get(tag))
|
||||||
|
if dt:
|
||||||
|
dates.append(dt)
|
||||||
|
comment = (_decode_comment(sub.get(_USER_COMMENT))
|
||||||
|
or _decode_comment(exif.get(_IMAGE_DESCRIPTION))
|
||||||
|
or _decode_comment(exif.get(_XP_COMMENT)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if dates:
|
||||||
|
fields.append(("exif date", min(dates).strftime("%Y-%m-%d %H:%M")))
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
birth = getattr(st, "st_birthtime", None)
|
||||||
|
if birth:
|
||||||
|
fields.append(("file date", _fmt_ts(birth)))
|
||||||
|
else:
|
||||||
|
fields.append(("mod date", _fmt_ts(st.st_mtime)))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if comment:
|
||||||
|
fields.append(("comment", comment))
|
||||||
|
|
||||||
|
fields.append(("c64 made", datetime.datetime.now().strftime("%Y-%m-%d %H:%M")))
|
||||||
|
fields.append(("system", f"{platform.system()} {platform.release()}"))
|
||||||
|
distro = _linux_distro()
|
||||||
|
if distro:
|
||||||
|
fields.append(("distro", distro))
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_distro() -> str | None:
|
||||||
|
"""Linux distribution name + version from /etc/os-release, if available."""
|
||||||
|
try:
|
||||||
|
rel = platform.freedesktop_os_release() # Python 3.10+
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
return None
|
||||||
|
pretty = rel.get("PRETTY_NAME")
|
||||||
|
if pretty:
|
||||||
|
return pretty
|
||||||
|
name = rel.get("NAME", "")
|
||||||
|
version = rel.get("VERSION", rel.get("VERSION_ID", ""))
|
||||||
|
return (f"{name} {version}".strip() or None)
|
||||||
111
c64view/palette.py
Normal file
111
c64view/palette.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Commodore 64 (VIC-II) 16-colour palettes and colour-space helpers.
|
||||||
|
|
||||||
|
All colour distance work in the converter happens in CIELAB, which is far more
|
||||||
|
perceptually uniform than RGB, so the per-cell colour choices and dithering land
|
||||||
|
much closer to what a human eye judges as "the same colour".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 16 fixed VIC-II colours, in canonical index order:
|
||||||
|
# 0 black 4 purple 8 orange 12 grey (medium)
|
||||||
|
# 1 white 5 green 9 brown 13 light green
|
||||||
|
# 2 red 6 blue 10 light red 14 light blue
|
||||||
|
# 3 cyan 7 yellow 11 dark grey 15 light grey
|
||||||
|
|
||||||
|
# "Colodore" (pepto's reworked, calibrated values) -- the modern default.
|
||||||
|
COLODORE = np.array([
|
||||||
|
(0x00, 0x00, 0x00),
|
||||||
|
(0xff, 0xff, 0xff),
|
||||||
|
(0x81, 0x33, 0x38),
|
||||||
|
(0x75, 0xce, 0xc8),
|
||||||
|
(0x8e, 0x3c, 0x97),
|
||||||
|
(0x56, 0xac, 0x4d),
|
||||||
|
(0x2e, 0x2c, 0x9b),
|
||||||
|
(0xed, 0xf1, 0x71),
|
||||||
|
(0x8e, 0x50, 0x29),
|
||||||
|
(0x55, 0x38, 0x00),
|
||||||
|
(0xc4, 0x6c, 0x71),
|
||||||
|
(0x4a, 0x4a, 0x4a),
|
||||||
|
(0x7b, 0x7b, 0x7b),
|
||||||
|
(0xa9, 0xff, 0x9f),
|
||||||
|
(0x70, 0x6d, 0xeb),
|
||||||
|
(0xb2, 0xb2, 0xb2),
|
||||||
|
], dtype=np.float64)
|
||||||
|
|
||||||
|
# "Pepto" (PAL) -- classic reference values, slightly more saturated.
|
||||||
|
PEPTO = np.array([
|
||||||
|
(0x00, 0x00, 0x00),
|
||||||
|
(0xff, 0xff, 0xff),
|
||||||
|
(0x68, 0x37, 0x2b),
|
||||||
|
(0x70, 0xa4, 0xb2),
|
||||||
|
(0x6f, 0x3d, 0x86),
|
||||||
|
(0x58, 0x8d, 0x43),
|
||||||
|
(0x35, 0x28, 0x79),
|
||||||
|
(0xb8, 0xc7, 0x6f),
|
||||||
|
(0x6f, 0x4f, 0x25),
|
||||||
|
(0x43, 0x39, 0x00),
|
||||||
|
(0x9a, 0x67, 0x59),
|
||||||
|
(0x44, 0x44, 0x44),
|
||||||
|
(0x6c, 0x6c, 0x6c),
|
||||||
|
(0x9a, 0xd2, 0x84),
|
||||||
|
(0x6c, 0x5e, 0xb5),
|
||||||
|
(0x95, 0x95, 0x95),
|
||||||
|
], dtype=np.float64)
|
||||||
|
|
||||||
|
PALETTES = {"colodore": COLODORE, "pepto": PEPTO}
|
||||||
|
|
||||||
|
COLOR_NAMES = [
|
||||||
|
"black", "white", "red", "cyan", "purple", "green", "blue", "yellow",
|
||||||
|
"orange", "brown", "light red", "dark grey", "grey", "light green",
|
||||||
|
"light blue", "light grey",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
|
||||||
|
"""sRGB (0..255) -> linear-light (0..1)."""
|
||||||
|
c = rgb.astype(np.float64) / 255.0
|
||||||
|
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
||||||
|
|
||||||
|
|
||||||
|
def linear_to_srgb(lin: np.ndarray) -> np.ndarray:
|
||||||
|
"""linear-light (0..1) -> sRGB (0..255)."""
|
||||||
|
c = np.clip(lin, 0.0, 1.0)
|
||||||
|
s = np.where(c <= 0.0031308, c * 12.92, 1.055 * (c ** (1 / 2.4)) - 0.055)
|
||||||
|
return np.clip(s * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
# D65 reference white.
|
||||||
|
_XYZ_FROM_LIN = np.array([
|
||||||
|
[0.4124564, 0.3575761, 0.1804375],
|
||||||
|
[0.2126729, 0.7151522, 0.0721750],
|
||||||
|
[0.0193339, 0.1191920, 0.9503041],
|
||||||
|
])
|
||||||
|
_WHITE = np.array([0.95047, 1.0, 1.08883])
|
||||||
|
|
||||||
|
|
||||||
|
def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
|
||||||
|
"""sRGB (0..255, last axis = RGB) -> CIELAB. Shape preserved except last axis."""
|
||||||
|
lin = srgb_to_linear(rgb)
|
||||||
|
xyz = lin @ _XYZ_FROM_LIN.T
|
||||||
|
xyz = xyz / _WHITE
|
||||||
|
eps = 216 / 24389
|
||||||
|
kappa = 24389 / 27
|
||||||
|
f = np.where(xyz > eps, np.cbrt(xyz), (kappa * xyz + 16) / 116)
|
||||||
|
fx, fy, fz = f[..., 0], f[..., 1], f[..., 2]
|
||||||
|
L = 116 * fy - 16
|
||||||
|
a = 500 * (fx - fy)
|
||||||
|
b = 200 * (fy - fz)
|
||||||
|
return np.stack([L, a, b], axis=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_palette(name: str = "colodore") -> np.ndarray:
|
||||||
|
"""Return the 16x3 sRGB palette (float64, 0..255)."""
|
||||||
|
return PALETTES[name]
|
||||||
|
|
||||||
|
|
||||||
|
def palette_lab(name: str = "colodore") -> np.ndarray:
|
||||||
|
"""Return the 16 palette colours in CIELAB (16x3)."""
|
||||||
|
return srgb_to_lab(get_palette(name))
|
||||||
9
c64view/viewer/__init__.py
Normal file
9
c64view/viewer/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""6502 viewer programs, assembled on demand by assemble.py."""
|
||||||
|
|
||||||
|
from .assemble import ( # noqa: F401
|
||||||
|
AssemblerError,
|
||||||
|
SOURCES,
|
||||||
|
assemble_stub,
|
||||||
|
build_viewer_prg,
|
||||||
|
have_xa,
|
||||||
|
)
|
||||||
BIN
c64view/viewer/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
c64view/viewer/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
c64view/viewer/__pycache__/assemble.cpython-313.pyc
Normal file
BIN
c64view/viewer/__pycache__/assemble.cpython-313.pyc
Normal file
Binary file not shown.
82
c64view/viewer/assemble.py
Normal file
82
c64view/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Assemble the 6502 viewer stubs with `xa` and build self-contained viewer PRGs.
|
||||||
|
|
||||||
|
Each viewer is a small ML stub originating at $0801 (behind a BASIC SYS 2061
|
||||||
|
autostart). The picture data is appended after the stub, zero-padded so the
|
||||||
|
bitmap lands exactly at $2000, screen RAM at $3F40, etc. The whole thing loads
|
||||||
|
in a single pass -- no second disk access -- so it works identically on real
|
||||||
|
hardware and in any emulator regardless of device configuration.
|
||||||
|
|
||||||
|
`xa -o` emits raw bytes starting at the origin without the 2-byte CBM load
|
||||||
|
address, which we prepend here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LOAD_ADDR = 0x0801
|
||||||
|
DATA_ADDR = 0x2000 # where appended picture data must land
|
||||||
|
|
||||||
|
# mode/viewer key -> source filename
|
||||||
|
SOURCES = {
|
||||||
|
"hires": "hires.s",
|
||||||
|
"multicolor": "multicolor.s",
|
||||||
|
"fli": "fli.s",
|
||||||
|
"fli_ntsc": "fli_ntsc.s",
|
||||||
|
"interlace": "interlace.s",
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AssemblerError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def have_xa() -> bool:
|
||||||
|
return shutil.which("xa") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_stub(viewer_key: str) -> bytes:
|
||||||
|
"""Assemble a viewer stub to raw bytes (origin $0801, no load-address prefix)."""
|
||||||
|
if viewer_key in _cache:
|
||||||
|
return _cache[viewer_key]
|
||||||
|
if not have_xa():
|
||||||
|
raise AssemblerError(
|
||||||
|
"The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||||
|
"Install it with: sudo apt install xa65 (Debian/Ubuntu)\n"
|
||||||
|
"or build from https://www.floodgap.com/retrotech/xa/")
|
||||||
|
|
||||||
|
src = os.path.join(VIEWER_DIR, SOURCES[viewer_key])
|
||||||
|
if not os.path.exists(src):
|
||||||
|
raise AssemblerError(f"viewer source missing: {src}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
out = os.path.join(td, "viewer.bin")
|
||||||
|
proc = subprocess.run(["xa", "-o", out, src], capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise AssemblerError(f"xa failed for {src}:\n{proc.stdout}{proc.stderr}")
|
||||||
|
with open(out, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
_cache[viewer_key] = raw
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def build_viewer_prg(viewer_key: str, data: bytes, data_addr: int = DATA_ADDR) -> bytes:
|
||||||
|
"""Combine the assembled stub + padding + picture ``data`` into one PRG.
|
||||||
|
|
||||||
|
``data`` is the block that must reside from ``data_addr`` upward (bitmap,
|
||||||
|
screen, colour RAM, background, ...).
|
||||||
|
"""
|
||||||
|
stub = assemble_stub(viewer_key)
|
||||||
|
pad_len = (data_addr - LOAD_ADDR) - len(stub)
|
||||||
|
if pad_len < 0:
|
||||||
|
raise AssemblerError(
|
||||||
|
f"viewer stub {viewer_key} is {len(stub)} bytes, exceeds the "
|
||||||
|
f"{data_addr - LOAD_ADDR} bytes available before ${data_addr:04x}")
|
||||||
|
payload = stub + bytes(pad_len) + bytes(data)
|
||||||
|
return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + payload
|
||||||
151
c64view/viewer/fli.s
Normal file
151
c64view/viewer/fli.s
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
; c64view -- FLI multicolor viewer (self-contained)
|
||||||
|
;
|
||||||
|
; Re-points the VIC video matrix ($D018) and forces a badline ($D011 yscroll)
|
||||||
|
; on every visible raster line via a cycle-timed loop, giving per-line colour.
|
||||||
|
;
|
||||||
|
; Memory layout of appended data (loads from $4000):
|
||||||
|
; $4000+L*$400 screen RAM for line L (L=0..7), 1000 bytes each
|
||||||
|
; $6000 bitmap 8000 (offset $2000 in VIC bank 1)
|
||||||
|
; $8000 colour RAM 1000 (copied to $D800)
|
||||||
|
; $83E8 background 1
|
||||||
|
;
|
||||||
|
; assembled by viewer/assemble.py via xa
|
||||||
|
|
||||||
|
; BASIC autostart, SYS 2061
|
||||||
|
* = $0801
|
||||||
|
.word basicend
|
||||||
|
.word 10
|
||||||
|
.byte $9e
|
||||||
|
.byte "2061"
|
||||||
|
.byte 0
|
||||||
|
basicend:
|
||||||
|
.word 0 ; ML begins at $080D
|
||||||
|
|
||||||
|
start:
|
||||||
|
sei
|
||||||
|
|
||||||
|
; copy colour RAM $8000 -> $D800 (1024 bytes)
|
||||||
|
ldx #0
|
||||||
|
ccopy:
|
||||||
|
lda $8000,x
|
||||||
|
sta $d800,x
|
||||||
|
lda $8100,x
|
||||||
|
sta $d900,x
|
||||||
|
lda $8200,x
|
||||||
|
sta $da00,x
|
||||||
|
lda $8300,x
|
||||||
|
sta $db00,x
|
||||||
|
inx
|
||||||
|
bne ccopy
|
||||||
|
|
||||||
|
; build per-line tables. d018tab = ((line AND 7) << 4) OR $08
|
||||||
|
; d011tab = $38 OR (line AND 7) = BMM+DEN+RSEL plus yscroll
|
||||||
|
ldx #0
|
||||||
|
btab:
|
||||||
|
txa
|
||||||
|
and #$07
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
ora #$08
|
||||||
|
sta d018tab,x
|
||||||
|
; yscroll must equal (raster AND 7) to force a badline; the first
|
||||||
|
; displayed raster is 51, so line x maps to raster 51+x.
|
||||||
|
txa
|
||||||
|
clc
|
||||||
|
adc #$03
|
||||||
|
and #$07
|
||||||
|
ora #$38
|
||||||
|
sta d011tab,x
|
||||||
|
inx
|
||||||
|
bne btab
|
||||||
|
|
||||||
|
; VIC bank 1 ($4000-$7FFF)
|
||||||
|
lda $dd00
|
||||||
|
and #$fc
|
||||||
|
ora #$02
|
||||||
|
sta $dd00
|
||||||
|
|
||||||
|
lda $83e8
|
||||||
|
sta $d021 ; background
|
||||||
|
lda #$00
|
||||||
|
sta $d020 ; border black
|
||||||
|
lda #$d8
|
||||||
|
sta $d016 ; multicolor on
|
||||||
|
|
||||||
|
; raster IRQ setup
|
||||||
|
lda #<irq1
|
||||||
|
sta $0314
|
||||||
|
lda #>irq1
|
||||||
|
sta $0315
|
||||||
|
lda #$7f
|
||||||
|
sta $dc0d ; disable CIA timer IRQs
|
||||||
|
sta $dd0d
|
||||||
|
lda $dc0d
|
||||||
|
lda $dd0d
|
||||||
|
lda #$01
|
||||||
|
sta $d01a ; enable raster IRQ
|
||||||
|
lda #$30
|
||||||
|
sta $d012 ; line 48
|
||||||
|
lda $d011
|
||||||
|
and #$7f
|
||||||
|
sta $d011
|
||||||
|
asl $d019 ; ack
|
||||||
|
cli
|
||||||
|
hang:
|
||||||
|
jmp hang
|
||||||
|
|
||||||
|
; first IRQ, arrives with jitter on line 48, sets up the stabilised one
|
||||||
|
irq1:
|
||||||
|
lda #<irq2
|
||||||
|
sta $0314
|
||||||
|
lda #>irq2
|
||||||
|
sta $0315
|
||||||
|
inc $d012 ; fire again next line (49)
|
||||||
|
asl $d019 ; ack
|
||||||
|
tsx
|
||||||
|
cli
|
||||||
|
; burn the rest of the line so irq2 fires inside this NOP slide
|
||||||
|
.dsb 40,$ea ; 40 NOPs
|
||||||
|
|
||||||
|
; stabilised IRQ on line 49, runs the FLI loop
|
||||||
|
irq2:
|
||||||
|
txs ; restore sp from irq1
|
||||||
|
; cancel the remaining 0/1-cycle jitter: read the raster twice; the
|
||||||
|
; branch is 2 or 3 cycles depending on alignment, normalising entry.
|
||||||
|
lda $d012
|
||||||
|
cmp $d012
|
||||||
|
beq jl
|
||||||
|
jl:
|
||||||
|
; fixed delay so the first store lands before the c-access window
|
||||||
|
ldx #$0d
|
||||||
|
d0: dex
|
||||||
|
bne d0
|
||||||
|
nop
|
||||||
|
nop
|
||||||
|
|
||||||
|
ldx #$00
|
||||||
|
fliloop:
|
||||||
|
lda d011tab,x ; force badline (yscroll = line&7)
|
||||||
|
sta $d011
|
||||||
|
lda d018tab,x ; point video matrix at screen[line]
|
||||||
|
sta $d018
|
||||||
|
inx
|
||||||
|
cpx #200
|
||||||
|
bne fliloop
|
||||||
|
|
||||||
|
; bottom border: leave bitmap on but stop forcing badlines
|
||||||
|
lda #<irq1
|
||||||
|
sta $0314
|
||||||
|
lda #>irq1
|
||||||
|
sta $0315
|
||||||
|
lda #$30
|
||||||
|
sta $d012
|
||||||
|
asl $d019
|
||||||
|
jmp $ea81 ; restore regs + RTI
|
||||||
|
|
||||||
|
d018tab:
|
||||||
|
.dsb 256,0
|
||||||
|
d011tab:
|
||||||
|
.dsb 256,0
|
||||||
155
c64view/viewer/fli_ntsc.s
Normal file
155
c64view/viewer/fli_ntsc.s
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
; c64view -- FLI multicolor viewer, NTSC timing (self-contained)
|
||||||
|
;
|
||||||
|
; Same as fli.s but the inner loop is one NOP (2 cycles) longer so it self-syncs
|
||||||
|
; to the NTSC 65-cycle raster line (25 free CPU cycles per badline) vs PAL's 63.
|
||||||
|
;
|
||||||
|
; Re-points the VIC video matrix ($D018) and forces a badline ($D011 yscroll)
|
||||||
|
; on every visible raster line via a cycle-timed loop, giving per-line colour.
|
||||||
|
;
|
||||||
|
; Memory layout of appended data (loads from $4000):
|
||||||
|
; $4000+L*$400 screen RAM for line L (L=0..7), 1000 bytes each
|
||||||
|
; $6000 bitmap 8000 (offset $2000 in VIC bank 1)
|
||||||
|
; $8000 colour RAM 1000 (copied to $D800)
|
||||||
|
; $83E8 background 1
|
||||||
|
;
|
||||||
|
; assembled by viewer/assemble.py via xa
|
||||||
|
|
||||||
|
; BASIC autostart, SYS 2061
|
||||||
|
* = $0801
|
||||||
|
.word basicend
|
||||||
|
.word 10
|
||||||
|
.byte $9e
|
||||||
|
.byte "2061"
|
||||||
|
.byte 0
|
||||||
|
basicend:
|
||||||
|
.word 0 ; ML begins at $080D
|
||||||
|
|
||||||
|
start:
|
||||||
|
sei
|
||||||
|
|
||||||
|
; copy colour RAM $8000 -> $D800 (1024 bytes)
|
||||||
|
ldx #0
|
||||||
|
ccopy:
|
||||||
|
lda $8000,x
|
||||||
|
sta $d800,x
|
||||||
|
lda $8100,x
|
||||||
|
sta $d900,x
|
||||||
|
lda $8200,x
|
||||||
|
sta $da00,x
|
||||||
|
lda $8300,x
|
||||||
|
sta $db00,x
|
||||||
|
inx
|
||||||
|
bne ccopy
|
||||||
|
|
||||||
|
; build per-line tables. d018tab = ((line AND 7) << 4) OR $08
|
||||||
|
; d011tab = $38 OR (line AND 7) = BMM+DEN+RSEL plus yscroll
|
||||||
|
ldx #0
|
||||||
|
btab:
|
||||||
|
txa
|
||||||
|
and #$07
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
asl
|
||||||
|
ora #$08
|
||||||
|
sta d018tab,x
|
||||||
|
; yscroll must equal (raster AND 7) to force a badline; the first
|
||||||
|
; displayed raster is 51, so line x maps to raster 51+x.
|
||||||
|
txa
|
||||||
|
clc
|
||||||
|
adc #$03
|
||||||
|
and #$07
|
||||||
|
ora #$38
|
||||||
|
sta d011tab,x
|
||||||
|
inx
|
||||||
|
bne btab
|
||||||
|
|
||||||
|
; VIC bank 1 ($4000-$7FFF)
|
||||||
|
lda $dd00
|
||||||
|
and #$fc
|
||||||
|
ora #$02
|
||||||
|
sta $dd00
|
||||||
|
|
||||||
|
lda $83e8
|
||||||
|
sta $d021 ; background
|
||||||
|
lda #$00
|
||||||
|
sta $d020 ; border black
|
||||||
|
lda #$d8
|
||||||
|
sta $d016 ; multicolor on
|
||||||
|
|
||||||
|
; raster IRQ setup
|
||||||
|
lda #<irq1
|
||||||
|
sta $0314
|
||||||
|
lda #>irq1
|
||||||
|
sta $0315
|
||||||
|
lda #$7f
|
||||||
|
sta $dc0d ; disable CIA timer IRQs
|
||||||
|
sta $dd0d
|
||||||
|
lda $dc0d
|
||||||
|
lda $dd0d
|
||||||
|
lda #$01
|
||||||
|
sta $d01a ; enable raster IRQ
|
||||||
|
lda #$30
|
||||||
|
sta $d012 ; line 48
|
||||||
|
lda $d011
|
||||||
|
and #$7f
|
||||||
|
sta $d011
|
||||||
|
asl $d019 ; ack
|
||||||
|
cli
|
||||||
|
hang:
|
||||||
|
jmp hang
|
||||||
|
|
||||||
|
; first IRQ, arrives with jitter on line 48, sets up the stabilised one
|
||||||
|
irq1:
|
||||||
|
lda #<irq2
|
||||||
|
sta $0314
|
||||||
|
lda #>irq2
|
||||||
|
sta $0315
|
||||||
|
inc $d012 ; fire again next line (49)
|
||||||
|
asl $d019 ; ack
|
||||||
|
tsx
|
||||||
|
cli
|
||||||
|
; burn the rest of the line so irq2 fires inside this NOP slide
|
||||||
|
.dsb 40,$ea ; 40 NOPs
|
||||||
|
|
||||||
|
; stabilised IRQ on line 49, runs the FLI loop
|
||||||
|
irq2:
|
||||||
|
txs ; restore sp from irq1
|
||||||
|
; cancel the remaining 0/1-cycle jitter: read the raster twice; the
|
||||||
|
; branch is 2 or 3 cycles depending on alignment, normalising entry.
|
||||||
|
lda $d012
|
||||||
|
cmp $d012
|
||||||
|
beq jl
|
||||||
|
jl:
|
||||||
|
; fixed delay so the first store lands before the c-access window
|
||||||
|
ldx #$0d
|
||||||
|
d0: dex
|
||||||
|
bne d0
|
||||||
|
nop
|
||||||
|
nop
|
||||||
|
|
||||||
|
ldx #$00
|
||||||
|
fliloop:
|
||||||
|
lda d011tab,x ; force badline (yscroll = line&7)
|
||||||
|
sta $d011
|
||||||
|
lda d018tab,x ; point video matrix at screen[line]
|
||||||
|
sta $d018
|
||||||
|
nop ; NTSC extra 2 cycles for 65 cycle line
|
||||||
|
inx
|
||||||
|
cpx #200
|
||||||
|
bne fliloop
|
||||||
|
|
||||||
|
; bottom border: leave bitmap on but stop forcing badlines
|
||||||
|
lda #<irq1
|
||||||
|
sta $0314
|
||||||
|
lda #>irq1
|
||||||
|
sta $0315
|
||||||
|
lda #$30
|
||||||
|
sta $d012
|
||||||
|
asl $d019
|
||||||
|
jmp $ea81 ; restore regs + RTI
|
||||||
|
|
||||||
|
d018tab:
|
||||||
|
.dsb 256,0
|
||||||
|
d011tab:
|
||||||
|
.dsb 256,0
|
||||||
80
c64view/viewer/hires.s
Normal file
80
c64view/viewer/hires.s
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
; c64view -- hires bitmap viewer (self-contained)
|
||||||
|
;
|
||||||
|
; The picture data is appended to this program by the exporter and loads in one
|
||||||
|
; pass. Fixed memory layout after load:
|
||||||
|
; $0801 this program (BASIC stub + ML, padded up to $2000)
|
||||||
|
; $2000 bitmap 8000 (VIC reads here directly)
|
||||||
|
; $3F40 screen 1000 (copied to $0400)
|
||||||
|
;
|
||||||
|
; assembled by viewer/assemble.py via xa
|
||||||
|
|
||||||
|
; BASIC autostart, SYS 2061
|
||||||
|
* = $0801
|
||||||
|
.word basicend
|
||||||
|
.word 10
|
||||||
|
.byte $9e
|
||||||
|
.byte "2061"
|
||||||
|
.byte 0
|
||||||
|
basicend:
|
||||||
|
.word 0 ; ML begins at $080D
|
||||||
|
|
||||||
|
SRC = $fb
|
||||||
|
DST = $fd
|
||||||
|
|
||||||
|
start:
|
||||||
|
lda #$0b
|
||||||
|
sta $d011 ; blank during setup
|
||||||
|
|
||||||
|
; copy screen RAM $3F40 -> $0400
|
||||||
|
lda #$40
|
||||||
|
sta SRC
|
||||||
|
lda #$3f
|
||||||
|
sta SRC+1
|
||||||
|
lda #$00
|
||||||
|
sta DST
|
||||||
|
lda #$04
|
||||||
|
sta DST+1
|
||||||
|
jsr copy1024
|
||||||
|
|
||||||
|
lda $dd00
|
||||||
|
ora #$03
|
||||||
|
sta $dd00 ; VIC bank 0
|
||||||
|
lda #$00
|
||||||
|
sta $d020
|
||||||
|
lda #$18
|
||||||
|
sta $d018 ; screen $0400, bitmap $2000
|
||||||
|
lda #$c8
|
||||||
|
sta $d016 ; hires (multicolor off)
|
||||||
|
lda #$3b
|
||||||
|
sta $d011 ; bitmap mode, display on
|
||||||
|
lda #$ff
|
||||||
|
sta $cc
|
||||||
|
|
||||||
|
waitkey:
|
||||||
|
jsr $ffe4
|
||||||
|
beq waitkey
|
||||||
|
|
||||||
|
lda #$1b
|
||||||
|
sta $d011
|
||||||
|
lda #$c8
|
||||||
|
sta $d016
|
||||||
|
lda #$15
|
||||||
|
sta $d018
|
||||||
|
lda #$00
|
||||||
|
sta $cc
|
||||||
|
jsr $e544
|
||||||
|
rts
|
||||||
|
|
||||||
|
copy1024:
|
||||||
|
ldx #4
|
||||||
|
ldy #0
|
||||||
|
cploop:
|
||||||
|
lda (SRC),y
|
||||||
|
sta (DST),y
|
||||||
|
iny
|
||||||
|
bne cploop
|
||||||
|
inc SRC+1
|
||||||
|
inc DST+1
|
||||||
|
dex
|
||||||
|
bne cploop
|
||||||
|
rts
|
||||||
139
c64view/viewer/interlace.s
Normal file
139
c64view/viewer/interlace.s
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
; c64view -- multicolor interlace viewer (self-contained)
|
||||||
|
;
|
||||||
|
; Shows two multicolor frames on alternating fields by flipping the VIC bank in a
|
||||||
|
; once-per-frame raster IRQ (no cycle-exact timing needed, so it is robust).
|
||||||
|
; Frame A lives in bank 0 (bitmap $2000, screen $0400); frame B in bank 1 (bitmap
|
||||||
|
; $6000, screen $4400). Both use $D018=$18; only the $DD00 bank bit toggles.
|
||||||
|
;
|
||||||
|
; Memory layout of appended data (loads from $2000):
|
||||||
|
; $2000 bitmap A 8000
|
||||||
|
; $3F40 screen A 1000 (copied to $0400)
|
||||||
|
; $4400 screen B 1000 (in place, bank 1 video matrix)
|
||||||
|
; $6000 bitmap B 8000
|
||||||
|
; $8000 colour RAM 1000 (copied to $D800)
|
||||||
|
; $83E8 background 1
|
||||||
|
;
|
||||||
|
; assembled by viewer/assemble.py via xa
|
||||||
|
|
||||||
|
; BASIC autostart, SYS 2061
|
||||||
|
* = $0801
|
||||||
|
.word basicend
|
||||||
|
.word 10
|
||||||
|
.byte $9e
|
||||||
|
.byte "2061"
|
||||||
|
.byte 0
|
||||||
|
basicend:
|
||||||
|
.word 0
|
||||||
|
|
||||||
|
SRC = $fb
|
||||||
|
DST = $fd
|
||||||
|
|
||||||
|
start:
|
||||||
|
sei
|
||||||
|
|
||||||
|
; screen A $3F40 -> $0400
|
||||||
|
lda #$40
|
||||||
|
sta SRC
|
||||||
|
lda #$3f
|
||||||
|
sta SRC+1
|
||||||
|
lda #$00
|
||||||
|
sta DST
|
||||||
|
lda #$04
|
||||||
|
sta DST+1
|
||||||
|
jsr copy1024
|
||||||
|
|
||||||
|
; colour RAM $8000 -> $D800
|
||||||
|
lda #$00
|
||||||
|
sta SRC
|
||||||
|
lda #$80
|
||||||
|
sta SRC+1
|
||||||
|
lda #$00
|
||||||
|
sta DST
|
||||||
|
lda #$d8
|
||||||
|
sta DST+1
|
||||||
|
jsr copy1024
|
||||||
|
|
||||||
|
lda $83e8
|
||||||
|
sta $d021 ; background
|
||||||
|
lda #$00
|
||||||
|
sta $d020 ; border black
|
||||||
|
lda $dd00
|
||||||
|
and #$fc
|
||||||
|
ora #$03
|
||||||
|
sta $dd00 ; start on VIC bank 0 (frame A)
|
||||||
|
lda #$18
|
||||||
|
sta $d018 ; screen $x400, bitmap $x000+$2000
|
||||||
|
lda #$d8
|
||||||
|
sta $d016 ; multicolor on
|
||||||
|
lda #$3b
|
||||||
|
sta $d011 ; bitmap mode, display on
|
||||||
|
|
||||||
|
; frame-flip raster IRQ near the bottom border
|
||||||
|
lda #<irq
|
||||||
|
sta $0314
|
||||||
|
lda #>irq
|
||||||
|
sta $0315
|
||||||
|
lda #$7f
|
||||||
|
sta $dc0d
|
||||||
|
sta $dd0d
|
||||||
|
lda $dc0d
|
||||||
|
lda $dd0d
|
||||||
|
lda #$01
|
||||||
|
sta $d01a
|
||||||
|
lda #$fa
|
||||||
|
sta $d012 ; line 250
|
||||||
|
lda $d011
|
||||||
|
and #$7f
|
||||||
|
sta $d011
|
||||||
|
asl $d019
|
||||||
|
cli
|
||||||
|
|
||||||
|
waitkey:
|
||||||
|
jsr $ffe4 ; GETIN (scanned via our IRQ -> $ea31)
|
||||||
|
beq waitkey
|
||||||
|
|
||||||
|
; restore text mode + KERNAL IRQ
|
||||||
|
sei
|
||||||
|
lda #$00
|
||||||
|
sta $d01a ; disable raster IRQ
|
||||||
|
lda #$81
|
||||||
|
sta $dc0d ; re-enable CIA timer IRQ
|
||||||
|
lda #$31
|
||||||
|
sta $0314
|
||||||
|
lda #$ea
|
||||||
|
sta $0315
|
||||||
|
lda #$1b
|
||||||
|
sta $d011
|
||||||
|
lda #$c8
|
||||||
|
sta $d016
|
||||||
|
lda #$15
|
||||||
|
sta $d018
|
||||||
|
lda $dd00
|
||||||
|
ora #$03
|
||||||
|
sta $dd00
|
||||||
|
asl $d019
|
||||||
|
cli
|
||||||
|
jsr $e544 ; clear screen
|
||||||
|
rts
|
||||||
|
|
||||||
|
; once per frame, flip bank 0 <-> bank 1, then let the KERNAL IRQ finish
|
||||||
|
irq:
|
||||||
|
lda $dd00
|
||||||
|
eor #$01
|
||||||
|
sta $dd00
|
||||||
|
asl $d019 ; ack raster IRQ
|
||||||
|
jmp $ea31 ; KERNAL housekeeping (keyboard) + RTI
|
||||||
|
|
||||||
|
copy1024:
|
||||||
|
ldx #4
|
||||||
|
ldy #0
|
||||||
|
cploop:
|
||||||
|
lda (SRC),y
|
||||||
|
sta (DST),y
|
||||||
|
iny
|
||||||
|
bne cploop
|
||||||
|
inc SRC+1
|
||||||
|
inc DST+1
|
||||||
|
dex
|
||||||
|
bne cploop
|
||||||
|
rts
|
||||||
98
c64view/viewer/multicolor.s
Normal file
98
c64view/viewer/multicolor.s
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
; c64view -- multicolor (Koala) bitmap viewer (self-contained)
|
||||||
|
;
|
||||||
|
; The picture data is appended to this program by the exporter and loads in one
|
||||||
|
; pass, so no second disk access is needed. Fixed memory layout after load:
|
||||||
|
; $0801 this program (BASIC stub + ML, padded up to $2000)
|
||||||
|
; $2000 bitmap 8000 (VIC reads here directly)
|
||||||
|
; $3F40 screen 1000 (copied to $0400)
|
||||||
|
; $4328 colram 1000 (copied to $D800)
|
||||||
|
; $4710 background 1
|
||||||
|
;
|
||||||
|
; assembled by viewer/assemble.py via xa
|
||||||
|
|
||||||
|
; BASIC autostart, SYS 2061
|
||||||
|
* = $0801
|
||||||
|
.word basicend
|
||||||
|
.word 10
|
||||||
|
.byte $9e
|
||||||
|
.byte "2061"
|
||||||
|
.byte 0
|
||||||
|
basicend:
|
||||||
|
.word 0 ; ML begins at $080D
|
||||||
|
|
||||||
|
SRC = $fb
|
||||||
|
DST = $fd
|
||||||
|
|
||||||
|
start:
|
||||||
|
lda #$0b
|
||||||
|
sta $d011 ; blank screen during setup
|
||||||
|
|
||||||
|
; copy screen RAM $3F40 -> $0400
|
||||||
|
lda #$40
|
||||||
|
sta SRC
|
||||||
|
lda #$3f
|
||||||
|
sta SRC+1
|
||||||
|
lda #$00
|
||||||
|
sta DST
|
||||||
|
lda #$04
|
||||||
|
sta DST+1
|
||||||
|
jsr copy1024
|
||||||
|
|
||||||
|
; copy colour RAM $4328 -> $D800
|
||||||
|
lda #$28
|
||||||
|
sta SRC
|
||||||
|
lda #$43
|
||||||
|
sta SRC+1
|
||||||
|
lda #$00
|
||||||
|
sta DST
|
||||||
|
lda #$d8
|
||||||
|
sta DST+1
|
||||||
|
jsr copy1024
|
||||||
|
|
||||||
|
; program the VIC-II
|
||||||
|
lda $dd00
|
||||||
|
ora #$03
|
||||||
|
sta $dd00 ; VIC bank 0 ($0000-$3FFF)
|
||||||
|
lda $4710
|
||||||
|
sta $d021 ; background colour
|
||||||
|
lda #$00
|
||||||
|
sta $d020 ; border black
|
||||||
|
lda #$18
|
||||||
|
sta $d018 ; screen $0400, bitmap $2000
|
||||||
|
lda #$d8
|
||||||
|
sta $d016 ; multicolor mode on
|
||||||
|
lda #$3b
|
||||||
|
sta $d011 ; bitmap mode, display on
|
||||||
|
lda #$ff
|
||||||
|
sta $cc ; disable cursor blink
|
||||||
|
|
||||||
|
waitkey:
|
||||||
|
jsr $ffe4 ; GETIN
|
||||||
|
beq waitkey
|
||||||
|
|
||||||
|
; restore text mode and return to BASIC
|
||||||
|
lda #$1b
|
||||||
|
sta $d011
|
||||||
|
lda #$c8
|
||||||
|
sta $d016
|
||||||
|
lda #$15
|
||||||
|
sta $d018
|
||||||
|
lda #$00
|
||||||
|
sta $cc
|
||||||
|
jsr $e544 ; clear screen
|
||||||
|
rts
|
||||||
|
|
||||||
|
; copy 1024 bytes from (SRC) to (DST)
|
||||||
|
copy1024:
|
||||||
|
ldx #4
|
||||||
|
ldy #0
|
||||||
|
cploop:
|
||||||
|
lda (SRC),y
|
||||||
|
sta (DST),y
|
||||||
|
iny
|
||||||
|
bne cploop
|
||||||
|
inc SRC+1
|
||||||
|
inc DST+1
|
||||||
|
dex
|
||||||
|
bne cploop
|
||||||
|
rts
|
||||||
BIN
docs/gui.png
Normal file
BIN
docs/gui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "c64view"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Convert modern images into Commodore 64 disk images with a built-in viewer"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.22",
|
||||||
|
"Pillow>=9.0",
|
||||||
|
# PyQt5 is needed only for the GUI; the CLI works without it.
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
gui = ["PyQt5>=5.15"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
c64view-cli = "c64view.cli:main"
|
||||||
|
|
||||||
|
[project.gui-scripts]
|
||||||
|
c64view = "c64view.gui:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["c64view*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"c64view.viewer" = ["*.s"]
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
numpy>=1.22
|
||||||
|
Pillow>=9.0
|
||||||
|
PyQt5>=5.15
|
||||||
BIN
samples/test.png
Normal file
BIN
samples/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
118
tests/test_roundtrip.py
Normal file
118
tests/test_roundtrip.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Regression tests: decode each mode's emitted VIC-II bytes and check they
|
||||||
|
reproduce the converter's own index image, and that every viewer assembles and
|
||||||
|
fits. Run with `pytest` or directly: `python tests/test_roundtrip.py`.
|
||||||
|
|
||||||
|
These tests exercise the byte-packing that the GUI preview deliberately does *not*
|
||||||
|
touch (the preview renders from the index image), so they are the safety net that
|
||||||
|
catches an encoding bug before it reaches a real C64.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from c64view import imageprep, palette as pal # noqa: E402
|
||||||
|
from c64view.convert import fli, hires, ifli, multicolor # noqa: E402
|
||||||
|
from c64view.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _gradient(w, h):
|
||||||
|
yy, xx = np.mgrid[0:h, 0:w]
|
||||||
|
rgb = np.stack([(xx * 255 // w), (yy * 255 // h),
|
||||||
|
((xx + yy) * 255 // (w + h))], axis=-1)
|
||||||
|
return rgb.astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_mc(bitmap, screen, colram, bg):
|
||||||
|
dec = np.zeros((200, 160), np.uint8)
|
||||||
|
for cr in range(25):
|
||||||
|
for cc in range(40):
|
||||||
|
ci = cr * 40 + cc
|
||||||
|
lut = [bg, screen[ci] >> 4, screen[ci] & 0xF, colram[ci] & 0xF]
|
||||||
|
for r in range(8):
|
||||||
|
byte = bitmap[cr * 320 + cc * 8 + r]
|
||||||
|
for x in range(4):
|
||||||
|
dec[cr * 8 + r, cc * 4 + x] = lut[(byte >> (6 - 2 * x)) & 3]
|
||||||
|
return dec
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicolor_roundtrip():
|
||||||
|
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
|
||||||
|
c = multicolor.convert(img)
|
||||||
|
d = c.data
|
||||||
|
dec = _decode_mc(np.frombuffer(d[:8000], np.uint8),
|
||||||
|
np.frombuffer(d[8000:9000], np.uint8),
|
||||||
|
np.frombuffer(d[9000:10000], np.uint8), d[10000])
|
||||||
|
assert np.array_equal(dec, c.index_image)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hires_roundtrip():
|
||||||
|
img = imageprep.prepare(_imgobj(320, 200), 320, 200, 1.0, imageprep.PrepOptions())
|
||||||
|
c = hires.convert(img)
|
||||||
|
bitmap = np.frombuffer(c.data[:8000], np.uint8)
|
||||||
|
screen = np.frombuffer(c.data[8000:9000], np.uint8)
|
||||||
|
dec = np.zeros((200, 320), np.uint8)
|
||||||
|
for cr in range(25):
|
||||||
|
for cc in range(40):
|
||||||
|
ci = cr * 40 + cc
|
||||||
|
fg, bgc = screen[ci] >> 4, screen[ci] & 0xF
|
||||||
|
for r in range(8):
|
||||||
|
byte = bitmap[cr * 320 + cc * 8 + r]
|
||||||
|
for x in range(8):
|
||||||
|
dec[cr * 8 + r, cc * 8 + x] = fg if (byte >> (7 - x)) & 1 else bgc
|
||||||
|
assert np.array_equal(dec, c.index_image)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fli_roundtrip():
|
||||||
|
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
|
||||||
|
c = fli.convert(img)
|
||||||
|
d = c.data
|
||||||
|
screens = [np.frombuffer(d[L * 1024:L * 1024 + 1000], np.uint8) for L in range(8)]
|
||||||
|
bitmap = np.frombuffer(d[8192:8192 + 8000], np.uint8)
|
||||||
|
colram = np.frombuffer(d[16384:16384 + 1000], np.uint8)
|
||||||
|
bg = d[17384]
|
||||||
|
dec = np.zeros((200, 160), np.uint8)
|
||||||
|
for cr in range(25):
|
||||||
|
for cc in range(40):
|
||||||
|
ci = cr * 40 + cc
|
||||||
|
for r in range(8):
|
||||||
|
sb = screens[r][ci]
|
||||||
|
lut = [bg, sb >> 4, sb & 0xF, colram[ci] & 0xF]
|
||||||
|
byte = bitmap[cr * 320 + cc * 8 + r]
|
||||||
|
for x in range(4):
|
||||||
|
dec[cr * 8 + r, cc * 4 + x] = lut[(byte >> (6 - 2 * x)) & 3]
|
||||||
|
assert np.array_equal(dec, c.index_image)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interlace_blend_better():
|
||||||
|
"""Interlace blend error should beat plain multicolor on a gradient."""
|
||||||
|
img = imageprep.prepare(_imgobj(160, 200), 160, 200, 2.0, imageprep.PrepOptions())
|
||||||
|
assert ifli.convert(img).error < multicolor.convert(img).error + 1e-6
|
||||||
|
assert len(ifli.convert(img).data) == 25577
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewers_assemble_and_fit():
|
||||||
|
if not have_xa():
|
||||||
|
return # xa not installed; skip
|
||||||
|
sizes = {"hires": 9000, "multicolor": 10001, "fli": 17385,
|
||||||
|
"fli_ntsc": 17385, "interlace": 25577}
|
||||||
|
for key in SOURCES:
|
||||||
|
prg = build_viewer_prg(key, bytes(sizes[key]),
|
||||||
|
0x4000 if key.startswith("fli") else 0x2000)
|
||||||
|
assert prg[:2] == bytes([0x01, 0x08]) # PRG load address $0801
|
||||||
|
|
||||||
|
|
||||||
|
def _imgobj(w, h):
|
||||||
|
from PIL import Image
|
||||||
|
return Image.fromarray(_gradient(w, h), "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
for fn in fns:
|
||||||
|
fn()
|
||||||
|
print(f"PASS {fn.__name__}")
|
||||||
|
print(f"\nAll {len(fns)} tests passed.")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue