diff --git a/tools/recolor/__init__.py b/tools/recolor/__init__.py new file mode 100644 index 0000000..0112231 --- /dev/null +++ b/tools/recolor/__init__.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from PIL import Image +import importlib + + +@dataclass(frozen=True) +class Color: + RED: int + GREEN: int + BLUE: int + + +@dataclass(frozen=True) +class ColorMask: + target_color: Color + replacement_color: Color + + +def load_color_map(name: str): + module_path = f"tools.recolor.color_maps.{name}" + module = importlib.import_module(module_path) + + if not hasattr(module, "COLOR_MAP"): + raise RuntimeError(f"color map '{name}' has no COLOR_MAP") + + return module.COLOR_MAP + + +def recolor_image(img: Image.Image, color_map: list[ColorMask]) -> Image.Image: + img = img.convert("RGBA") + pixels = img.load() + + lookup = { + (m.target_color.RED, m.target_color.GREEN, m.target_color.BLUE): + (m.replacement_color.RED, m.replacement_color.GREEN, m.replacement_color.BLUE) + for m in color_map + } + + width, height = img.size + + for y in range(height): + for x in range(width): + r, g, b, a = pixels[x, y] + if (r, g, b) in lookup: + nr, ng, nb = lookup[(r, g, b)] + pixels[x, y] = (nr, ng, nb, a) + + return img + + +def extract_unique_colors(img: Image.Image) -> set[Color]: + img = img.convert("RGBA") + pixels = img.load() + + width, height = img.size + colors: set[Color] = set() + + for y in range(height): + for x in range(width): + r, g, b, a = pixels[x, y] + if a != 0: + colors.add(Color(r, g, b)) + + return colors diff --git a/tools/recolor/__main__.py b/tools/recolor/__main__.py new file mode 100644 index 0000000..51168a2 --- /dev/null +++ b/tools/recolor/__main__.py @@ -0,0 +1,128 @@ +import argparse +import sys +from pathlib import Path +from PIL import Image + +from . import load_color_map, recolor_image, extract_unique_colors + + +def collect_files(dir_arg: str) -> list[Path]: + path = Path(dir_arg) + + if "*" in dir_arg: + parent = path.parent + pattern = path.name + + if not parent.exists() or not parent.is_dir(): + print("directory does not exist", file=sys.stderr) + sys.exit(1) + + return [ + f for f in parent.iterdir() + if f.is_file() + and f.suffix.lower() == ".png" + and f.match(pattern) + ] + + if not path.exists() or not path.is_dir(): + print("directory does not exist", file=sys.stderr) + sys.exit(1) + + return [ + f for f in path.iterdir() + if f.is_file() and f.suffix.lower() == ".png" + ] + + +def process_file(path: Path, color_map): + img = Image.open(path) + img = recolor_image(img, color_map) + img.save(path) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-i", "--input") + parser.add_argument("-dir", "--directory") + parser.add_argument("-o", "--output") + parser.add_argument("-r", "--replace", action="store_true") + parser.add_argument("-m", "--map") + parser.add_argument("-ext", "--extract", action="store_true") + + args = parser.parse_args() + + if not args.input and not args.directory: + print("either --input or --directory is required", file=sys.stderr) + sys.exit(1) + + # -------- directory / glob mode -------- + if args.directory: + files = collect_files(args.directory) + + if args.extract: + all_colors = set() + + for file in files: + img = Image.open(file) + all_colors |= extract_unique_colors(img) + + for c in sorted(all_colors, key=lambda x: (x.RED, x.GREEN, x.BLUE)): + print(f"{c.RED:3}, {c.GREEN:3}, {c.BLUE:3}") + + sys.exit(0) + + if not args.map: + print("color map required for directory mode", file=sys.stderr) + sys.exit(1) + + try: + color_map = load_color_map(args.map) + except Exception as e: + print(str(e), file=sys.stderr) + sys.exit(1) + + for file in files: + process_file(file, color_map) + + sys.exit(0) + + # -------- single file mode -------- + input_path = Path(args.input) + if not input_path.exists(): + print("input file does not exist", file=sys.stderr) + sys.exit(1) + + if args.extract: + img = Image.open(input_path) + colors = extract_unique_colors(img) + + for c in sorted(colors, key=lambda x: (x.RED, x.GREEN, x.BLUE)): + print(f"{c.RED:3}, {c.GREEN:3}, {c.BLUE:3}") + + sys.exit(0) + + if args.replace: + output_path = input_path + else: + if not args.output: + print("output file required unless --replace is set", file=sys.stderr) + sys.exit(1) + output_path = Path(args.output) + + if not args.map: + print("color map required unless --extract is set", file=sys.stderr) + sys.exit(1) + + try: + color_map = load_color_map(args.map) + except Exception as e: + print(str(e), file=sys.stderr) + sys.exit(1) + + img = Image.open(input_path) + img = recolor_image(img, color_map) + img.save(output_path) + + +if __name__ == "__main__": + main() diff --git a/tools/recolor/color_maps/chipper_orange.py b/tools/recolor/color_maps/chipper_orange.py new file mode 100644 index 0000000..4da829b --- /dev/null +++ b/tools/recolor/color_maps/chipper_orange.py @@ -0,0 +1,35 @@ +from tools.recolor import Color, ColorMask + +COLOR_MAP = [ + # ---- item/armor (teal -> orange) ---- + ColorMask(Color( 8, 37, 32), Color(100, 45, 10)), + ColorMask(Color( 14, 62, 53), Color(130, 60, 15)), + ColorMask(Color( 26, 168, 165), Color(160, 80, 20)), + ColorMask(Color( 32, 195, 179), Color(190, 100, 25)), + ColorMask(Color( 73, 234, 214), Color(220, 120, 30)), + ColorMask(Color(159, 248, 229), Color(235, 140, 40)), + ColorMask(Color(252, 252, 252), Color(255, 220, 180)), + + # ---- model/armor (old teal -> orange) ---- + ColorMask(Color( 44, 224, 216), Color(130, 60, 15)), + ColorMask(Color( 45, 196, 178), Color(160, 80, 20)), + ColorMask(Color( 48, 208, 190), Color(190, 100, 25)), + ColorMask(Color( 74, 237, 217), Color(220, 120, 30)), + ColorMask(Color(107, 243, 227), Color(235, 140, 40)), + ColorMask(Color(154, 248, 240), Color(245, 160, 60)), + ColorMask(Color(161, 251, 232), Color(255, 180, 100)), + ColorMask(Color(180, 253, 238), Color(255, 200, 140)), + ColorMask(Color(229, 255, 250), Color(255, 220, 180)), + + # ---- already orange (identity, makes it safe to rerun) ---- + ColorMask(Color(100, 45, 10), Color(100, 45, 10)), + ColorMask(Color(130, 60, 15), Color(130, 60, 15)), + ColorMask(Color(160, 80, 20), Color(160, 80, 20)), + ColorMask(Color(190, 100, 25), Color(190, 100, 25)), + ColorMask(Color(220, 120, 30), Color(220, 120, 30)), + ColorMask(Color(235, 140, 40), Color(235, 140, 40)), + ColorMask(Color(245, 160, 60), Color(245, 160, 60)), + ColorMask(Color(255, 180, 100), Color(255, 180, 100)), + ColorMask(Color(255, 200, 140), Color(255, 200, 140)), + ColorMask(Color(255, 220, 180), Color(255, 220, 180)), +]