8bitlenser/lenser/atari/palette.py
2026-07-03 19:35:35 -07:00

96 lines
3 KiB
Python

"""Atari 8-bit (GTIA) colour palette.
The GTIA produces 256 colour-register values: the high nibble is the hue (0 =
grey, 1..15 = colours around the NTSC wheel) and the low nibble is the luminance.
We generate an NTSC palette with a standard YIQ formula, but if the atari800
emulator's palette file is present we load that instead so the preview matches
exactly what the emulator displays.
"""
from __future__ import annotations
import math
import os
import numpy as np
from ..palette import srgb_to_lab # reuse the CIELAB conversion
# Candidate locations for atari800's bundled NTSC palette (768 raw RGB bytes).
_PAL_FILES = [
"/usr/share/atari800/Palettes/Real.act",
"/usr/share/atari800/default.pal",
"/usr/local/share/atari800/default.pal",
]
def _generate_ntsc() -> np.ndarray:
"""Generate a 256x3 (uint8) NTSC palette via a YIQ approximation."""
pal = np.zeros((256, 3), dtype=np.float64)
# Calibration roughly matching the common Atari NTSC look.
sat = 0.30
hue0 = -58.0 # phase of hue 1, degrees
for reg in range(256):
hue = (reg >> 4) & 0x0F
lum = reg & 0x0F
y = lum / 15.0
if hue == 0:
i = q = 0.0
else:
angle = math.radians(hue0 + (hue - 1) * (360.0 / 15.0))
i = sat * math.cos(angle)
q = sat * math.sin(angle)
r = y + 0.956 * i + 0.621 * q
g = y - 0.272 * i - 0.647 * q
b = y - 1.106 * i + 1.703 * q
pal[reg] = [r, g, b]
return np.clip(pal * 255.0 + 0.5, 0, 255).astype(np.uint8).astype(np.float64)
def _load_pal_file() -> np.ndarray | None:
for path in _PAL_FILES:
try:
with open(path, "rb") as f:
data = f.read()
if len(data) >= 768:
return np.frombuffer(data[:768], dtype=np.uint8).reshape(256, 3).astype(np.float64)
except OSError:
continue
return None
_CACHE: dict[str, np.ndarray] = {}
def get_palette(name: str = "ntsc") -> np.ndarray:
"""Return the 256x3 sRGB palette (float64, 0..255)."""
if "rgb" not in _CACHE:
_CACHE["rgb"] = _load_pal_file()
if _CACHE["rgb"] is None:
_CACHE["rgb"] = _generate_ntsc()
return _CACHE["rgb"]
def palette_lab(name: str = "ntsc") -> np.ndarray:
"""Return the 256 palette colours in CIELAB (256x3)."""
if "lab" not in _CACHE:
_CACHE["lab"] = srgb_to_lab(get_palette(name))
return _CACHE["lab"]
def hue_ramp(hue: int) -> list[int]:
"""The 16 register values of one hue (luminance 0..15) -- for GR.9 / mono."""
return [((hue & 0x0F) << 4) | lum for lum in range(16)]
def nearest_hue(rgb) -> int:
"""Atari hue (0..15) whose mid-luminance colour best matches ``rgb``."""
from ..palette import srgb_to_lab
lab = srgb_to_lab(np.asarray(rgb, dtype=np.float64))
pl = palette_lab()
best, best_d = 0, float("inf")
for h in range(16):
d = float(np.sum((pl[(h << 4) | 8] - lab) ** 2))
if d < best_d:
best, best_d = h, d
return best