commit 2a48f5297963888f99213eb28e2f70c069b26d01 Author: The Dust Council Date: Sun Jun 14 17:43:12 2026 -0700 Working Python version for Commodore. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3170657 --- /dev/null +++ b/README.md @@ -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. + +![c64view GUI](docs/gui.png) + +## 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,}`, `--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`. diff --git a/c64view/__init__.py b/c64view/__init__.py new file mode 100644 index 0000000..55275d4 --- /dev/null +++ b/c64view/__init__.py @@ -0,0 +1,3 @@ +"""c64view -- convert modern images into Commodore 64 disk images with a viewer.""" + +__version__ = "0.1.0" diff --git a/c64view/__pycache__/__init__.cpython-313.pyc b/c64view/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4ccefee Binary files /dev/null and b/c64view/__pycache__/__init__.cpython-313.pyc differ diff --git a/c64view/__pycache__/basicgen.cpython-313.pyc b/c64view/__pycache__/basicgen.cpython-313.pyc new file mode 100644 index 0000000..357d57c Binary files /dev/null and b/c64view/__pycache__/basicgen.cpython-313.pyc differ diff --git a/c64view/__pycache__/cli.cpython-313.pyc b/c64view/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..1ce970d Binary files /dev/null and b/c64view/__pycache__/cli.cpython-313.pyc differ diff --git a/c64view/__pycache__/diskimage.cpython-313.pyc b/c64view/__pycache__/diskimage.cpython-313.pyc new file mode 100644 index 0000000..ba3e2e0 Binary files /dev/null and b/c64view/__pycache__/diskimage.cpython-313.pyc differ diff --git a/c64view/__pycache__/dither.cpython-313.pyc b/c64view/__pycache__/dither.cpython-313.pyc new file mode 100644 index 0000000..49254f7 Binary files /dev/null and b/c64view/__pycache__/dither.cpython-313.pyc differ diff --git a/c64view/__pycache__/exporter.cpython-313.pyc b/c64view/__pycache__/exporter.cpython-313.pyc new file mode 100644 index 0000000..dcf1825 Binary files /dev/null and b/c64view/__pycache__/exporter.cpython-313.pyc differ diff --git a/c64view/__pycache__/gallery.cpython-313.pyc b/c64view/__pycache__/gallery.cpython-313.pyc new file mode 100644 index 0000000..9169874 Binary files /dev/null and b/c64view/__pycache__/gallery.cpython-313.pyc differ diff --git a/c64view/__pycache__/gui.cpython-313.pyc b/c64view/__pycache__/gui.cpython-313.pyc new file mode 100644 index 0000000..6f55ef7 Binary files /dev/null and b/c64view/__pycache__/gui.cpython-313.pyc differ diff --git a/c64view/__pycache__/imageprep.cpython-313.pyc b/c64view/__pycache__/imageprep.cpython-313.pyc new file mode 100644 index 0000000..f94e8bc Binary files /dev/null and b/c64view/__pycache__/imageprep.cpython-313.pyc differ diff --git a/c64view/__pycache__/imginfo.cpython-313.pyc b/c64view/__pycache__/imginfo.cpython-313.pyc new file mode 100644 index 0000000..0741b58 Binary files /dev/null and b/c64view/__pycache__/imginfo.cpython-313.pyc differ diff --git a/c64view/__pycache__/palette.cpython-313.pyc b/c64view/__pycache__/palette.cpython-313.pyc new file mode 100644 index 0000000..70155dc Binary files /dev/null and b/c64view/__pycache__/palette.cpython-313.pyc differ diff --git a/c64view/basicgen.py b/c64view/basicgen.py new file mode 100644 index 0000000..db6aabb --- /dev/null +++ b/c64view/basicgen.py @@ -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) diff --git a/c64view/cli.py b/c64view/cli.py new file mode 100644 index 0000000..60e6ce0 --- /dev/null +++ b/c64view/cli.py @@ -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()) diff --git a/c64view/convert/__init__.py b/c64view/convert/__init__.py new file mode 100644 index 0000000..1e84eeb --- /dev/null +++ b/c64view/convert/__init__.py @@ -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 diff --git a/c64view/convert/__pycache__/__init__.cpython-313.pyc b/c64view/convert/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6712eba Binary files /dev/null and b/c64view/convert/__pycache__/__init__.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/base.cpython-313.pyc b/c64view/convert/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..f21e43f Binary files /dev/null and b/c64view/convert/__pycache__/base.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/fli.cpython-313.pyc b/c64view/convert/__pycache__/fli.cpython-313.pyc new file mode 100644 index 0000000..4fa9001 Binary files /dev/null and b/c64view/convert/__pycache__/fli.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/hires.cpython-313.pyc b/c64view/convert/__pycache__/hires.cpython-313.pyc new file mode 100644 index 0000000..aa44d41 Binary files /dev/null and b/c64view/convert/__pycache__/hires.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/ifli.cpython-313.pyc b/c64view/convert/__pycache__/ifli.cpython-313.pyc new file mode 100644 index 0000000..a9d190a Binary files /dev/null and b/c64view/convert/__pycache__/ifli.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/mono.cpython-313.pyc b/c64view/convert/__pycache__/mono.cpython-313.pyc new file mode 100644 index 0000000..ebb15cc Binary files /dev/null and b/c64view/convert/__pycache__/mono.cpython-313.pyc differ diff --git a/c64view/convert/__pycache__/multicolor.cpython-313.pyc b/c64view/convert/__pycache__/multicolor.cpython-313.pyc new file mode 100644 index 0000000..9c9fa62 Binary files /dev/null and b/c64view/convert/__pycache__/multicolor.cpython-313.pyc differ diff --git a/c64view/convert/base.py b/c64view/convert/base.py new file mode 100644 index 0000000..cb83e38 --- /dev/null +++ b/c64view/convert/base.py @@ -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()) diff --git a/c64view/convert/fli.py b/c64view/convert/fli.py new file mode 100644 index 0000000..7aad0a2 --- /dev/null +++ b/c64view/convert/fli.py @@ -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) diff --git a/c64view/convert/hires.py b/c64view/convert/hires.py new file mode 100644 index 0000000..1a28a3e --- /dev/null +++ b/c64view/convert/hires.py @@ -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 diff --git a/c64view/convert/ifli.py b/c64view/convert/ifli.py new file mode 100644 index 0000000..f15d432 --- /dev/null +++ b/c64view/convert/ifli.py @@ -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) diff --git a/c64view/convert/mono.py b/c64view/convert/mono.py new file mode 100644 index 0000000..0dac911 --- /dev/null +++ b/c64view/convert/mono.py @@ -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 diff --git a/c64view/convert/multicolor.py b/c64view/convert/multicolor.py new file mode 100644 index 0000000..0abdc70 --- /dev/null +++ b/c64view/convert/multicolor.py @@ -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 diff --git a/c64view/diskimage.py b/c64view/diskimage.py new file mode 100644 index 0000000..af53125 --- /dev/null +++ b/c64view/diskimage.py @@ -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 diff --git a/c64view/dither.py b/c64view/dither.py new file mode 100644 index 0000000..8b80b92 --- /dev/null +++ b/c64view/dither.py @@ -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) diff --git a/c64view/exporter.py b/c64view/exporter.py new file mode 100644 index 0000000..e816e31 --- /dev/null +++ b/c64view/exporter.py @@ -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) diff --git a/c64view/gallery.py b/c64view/gallery.py new file mode 100644 index 0000000..997b525 --- /dev/null +++ b/c64view/gallery.py @@ -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) diff --git a/c64view/gui.py b/c64view/gui.py new file mode 100644 index 0000000..e2ae173 --- /dev/null +++ b/c64view/gui.py @@ -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()) diff --git a/c64view/imageprep.py b/c64view/imageprep.py new file mode 100644 index 0000000..b7ec9be --- /dev/null +++ b/c64view/imageprep.py @@ -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) diff --git a/c64view/imginfo.py b/c64view/imginfo.py new file mode 100644 index 0000000..85c749a --- /dev/null +++ b/c64view/imginfo.py @@ -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) diff --git a/c64view/palette.py b/c64view/palette.py new file mode 100644 index 0000000..2194a8b --- /dev/null +++ b/c64view/palette.py @@ -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)) diff --git a/c64view/viewer/__init__.py b/c64view/viewer/__init__.py new file mode 100644 index 0000000..29012b5 --- /dev/null +++ b/c64view/viewer/__init__.py @@ -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, +) diff --git a/c64view/viewer/__pycache__/__init__.cpython-313.pyc b/c64view/viewer/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..93c49dd Binary files /dev/null and b/c64view/viewer/__pycache__/__init__.cpython-313.pyc differ diff --git a/c64view/viewer/__pycache__/assemble.cpython-313.pyc b/c64view/viewer/__pycache__/assemble.cpython-313.pyc new file mode 100644 index 0000000..7031250 Binary files /dev/null and b/c64view/viewer/__pycache__/assemble.cpython-313.pyc differ diff --git a/c64view/viewer/assemble.py b/c64view/viewer/assemble.py new file mode 100644 index 0000000..c17750f --- /dev/null +++ b/c64view/viewer/assemble.py @@ -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 diff --git a/c64view/viewer/fli.s b/c64view/viewer/fli.s new file mode 100644 index 0000000..7c8b3cd --- /dev/null +++ b/c64view/viewer/fli.s @@ -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 $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 $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 $0315 + lda #$30 + sta $d012 + asl $d019 + jmp $ea81 ; restore regs + RTI + +d018tab: + .dsb 256,0 +d011tab: + .dsb 256,0 diff --git a/c64view/viewer/fli_ntsc.s b/c64view/viewer/fli_ntsc.s new file mode 100644 index 0000000..3683b17 --- /dev/null +++ b/c64view/viewer/fli_ntsc.s @@ -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 $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 $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 $0315 + lda #$30 + sta $d012 + asl $d019 + jmp $ea81 ; restore regs + RTI + +d018tab: + .dsb 256,0 +d011tab: + .dsb 256,0 diff --git a/c64view/viewer/hires.s b/c64view/viewer/hires.s new file mode 100644 index 0000000..cc4080e --- /dev/null +++ b/c64view/viewer/hires.s @@ -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 diff --git a/c64view/viewer/interlace.s b/c64view/viewer/interlace.s new file mode 100644 index 0000000..e261f39 --- /dev/null +++ b/c64view/viewer/interlace.s @@ -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 $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 diff --git a/c64view/viewer/multicolor.s b/c64view/viewer/multicolor.s new file mode 100644 index 0000000..a94d559 --- /dev/null +++ b/c64view/viewer/multicolor.s @@ -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 diff --git a/docs/gui.png b/docs/gui.png new file mode 100644 index 0000000..471e175 Binary files /dev/null and b/docs/gui.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b567aad --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..69854a4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy>=1.22 +Pillow>=9.0 +PyQt5>=5.15 diff --git a/samples/test.png b/samples/test.png new file mode 100644 index 0000000..5ff1833 Binary files /dev/null and b/samples/test.png differ diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 0000000..a47c7c9 --- /dev/null +++ b/tests/test_roundtrip.py @@ -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.")