96 lines
3 KiB
Python
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
|