From 2a48f5297963888f99213eb28e2f70c069b26d01 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Sun, 14 Jun 2026 17:43:12 -0700 Subject: [PATCH] Working Python version for Commodore. --- README.md | 125 +++++ c64view/__init__.py | 3 + c64view/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 261 bytes c64view/__pycache__/basicgen.cpython-313.pyc | Bin 0 -> 6675 bytes c64view/__pycache__/cli.cpython-313.pyc | Bin 0 -> 5140 bytes c64view/__pycache__/diskimage.cpython-313.pyc | Bin 0 -> 7419 bytes c64view/__pycache__/dither.cpython-313.pyc | Bin 0 -> 7238 bytes c64view/__pycache__/exporter.cpython-313.pyc | Bin 0 -> 3171 bytes c64view/__pycache__/gallery.cpython-313.pyc | Bin 0 -> 1503 bytes c64view/__pycache__/gui.cpython-313.pyc | Bin 0 -> 33544 bytes c64view/__pycache__/imageprep.cpython-313.pyc | Bin 0 -> 4667 bytes c64view/__pycache__/imginfo.cpython-313.pyc | Bin 0 -> 6951 bytes c64view/__pycache__/palette.cpython-313.pyc | Bin 0 -> 4123 bytes c64view/basicgen.py | 120 +++++ c64view/cli.py | 80 +++ c64view/convert/__init__.py | 81 +++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 3507 bytes .../convert/__pycache__/base.cpython-313.pyc | Bin 0 -> 7197 bytes .../convert/__pycache__/fli.cpython-313.pyc | Bin 0 -> 8141 bytes .../convert/__pycache__/hires.cpython-313.pyc | Bin 0 -> 3572 bytes .../convert/__pycache__/ifli.cpython-313.pyc | Bin 0 -> 9635 bytes .../convert/__pycache__/mono.cpython-313.pyc | Bin 0 -> 4127 bytes .../__pycache__/multicolor.cpython-313.pyc | Bin 0 -> 4487 bytes c64view/convert/base.py | 124 +++++ c64view/convert/fli.py | 142 +++++ c64view/convert/hires.py | 64 +++ c64view/convert/ifli.py | 151 ++++++ c64view/convert/mono.py | 77 +++ c64view/convert/multicolor.py | 80 +++ c64view/diskimage.py | 145 ++++++ c64view/dither.py | 136 +++++ c64view/exporter.py | 51 ++ c64view/gallery.py | 29 ++ c64view/gui.py | 490 ++++++++++++++++++ c64view/imageprep.py | 78 +++ c64view/imginfo.py | 142 +++++ c64view/palette.py | 111 ++++ c64view/viewer/__init__.py | 9 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 366 bytes .../__pycache__/assemble.cpython-313.pyc | Bin 0 -> 4399 bytes c64view/viewer/assemble.py | 82 +++ c64view/viewer/fli.s | 151 ++++++ c64view/viewer/fli_ntsc.s | 155 ++++++ c64view/viewer/hires.s | 80 +++ c64view/viewer/interlace.s | 139 +++++ c64view/viewer/multicolor.s | 98 ++++ docs/gui.png | Bin 0 -> 99726 bytes pyproject.toml | 31 ++ requirements.txt | 3 + samples/test.png | Bin 0 -> 13744 bytes tests/test_roundtrip.py | 118 +++++ 51 files changed, 3095 insertions(+) create mode 100644 README.md create mode 100644 c64view/__init__.py create mode 100644 c64view/__pycache__/__init__.cpython-313.pyc create mode 100644 c64view/__pycache__/basicgen.cpython-313.pyc create mode 100644 c64view/__pycache__/cli.cpython-313.pyc create mode 100644 c64view/__pycache__/diskimage.cpython-313.pyc create mode 100644 c64view/__pycache__/dither.cpython-313.pyc create mode 100644 c64view/__pycache__/exporter.cpython-313.pyc create mode 100644 c64view/__pycache__/gallery.cpython-313.pyc create mode 100644 c64view/__pycache__/gui.cpython-313.pyc create mode 100644 c64view/__pycache__/imageprep.cpython-313.pyc create mode 100644 c64view/__pycache__/imginfo.cpython-313.pyc create mode 100644 c64view/__pycache__/palette.cpython-313.pyc create mode 100644 c64view/basicgen.py create mode 100644 c64view/cli.py create mode 100644 c64view/convert/__init__.py create mode 100644 c64view/convert/__pycache__/__init__.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/base.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/fli.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/hires.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/ifli.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/mono.cpython-313.pyc create mode 100644 c64view/convert/__pycache__/multicolor.cpython-313.pyc create mode 100644 c64view/convert/base.py create mode 100644 c64view/convert/fli.py create mode 100644 c64view/convert/hires.py create mode 100644 c64view/convert/ifli.py create mode 100644 c64view/convert/mono.py create mode 100644 c64view/convert/multicolor.py create mode 100644 c64view/diskimage.py create mode 100644 c64view/dither.py create mode 100644 c64view/exporter.py create mode 100644 c64view/gallery.py create mode 100644 c64view/gui.py create mode 100644 c64view/imageprep.py create mode 100644 c64view/imginfo.py create mode 100644 c64view/palette.py create mode 100644 c64view/viewer/__init__.py create mode 100644 c64view/viewer/__pycache__/__init__.cpython-313.pyc create mode 100644 c64view/viewer/__pycache__/assemble.cpython-313.pyc create mode 100644 c64view/viewer/assemble.py create mode 100644 c64view/viewer/fli.s create mode 100644 c64view/viewer/fli_ntsc.s create mode 100644 c64view/viewer/hires.s create mode 100644 c64view/viewer/interlace.s create mode 100644 c64view/viewer/multicolor.s create mode 100644 docs/gui.png create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 samples/test.png create mode 100644 tests/test_roundtrip.py 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 0000000000000000000000000000000000000000..4ccefeefa7833a395cd0d1046f5f042432b76f96 GIT binary patch literal 261 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 literal 0 HcmV?d00001 diff --git a/c64view/__pycache__/cli.cpython-313.pyc b/c64view/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ce970d42a843c638af787b3b9d714a66c3598cb GIT binary patch literal 5140 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 literal 0 HcmV?d00001 diff --git a/c64view/__pycache__/diskimage.cpython-313.pyc b/c64view/__pycache__/diskimage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba3e2e0454fa51d77b548994604da1ee1f2b3a85 GIT binary patch literal 7419 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 m&@NxeE2-G z%$ySjyHR@K}ft}=IwiL-uvF) z9(8uQ2q%*PAJtNgpclnL)lql+Cj)e1Wk7z-_N&iX|N2#3<)pDokOCd=1RbHB3(tkck{sE7mO4jR4a;6N%8LjA0 zCzJywtB2tuU~Z!_YMKbe-SH4JmriWZT;`NLWqMIy7E#$O;bF|IC3w}Q#qM&pYy`M$ z1(^|x*-Q4RM(WWT$ueiLZ*hDM2H?L`c81Isk#F7n?s~gulqJyOnAaIJVr3-~HMz1v z-X#5Tl&s~+2J}8p;&$uvq?&df1W>(jeM?*W1Fkzqlcr#j^#6}J6@rUA5w#mW*@Drb znk99a&PlZ{;|3J7dchS`@#qEp5qOW7=o{&hAh%^D`Yh+^k`t~4+got(q z+%ex&h7PPcj&X)8+Mvp7gAG$v4&G5F>zh?y0@hX!i76|P-#z4;Lf2S%vyk0P ztYJu@YbbPqm6>MY=aiw-R)#K+fZapNK8~xbc1Y#@t3F@|?v$Y?N@XE$sRx3bkb0%C z!IDCiZWbzys9B|j%J7iR_w=qsjRvgYK)j>6j>q(L^F%NBg8fEv0ge`W(t(OY2S&bC z`qbWg?vZ!)Cy#vZkpr)=_c|P{(j)KL-Lv<%9=`PI{pN$@FpE2;ajW#33(~vMfm>`{ zdDzjlJACKT7nknJeQ$8z7(4>c=v}{4*=_1_sJ9_44G;)AOe(896*WUL?%TbVseQ2Q<`hLuf zeO}(hUry|O@NMnuGapBv_}|oS`S&Jn`Ja`4$c?qEBg?}>fBj)2cN|OI|2yX|pK<;M DI^wBj literal 0 HcmV?d00001 diff --git a/c64view/__pycache__/gui.cpython-313.pyc b/c64view/__pycache__/gui.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f55ef75d1ee6ec91ad2d9653db9d882a0ba6595 GIT binary patch literal 33544 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 literal 0 HcmV?d00001 diff --git a/c64view/__pycache__/imginfo.cpython-313.pyc b/c64view/__pycache__/imginfo.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0741b58b229fb0fee427cec6454ca9da10068846 GIT binary patch literal 6951 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6712ebab88352378332eff77b53a5c1622a6e05f GIT binary patch literal 3507 zcmcImU2GHC6~5yc|IOGF=a1h&hCoTYEKUf75|#h~yd(hvwXtd`p~J);%&^XkxiiiW zS|L}wnB7WH)QWV41br&|)KprjecWC3p--!N(^b5;sfx7QR!Ds?MXjpsOV7O?Cny`# zr(Rok?wot>Ip>}^=R4*&$eTRo>Ob4JIu zVVO$SunU@#xvuO}3Rs^p^jQV#W>&|BnTtKAhDA9dX{Kp8nga*y@#h{&^pK!2t>tt$ zR3QF>me(Cee@^cb6^JmU**fuFH?VFKVcIe+B2E|cj*+qQ7LM{b2z*$g6a9*yfxnhF zaHzf(40r+YrlaG$meDhQpf?6R33ugQ{(*hlly`{hard(A)CUs{PL!aAt{Yvb;dj%U z;*#9aG(y^22qitHKgqYEkpl>sfeCNpSUQm2osJLq>hESS<)7+6C>2b&lm;Jtru-XG zkG3F`1xqgU(sQQ&!a;;2uruY~gC+uv^C^z1G{p%P)s&i`-dH!fP|DMYmy|aluiJ)K zCPjM12a#{{bfL7E0ISxIl$dB+PXn99#HMdk(-TQy&}W9ghhFBxX2!X71I|y=hf=>P}G0xi%Sqx8rBufR9KKH*DS1Dy*h1W^{ZEbnpH9^f*r+hV(!LJ zw^nqlZrFFQq1%cM5k4>44sdHYtI)ITB-WiGHf_bZt}A(KR=1t~I>sO$ViiJ^ilfo1 zvQPzSJ#K7^24uzDn+kA_Tc*xT%3*EZ&Oq!(70b-eD_WtDr@9@BiKiBu7RaE=V?>|^ zjz>N9z^8!>fiF&37_v>pxv54jh->G*kdIC&|4`ZDpm zTB2bh0YY3?Eer~CG@Y|L3Av(GQ=8U_lr_MeSY=x*aZTHp(Mf=6Qmul6X}AF_fVT-Z zO}Gs40dFy{V~86>{2-Wmzz)Nk^6DT5d#6pOH>=O|X7XAwtM_IOAEGF=_N+%juc{iR z;izh?Fpp^%vq^!4y#iAS)npXevfR}FXmELZ-{;%=mbUkoMxObl^30vv)uz_6@W|89(D|fesX6v^Zn^hN1^&(P(%6c;d0FoKT<)v}S|3jSc=E~N zUkv?xXmRgyf4tHkuLdG7ynK_dbQS~#LUnuE@7=z8dm*s~@hGyhhPcQ8Ou>O? zQuDpzcaJZ0FK%CwMt(1hyoCFq41nV5ps>O~^6zz!M2Xd{wZ+sBr0j+}CZF;EH2wsj zF^bT{DvT!FBwdq%P}l$5PxmK1Nq&&Yq)(8}1N5x|Xkrxv*W?1waSfnL&^obhykgS^ zFxfb13h9%t1xbE_h8`Oet&J7B1IP^arTs=VZgPRD4w@+2B4c# zFy{2U4P;%JHL|*?XO*dWrhq0qh-M6mW*78~qx9I8vR3Tf!y-NG%o-U0VVg;bzNzGl z8R#;=lA>t1>ytz(KQEerp#MlB0kM_<_plA6hYzhb6Yqfau6H!B^3zZv-WW~F7c ze7f4yQWn3Kw?Rs_Mis$znqgqH`McIv~LOR1N@g;t1TVXP~?SQ2zg3@nk;a^KMC#COL3pB&-ne9U*s%YQ0xF#i`H5;v^? literal 0 HcmV?d00001 diff --git a/c64view/convert/__pycache__/base.cpython-313.pyc b/c64view/convert/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f21e43f6337f34ec97846b4c67280bc303c4d4e2 GIT binary patch literal 7197 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

5cwn(Q$>iYMne=GAe8Vb*Od(H zFworgr72uQv~9K3y*;>J2#m3thbw4Y&hl}H4_jW2ouDJQ>=9=sb;A;KmXlE3v*b>& z1{zLcNkn;r#FvzRYq1SM$WXJ0qqwh`AY2~VkPl#tLmG?1!;lc{T)DD*r5s+)6;Evh zLaW{1pDdo(Xb-F0wXQzyTolUNDqVX%?}}EsqIa*YcSYB` z_LscB4)>N^8y%6=gWtbea@GQ&Tj5*dtH$b<$v~P6v+&*>d)JI1@K7Hr(TKs4IKOgwXK;@Yu_vb&ocK>p9}{) zsYz!q)b+5m8|RSm)7*1HR9brHSXzf8#C4g0TmbwY#3VfBqa?4+GrgcS5>E0!4k37k zyCfqgUq6GS3}u}fdd%>dNV^~@L+Kb>y$nK`kw-)av>O+x8r&KnxlSp5##K;H0etsJC;HA0HMGA4*B+$L~qh^%+6b41}~mo zHJhUk+_uWHTpZ6J=dtC@*-x%h&zn%nOKM`ad3y#!E&K_7hKUO9w_AvG-R^m>r#Jyg zDjxrJM_+T!H@{8zgWK46gB z^Eck{eeR#SQ6-x`GKR@d;~xAQ0KQFHVddV0O{~i$m#G`mCPmwE2akl`C3pxcY2Ils zIco5n#ifZ8H}W9%Hzf0PN*^IQmM;#H=!`m=O zh9v6a%waEJFZLo;2dH|UD)M+43Ag1~T(lfFZde{})u2{6MV@f4?l@!XL_Gs#Z_O6* zbrwH^yl&3Aj?l_mH{U9U%kFCX&Ua20kJdtw_X2BDCAj;pV zNW!Uo5&)=OrzGfycy$Opw2mRXQM)(Nq;(5s)7K?BgTBtC*Df+8FEeEUX;z4{tl*z~ z?C4qf{M0FVAe%AGtU9EEU?4Q|Y$=&?WU7H2GgD!8kYe})mU=*n3(tr=KFWI%CeNnv z1l3dVr8Cdw+1tP)i8i9Bio$>RL#xqpsuJm6`|iD6KlsyoW@Y!0_3%qo|I7dA`ZV=R zf5kt!E=}4r0S%gkG!m!D`v5)=rez)nIG^0OC6Xg)-BjT1LFVi-MaF(pfz0mbaiYlM zM8uVcnpFM&v|&TaL1Ag>_*n#B%@lx|HI<%%gfc{-dA|sw#-4?vohTn8}qCBhf&dM~S$WV9#yuuqoE_QBauRiiv#>ACSBn zl&C?F*WOoY-vhJl54|6`-FvI|qsYfScY4-(D!caIQ!4Tc_h&0x#;f6@pNCIX!l$a? zGgbe@x-`KVgjkfvnPEBve^C?{+0Tj~%#YImpFi4wEVr$$`S4RqG*Q^Qgv^%^nG6^= z;E6Kgz2!{c=Bq_HbBL572?ApX`kgE`GOi)@|cdCAT{zrspDCUs)0qI&2Q zMez$qn<)K0B8boaP8j%Cq5ro+1ip;J|KE;wap<9d>X8%Ohtz#6yTvV!BQ9}Td>oR* zeP4F$6t{kHTojIpr^U}4zl?q<1jNw89WHTeZO5*MPHLYUw%g~&?Dohjc6;pXW14>) Ra)_^szxO%CNs*uU{{Z?3nk)bS literal 0 HcmV?d00001 diff --git a/c64view/convert/__pycache__/fli.cpython-313.pyc b/c64view/convert/__pycache__/fli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fa900179250a39d6b0ee00022e8166c519becb0 GIT binary patch literal 8141 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 literal 0 HcmV?d00001 diff --git a/c64view/convert/__pycache__/ifli.cpython-313.pyc b/c64view/convert/__pycache__/ifli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9d190a56e8c7c84e090478efcdceb8ab4c4d40b GIT binary patch literal 9635 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 T7(25#A-2|KhJiNmd zb}36PkkaX)igK`;CJ2!js17~2MS$v%gAYFRkV6hVn4tj^4+3f+1`6GrxB+@7(9S$+ zN!BpX0e0TczBg~)H#2YMQ8*mrfap;A(pMpl`yK}l1sWax(He9<<0Ou75-+*V^XFW| zC1T%w-c1BakUZx-=e)#w&PV((@0ENo4v@gPAPK_QUvhCA36TydzKE)FXBGahG_~wct+mDiIL)hH03& zMPe3IF)c0-WyQ)Vx+)gTyejthi}s=_E@}&ls%7^R)iU*xt(k^cRP4opU~pPB@+z@J z#fC-jTRix}vFDeMJ^y@4oGaO4L9uhNlL^rZ%7QA+t%!&8Qb98mBc~n`ZBtZ4tDxw* zNR&b`XwHLVUA1jh%$d4bBGwVnGR3@VE1E89mY7or(bT-C8aAA#ub@B+l+hGj%xj=5 z(Ts%wQG6TvcxL%@Y? zg`uh0B2i1$j$ImdThi1kDj5iFqpzlT8d40yv;kMsur6$an{X)?^)?Wuz8xEF^No1{ z?@T>RQp%;h2GiT$vR~VNXb1!Zw*Vi|!_(WG2*NxBWp3l2?G9v7V8n+#45aNaG7qu{ zGA}ZWBPJ{5Cj$0k$P&on$hweqB15qx2`q=FID~uL9LGQU20FjsKjSjo90<-p1fGN$ z1;l0XMvSt}oyT|P4A;~#$tAfn+)=mT%5Yhf$B6Cj1xc=HyuoACT9@JQMvgqO?X--` znFYzN>75s&T~LM}_@JN49ozE901KfLhy$G$r&C|9CYFR zKrhz?X5K%c)9@MN z{ik%LFqc<`=1FU~!J3G`?8 zWpyRxp@ObiHVuqT55FT%55IAd`lX52Cnqk9Nz|?6^He~vQf?YVkCyQXBBT!_Pyr9M z@ZRDtMe;M83bwQ%c=#UptapKd0{4CKs&7~2=XVajT6^l%t!U?`6CeNjBjJ85zJ7H5 z(w*quRiPeA-sr7`_SQRM*T+8^|8%x;shUjP?KlLh*GE3S3M&NvZzrxz+)X@Jo~Q~3 z>Y-@)Ji$|P2(v>N1k9_w;E$hiS!bS`ZhCNYirdF!_@<|M+SWFG`G3|neO2P|NW+`O zKx8zHk^LC$c|OEu55?g&R00F|<03uIy1~O{UY4)i&4V%=N=E zBiV-0F=t<^-N#9RmM^#AGU%+yBr?hDi_S=D_okPf*;dPRUUqzEHZeOPg$4wv<3!Nt zmfC&jmDV1o2f9QuNhykYJH9mNO7od+z3D}nZg_uV7gDZ)MutTvO}VyNszgJuS(ZNG zRrDJ2JW*Blz{5~bmNknC`0x@;K`DV?yc3Ll@+>kmcPfZW@{sVaXnA{)dKXo!nrKH+ zTUK>hv5IQWrXk2g>axr-3>C2UpaJAEJhOy)8}$bDsDzjVba^C)1uz-(TTC~8R~g^Vq|8Vz01@M zlWMSJu^BvDZfn{?KlnNFF6l60?Shxvs4}KBC}abwJjRTnMO{r zRRca6heA-^xwNjESJXU1iu&I#DNvqXRjF68?3E%QRf2l$WrivB$*PfK@Xf8*szrlm zSiZBM(xM#~CuT<{@7fAQ?}eWFF7#AIt%cICEt0qwdFH#wGaDnDOV!BGwXyQZ>fX99 zydM9}uTZ-W);_4%6@4>ajlQrdJiz7qzl-eOnAz<5dg=DkH%osj)gqbl*jA$V^Yka_ zje|GGHs7fwChsMrYC^g@^VYqYcdIk+))McP&p$}^eg57j?`@pi6mE^wlEc?7l+Ui7 z-HLRspZ&OSL)|$0WuY25TpoK6iXw3JaOaKVUv=Ns>s?Q8EZpAp?MtBTb#u+ET)z2o zEpocP>)HD5=YDYWkr#gmcsso1k%tkk@4#2WU;KaeZ+746`Rhy76X$9N&)?~tywmeW zdE~Cp)hbI;0`+6bls5^u{Jd_i+e^lH{QH?Z1d=?_^tP=q2aRsM{kgiJ?!I7zRuSV9{I_=kB>1e F{tHVY#Z&+Q literal 0 HcmV?d00001 diff --git a/c64view/convert/__pycache__/multicolor.cpython-313.pyc b/c64view/convert/__pycache__/multicolor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c9fa62c7dc6fe676a6345d18fb3e57b89493867 GIT binary patch literal 4487 zcmbUkTWlN0@s2zmUm_(^l&ps(pJh1~{m{#jVmU8Mre2m5J328TKoplFd8W>yj`ZG9 zuc810^g~I3Mh=ifPJ@�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 (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 0000000000000000000000000000000000000000..93c49dde581ce94240bad61489940f3e30bf790a GIT binary patch literal 366 zcmX|+%SyyB6o!*t2c;rDK^ATbGLb8Al>lp;r@CNN^cZlP20tCKkl-ev$YkgA(ie^)C=|+-TJO{8RJ#Ln4FAT zU@CpEG;9q=!&P`kgCdeMT9hs2qNhn7bzIP7exlX4d2K}*%gU@}OdTyJ=S9W4TwH*s aB=irO1BD+GWBiFO|11X^zeoXg)%YK7{$;uV literal 0 HcmV?d00001 diff --git a/c64view/viewer/__pycache__/assemble.cpython-313.pyc b/c64view/viewer/__pycache__/assemble.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7031250a2b5ad98d76b5c3fd837f6af6462762e6 GIT binary patch literal 4399 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 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 0000000000000000000000000000000000000000..471e1759f4857e62a0de02171cc7435ed7d41d85 GIT binary patch literal 99726 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"] +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 0000000000000000000000000000000000000000..5ff1833cfd41ae492ac9133a2ff2576278189f73 GIT binary patch literal 13744 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 literal 0 HcmV?d00001 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.")