"""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