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

58 lines
1.8 KiB
Python

"""Nintendo Entertainment System (2C02 PPU) master palette.
The NES has no fixed RGB palette -- the PPU generates an NTSC signal. We
reproduce MAME's exact ``nespal_to_RGB`` (YUV->RGB) formula so the encoder
matches what the emulator renders. A palette value is a 6-bit index 0-63:
high nibble = luminance (0-3), low nibble = hue (0-15, with 0/13/14/15 greyscale).
The byte written to PPU palette RAM IS this index, so ``PALETTE``/``GREYS`` are
indexed by the hardware value.
"""
from __future__ import annotations
import math
import numpy as np
from ..palette import srgb_to_lab
_TINT, _HUE = 0.22, 287.0
_Kr, _Kb, _Ku, _Kv = 0.2989, 0.1145, 2.029, 1.140
_BRIGHT = [[0.50, 0.75, 1.0, 1.0],
[0.29, 0.45, 0.73, 0.9],
[0.0, 0.24, 0.47, 0.77]]
def _rgb(intensity: int, num: int):
if num == 0:
sat = rad = 0.0; y = _BRIGHT[0][intensity]
elif num == 13:
sat = rad = 0.0; y = _BRIGHT[2][intensity]
elif num in (14, 15):
sat = rad = y = 0.0
else:
sat = _TINT; rad = math.radians(num * 30 + _HUE); y = _BRIGHT[1][intensity]
u, v = sat * math.cos(rad), sat * math.sin(rad)
R = (y + _Kv * v) * 255.0
G = (y - (_Kb * _Ku * u + _Kr * _Kv * v) / (1 - _Kb - _Kr)) * 255.0
B = (y + _Ku * u) * 255.0
cl = lambda x: max(0, min(255, int(math.floor(x + 0.5))))
return (cl(R), cl(G), cl(B))
PALETTE = np.array([_rgb(i >> 4, i & 0x0F) for i in range(64)], dtype=np.float64)
# Distinct grey ramp (R==G==B), sorted dark->light, deduped by luminance.
_grey = {}
for _i in range(64):
r, g, b = PALETTE[_i]
if r == g == b:
_grey.setdefault(int(r), _i)
GREYS = [_grey[k] for k in sorted(_grey)] # e.g. $0F,$1D,$2D,$10,$20
def get_palette() -> np.ndarray:
return PALETTE
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)