From 7b8f8506ef16782fceb46b62fe812e68d55fe4a6 Mon Sep 17 00:00:00 2001 From: MichelIwaniec Date: Thu, 23 Jun 2022 23:49:59 +0100 Subject: [PATCH] RGB -> NES color conversion support: * Add Python script gbdk-support/nespal/nespal.py, to allow generating C LUT / macro from a specific 192-byte .pal file * Add example .pal file from Drag's palette generator as default * Add generated C macro file gbdk-lib/include/nes/rgb_to_nes_macro.h, providing RGB_TO_NES macro * Update RGB / RGB8 / RGBHTML in nes.h to invoke RGB_TO_NES macro * Manually enter EGA-like color constants from nesdev wiki --- gbdk-lib/include/nes/nes.h | 42 +++-- gbdk-lib/include/nes/rgb_to_nes_macro.h | 70 ++++++++ gbdk-support/nespal/nespal.py | 220 ++++++++++++++++++++++++ gbdk-support/nespal/palettes/palgen.pal | Bin 0 -> 192 bytes 4 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 gbdk-lib/include/nes/rgb_to_nes_macro.h create mode 100644 gbdk-support/nespal/nespal.py create mode 100644 gbdk-support/nespal/palettes/palgen.pal diff --git a/gbdk-lib/include/nes/nes.h b/gbdk-lib/include/nes/nes.h index 6d4c307b..0bb3c78b 100644 --- a/gbdk-lib/include/nes/nes.h +++ b/gbdk-lib/include/nes/nes.h @@ -8,34 +8,38 @@ #include #include #include +#include #define NINTENDO_ENTERTAINMENT_SYSTEM #ifdef SEGA #undef SEGA #endif -#define RGB(r,g,b) ((r) | ((g) << 2) | ((b) << 4)) -#define RGB8(r,g,b) (((r) >> 6) | (((g) >> 6) << 2) | (((b) >> 6) << 4)) -#define RGBHTML(RGB24bit) (((RGB24bit) >> 22) | ((((RGB24bit) & 0xFFFF) >> 14) << 2) | ((((RGB24bit) & 0xFF) >> 6) << 4)) +#define RGB(r,g,b) RGB_TO_NES(((r) | ((g) << 2) | ((b) << 4))) +#define RGB8(r,g,b) RGB_TO_NES((((r) >> 6) | (((g) >> 6) << 2) | (((b) >> 6) << 4))) +#define RGBHTML(RGB24bit) RGB_TO_NES((((RGB24bit) >> 22) | ((((RGB24bit) & 0xFFFF) >> 14) << 2) | ((((RGB24bit) & 0xFF) >> 6) << 4))) /** Common colors based on the EGA default palette. + * + * Manually entered from https://www.nesdev.org/wiki/PPU_palettes#RGBI + * */ -#define RGB_RED RGB( 3, 0, 0) -#define RGB_DARKRED RGB( 2, 0, 0) -#define RGB_GREEN RGB( 0, 3, 0) -#define RGB_DARKGREEN RGB( 0, 2, 0) -#define RGB_BLUE RGB( 0, 0, 3) -#define RGB_DARKBLUE RGB( 0, 0, 2) -#define RGB_YELLOW RGB( 3, 3, 0) -#define RGB_DARKYELLOW RGB( 2, 2, 0) -#define RGB_CYAN RGB( 0, 3, 3) -#define RGB_AQUA RGB( 3, 1, 2) -#define RGB_PINK RGB( 3, 0, 3) -#define RGB_PURPLE RGB( 2, 0, 2) -#define RGB_BLACK RGB( 0, 0, 0) -#define RGB_DARKGRAY RGB( 1, 1, 1) -#define RGB_LIGHTGRAY RGB( 2, 2, 2) -#define RGB_WHITE RGB( 3, 3, 3) +#define RGB_RED 0x16 // EGA12 +#define RGB_DARKRED 0x06 // EGA4 +#define RGB_GREEN 0x2A // EGA10 +#define RGB_DARKGREEN 0x1A // EGA2 +#define RGB_BLUE 0x12 // EGA9 +#define RGB_DARKBLUE 0x02 // EGA1 +#define RGB_YELLOW 0x28 // EGA14 +#define RGB_DARKYELLOW 0x18 // EGA6 +#define RGB_CYAN 0x2C // EGA11 +#define RGB_AQUA 0x1C // EGA3 +#define RGB_PINK 0x24 // EGA13 +#define RGB_PURPLE 0x14 // EGA5 +#define RGB_BLACK 0x0F // EGA0 +#define RGB_DARKGRAY 0x00 // EGA8 +#define RGB_LIGHTGRAY 0x10 // EGA7 +#define RGB_WHITE 0x30 // EGA15 typedef uint8_t palette_color_t; diff --git a/gbdk-lib/include/nes/rgb_to_nes_macro.h b/gbdk-lib/include/nes/rgb_to_nes_macro.h new file mode 100644 index 00000000..ba2cf19c --- /dev/null +++ b/gbdk-lib/include/nes/rgb_to_nes_macro.h @@ -0,0 +1,70 @@ +// File auto-generated file by nespal.py +#ifndef __RGB_TO_NES_MACRO_H__ +#define __RGB_TO_NES_MACRO_H__ +#define RGB_TO_NES(c) \ + (c == 0x00) ? 0x1D : \ + (c == 0x01) ? 0x06 : \ + (c == 0x02) ? 0x17 : \ + (c == 0x03) ? 0x16 : \ + (c == 0x04) ? 0x19 : \ + (c == 0x05) ? 0x18 : \ + (c == 0x06) ? 0x17 : \ + (c == 0x07) ? 0x27 : \ + (c == 0x08) ? 0x2A : \ + (c == 0x09) ? 0x29 : \ + (c == 0x0A) ? 0x28 : \ + (c == 0x0B) ? 0x27 : \ + (c == 0x0C) ? 0x2A : \ + (c == 0x0D) ? 0x29 : \ + (c == 0x0E) ? 0x29 : \ + (c == 0x0F) ? 0x28 : \ + (c == 0x10) ? 0x01 : \ + (c == 0x11) ? 0x04 : \ + (c == 0x12) ? 0x15 : \ + (c == 0x13) ? 0x15 : \ + (c == 0x14) ? 0x1C : \ + (c == 0x15) ? 0x00 : \ + (c == 0x16) ? 0x15 : \ + (c == 0x17) ? 0x26 : \ + (c == 0x18) ? 0x2B : \ + (c == 0x19) ? 0x2A : \ + (c == 0x1A) ? 0x10 : \ + (c == 0x1B) ? 0x26 : \ + (c == 0x1C) ? 0x2B : \ + (c == 0x1D) ? 0x2A : \ + (c == 0x1E) ? 0x39 : \ + (c == 0x1F) ? 0x38 : \ + (c == 0x20) ? 0x02 : \ + (c == 0x21) ? 0x13 : \ + (c == 0x22) ? 0x14 : \ + (c == 0x23) ? 0x14 : \ + (c == 0x24) ? 0x11 : \ + (c == 0x25) ? 0x13 : \ + (c == 0x26) ? 0x10 : \ + (c == 0x27) ? 0x25 : \ + (c == 0x28) ? 0x2C : \ + (c == 0x29) ? 0x10 : \ + (c == 0x2A) ? 0x3D : \ + (c == 0x2B) ? 0x36 : \ + (c == 0x2C) ? 0x2C : \ + (c == 0x2D) ? 0x3B : \ + (c == 0x2E) ? 0x3A : \ + (c == 0x2F) ? 0x37 : \ + (c == 0x30) ? 0x12 : \ + (c == 0x31) ? 0x13 : \ + (c == 0x32) ? 0x14 : \ + (c == 0x33) ? 0x24 : \ + (c == 0x34) ? 0x12 : \ + (c == 0x35) ? 0x22 : \ + (c == 0x36) ? 0x23 : \ + (c == 0x37) ? 0x24 : \ + (c == 0x38) ? 0x21 : \ + (c == 0x39) ? 0x22 : \ + (c == 0x3A) ? 0x32 : \ + (c == 0x3B) ? 0x34 : \ + (c == 0x3C) ? 0x2C : \ + (c == 0x3D) ? 0x3C : \ + (c == 0x3E) ? 0x3C : \ + (c == 0x3F) ? 0x20 : \ + 0xFF // out-of-range value - set to 0xFF +#endif diff --git a/gbdk-support/nespal/nespal.py b/gbdk-support/nespal/nespal.py new file mode 100644 index 00000000..eb7b109c --- /dev/null +++ b/gbdk-support/nespal/nespal.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +import sys +import argparse +from operator import itemgetter +from pathlib import Path + +from typing import Optional, Tuple, List, Sequence, Dict, Set, NewType + + +def get_ppu_color_name(ppu_color: int) -> str: + """ + Returns a human-readable name for a PPU color + + Naming convention from https://www.nesdev.org/wiki/PPU_palettes#Color_names + + With following simplifications: + 'Dark gray' -> 'Gray' + 'Light gray or silver' -> 'Silver' + + :param ppu_color: 6-bit NES PPU color + :return: Human-readable name for PPU color + """ + ppu_hue_names = [ + 'Gray', + 'Azure', + 'Blue', + 'Violet', + 'Magenta', + 'Rose', + 'Red', + 'Orange', + 'Yellow', + 'Chartreuse', + 'Green', + 'Spring', + 'Cyan' + ] + ppu_luma_names = [ + 'Dark', + 'Medium', + 'Light', + 'Pale' + ] + c = ppu_color & 0x3F + # Special-cases for black/white/grays + if c in [0x20, 0x30]: + return 'White' + elif c in [0x3D]: + return 'Silver' + elif c in [0x00, 0x2D]: + return 'Gray' + elif c in [0x1D, 0x0E, 0x1E, 0x2E, 0x3E, 0x0F, 0x1F, 0x2F, 0x3F]: + return 'Black' + elif c == 0x0D: + return 'BlackerThanBlack' + # All normal hues + ppu_hue_name = ppu_hue_names[c & 0xF] + ppu_luma_name = ppu_luma_names[(c >> 4) & 0x3] + return f'{ppu_luma_name}-{ppu_hue_name}' + + +def get_script_directory() -> Path: + """ + Return path to current scripts directory. + (or the executable if built with pyinstaller) + + :return: Path to directory of this script + """ + if getattr(sys, 'frozen', False): + return Path(sys.executable).parent + # or a script file (e.g. `.py` / `.pyw`) + elif __file__: + return Path(__file__).parent + + +def nes_closest_palette_entry(rgb: Tuple[int, int, int], NESPaletteRGB: List[Tuple[int, int, int]]) -> int: + def dist2(rgbA, rgbB): + return sum((rgbA[i] - rgbB[i])**2 for i in range(3)) + minIndex = min(enumerate([dist2(rgb, nprgb) for i, nprgb in enumerate(NESPaletteRGB)]), key=itemgetter(1))[0] + return minIndex + + +def to_triplets(data: List[int]) -> List[Tuple[int, int, int]]: + """ + Convert a linear list into a triplet list + + :param data: List formatted as [0, 1, 2 ... N, N+1, N+2] + :return: Nested list formatted as [[0, 1, 2] ... [N, N+1, N+2]] + """ + assert(len(data) % 3 == 0) + length = len(data) // 3 + data_triplets = [] + for i in range(length): + data_triplets.append((data[3 * i + 0], data[3 * i + 1], data[3 * i + 2])) + return data_triplets + + +def map_to_PPU_colors(target_colors: List[int], rgb_palette_nes: List[int], invalid_colors: List[int]) -> Tuple[List[int], List[int]]: + """ + Creates a mapping from target colors in RGB format to NES PPU colors + + :param target_colors: List of length N, with target colors in RGB format + :param rgb_palette_nes: List of length 64, giving RGB values for NES colors + :return: List of length N with each entry indicating the best NES color + """ + target_colors = to_triplets(target_colors) + rgb_palette_nes = to_triplets(rgb_palette_nes) + # Set invalid_colors to large value to prevent being picked + rgb_palette_nes = rgb_palette_nes[:] + for c in invalid_colors: + rgb_palette_nes[c] = (1000000, 1000000, 1000000) + nes_palette_colors = [] + for rgb_color in target_colors: + # print(rgb_color) + closest_palette_entry = nes_closest_palette_entry(rgb_color, rgb_palette_nes) + nes_palette_colors.append(closest_palette_entry) + return nes_palette_colors + + +def get_pal_file_path(pal_file_path: str) -> Path: + """ + Convert a string path to Pathlib path. + If None, use default path and print warning message. + If file is missing, print error. + + :param pal_file_path: path to .pal file + :return: path as pathlib Path + """ + default_pal_file_path = get_script_directory() / 'palettes' / 'palgen.pal' + if pal_file_path is None: + print(f'WARNING: Palette file not specified - falling back to default palette file {str(default_pal_file_path)}') + return default_pal_file_path + elif not Path(pal_file_path).exists(): + print(f'WARNING: Palette file {str(Path(pal_file_path))} does not exist - falling back to default palette file {str(default_pal_file_path)}') + return default_pal_file_path + else: + return Path(pal_file_path) + + +def write_c_lut(lut: List[int], filename: Path): + """ + Writes NES color mapping as a C-array rgb_to_nes + + :param lut: Lookup table of 64 values, indexed as BBGGRR + :param filename: Source text file to write + """ + with open(filename, 'wt') as f: + print('// File auto-generated file by nespal.py', file=f) + print(f'unsigned char rgb_to_nes[{len(lut)}] = {{', file=f) + for i, c in enumerate(lut): + r = ((i >> 0) & 0x3) + g = ((i >> 2) & 0x3) + b = ((i >> 4) & 0x3) + print(f' 0x{c:0{2}X}, // RGB({r},{g},{b}) -> {c:0{2}X} ({get_ppu_color_name(c)})', file=f) + print(f'}};', file=f) + + +def write_c_macro(lut: List[int], filename: Path): + """ + Writes NES color mapping as a C-macro RGB_TO_NES(c) + + :param lut: Lookup table of 64 values, indexed as BBGGRR + :param filename: Source text file to write + """ + with open(filename, 'wt') as f: + print('// File auto-generated file by nespal.py', file=f) + print('#ifndef __RGB_TO_NES_MACRO_H__', file=f) + print('#define __RGB_TO_NES_MACRO_H__', file=f) + print(f'#define RGB_TO_NES(c) \\', file=f) + for i, c in enumerate(lut): + print(f' (c == 0x{i:0{2}X}) ? 0x{c:0{2}X} : \\', file=f) + print(f' 0xFF // out-of-range value - set to 0xFF', file=f) + print('#endif', file=f) + + +def main(palette_file: Path, + invalid_colors: List[int], + output_c_lut_path: Path, + output_c_macro_path: Path) -> int: + # Read NES palette mapping file + with open(palette_file, 'rb') as f: + nes_palette = f.read(192) + # Use a 6-bit RGB palette in BBGGRR format + gbdk_palette = [] + for i in range(64): + r = (((i >> 0) & 0x3) / 3.0) * 255.0 + g = (((i >> 2) & 0x3) / 3.0) * 255.0 + b = (((i >> 4) & 0x3) / 3.0) * 255.0 + gbdk_palette.append(r) + gbdk_palette.append(g) + gbdk_palette.append(b) + nes_palette_colors = map_to_PPU_colors(gbdk_palette, nes_palette, invalid_colors) + if output_c_lut_path is not None: + write_c_lut(nes_palette_colors, output_c_lut_path) + if output_c_macro_path is not None: + write_c_macro(nes_palette_colors, output_c_macro_path) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=f'Generates lookup tables to convert RGB to NES PPU compatible colors.') + parser.add_argument('--pal_file', type=str, + default=None, + help='Binary 192-byte file specifying a particular NES palette flavor.') + parser.add_argument('--output_c_lut', type=str, + default='rgb_to_nes_lut.c', + help='Output .c file with generated rgb_to_nes lookup table.') + parser.add_argument('--output_c_macro', type=str, + default='rgb_to_nes_macro.h', + help='Output .h file with generated RGB_TO_NES C macro.') + parser.add_argument('--invalid_colors', type=str, + nargs='+', + default=['0D', '0E', '1E', '2E', '3E', '0F', '1F', '2F', '3F'], + help='Specify which NES PPU colors should be considered invalid.') + args = parser.parse_args() + # Call main program + rc = main(get_pal_file_path(args.pal_file), + [int(s, 16) for s in args.invalid_colors], + Path(args.output_c_lut), + Path(args.output_c_macro)) + sys.exit(rc) diff --git a/gbdk-support/nespal/palettes/palgen.pal b/gbdk-support/nespal/palettes/palgen.pal new file mode 100644 index 0000000000000000000000000000000000000000..733454a48c691b7b4ed20909fdb45a48d8b8fcd3 GIT binary patch literal 192 zcmZ>Bb7NqOVqmLaVk5~AaZ0DL*ZryjU$Q-CrTLhU(nXphG+q*pLg~D`UC$j?D+q0|Ns979{fMN_y4Im a&#!hLz2CR&+2ScLj!t^=dinC@5cL4~`$=K| literal 0 HcmV?d00001