← back to Wallco Ai

scripts/colorway-variants.py

160 lines

#!/usr/bin/env python3
"""Generate 3 colorway variants of a 2-tone design at near-zero cost.

Steve directive 2026-05-20: "focus on patterns in 1 or 2 colors and then a way
to create 3 more colorways." Replicate generation is ~$0.01/design — recoloring
a 2-tone PNG via PIL palette swap is essentially free. Every paid generation
becomes 4 designs (original + 3 recolors).

Usage:
  python3 colorway-variants.py <input.png> <ground_hex> <figure_hex>

Behavior:
  - Reads <input.png> (must be 2-tone already, run quantize-2tone.py first)
  - Detects the two dominant colors
  - For each of 3 alternate colorway pairs (drawn from MUTED_PALETTE), produces
    a recolored copy at <input>.cw-<slug>.png
  - Prints JSON to stdout: { "originals": [...], "variants": [{slug, path}, ...] }

The 3 alternate colorways are picked deterministically from the input's seed so
the same design always gets the same 3 children.
"""
import sys
import os
import json
import hashlib
from collections import Counter
from PIL import Image

# Steve's muted-Gucci palette pairs — (ground, figure)
COLORWAY_PAIRS = [
    ("saddle-mocha",       "#4A3528", "#F2EADB"),
    ("antique-tan",        "#7A6248", "#F2EADB"),
    ("smoke-ash",          "#5C5249", "#F2EADB"),
    ("champagne-linen",    "#D8C8AA", "#3A2A1C"),
    ("bone-ivory",         "#F2EADB", "#3A2A1C"),
    ("stone-pewter",       "#8A857B", "#F2EADB"),
    ("greenhouse-glass",   "#1F4E47", "#F2EADB"),
    ("indigo-library",     "#1E2740", "#F2EADB"),
    ("bordeaux-velvet",    "#5C2329", "#F2EADB"),
    ("manor-saddle",       "#7A5638", "#F2EADB"),
    ("slate-mist",         "#85847E", "#F2EADB"),
    ("antique-brass",      "#9B8050", "#F2EADB"),
    ("oxblood",            "#722F2F", "#F2EADB"),
    ("forest-loden",       "#3B4A36", "#F2EADB"),
    ("midnight-navy",      "#1B2A3A", "#F2EADB"),
    ("deep-plum",          "#3D2B3F", "#F2EADB"),
]


def hex_to_rgb(h):
    h = h.lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))


def parent_seed_from_path(path):
    """Stable per-path hash so the same parent always gets the same 3 children."""
    h = hashlib.sha256(path.encode()).digest()
    return int.from_bytes(h[:4], "big")


def detect_two_tones(im):
    """Return [(rgb, count), (rgb, count)] sorted by count desc."""
    pixels = list(im.convert("RGB").getdata())
    c = Counter(pixels)
    if len(c) < 2:
        # Single-color image — duplicate so we have 2 entries
        only = c.most_common(1)[0]
        return [only, (only[0], 0)]
    return c.most_common(2)


def recolor(im, src_ground_rgb, src_figure_rgb, dst_ground_rgb, dst_figure_rgb):
    """Swap src ground+figure → dst ground+figure. Idempotent: each pixel maps
    to the closer of the two source colors and gets the matching dst color.
    Returns a new RGB image."""
    out = im.copy().convert("RGB")
    src_g = src_ground_rgb
    src_f = src_figure_rgb
    pixels = list(out.getdata())
    new = []
    for p in pixels:
        # Distance to ground vs figure
        dg = (p[0]-src_g[0])**2 + (p[1]-src_g[1])**2 + (p[2]-src_g[2])**2
        df = (p[0]-src_f[0])**2 + (p[1]-src_f[1])**2 + (p[2]-src_f[2])**2
        new.append(dst_ground_rgb if dg <= df else dst_figure_rgb)
    out.putdata(new)
    return out


def rgb_to_hex(rgb):
    return "#%02x%02x%02x" % (int(rgb[0]), int(rgb[1]), int(rgb[2]))


def main():
    # --list-presets: dump COLORWAY_PAIRS as JSON (UI builds chips from this so
    # the preset list lives in ONE place, not duplicated in server.js).
    if len(sys.argv) >= 2 and sys.argv[1] == "--list-presets":
        print(json.dumps({"ok": True, "presets": [
            {"slug": s, "ground": g, "figure": f} for (s, g, f) in COLORWAY_PAIRS
        ]}))
        return
    # --detect <input.png>: print the two dominant tones (ground=most-common,
    # figure=less-common) as hex. The colorway-preset endpoint uses these as the
    # ink_map `from` colors, then maps them to a preset's (ground,figure) `to`.
    if len(sys.argv) >= 3 and sys.argv[1] == "--detect":
        src_path = sys.argv[2]
        if not os.path.exists(src_path):
            print(json.dumps({"ok": False, "error": "file not found: %s" % src_path}))
            sys.exit(2)
        im = Image.open(src_path).convert("RGB")
        two = detect_two_tones(im)
        print(json.dumps({
            "ok": True,
            "ground_hex": rgb_to_hex(two[0][0]),
            "figure_hex": rgb_to_hex(two[1][0]),
        }))
        return
    if len(sys.argv) < 2:
        print("usage: colorway-variants.py <input.png> | --detect <input.png> | --list-presets", file=sys.stderr)
        sys.exit(1)
    src_path = sys.argv[1]
    if not os.path.exists(src_path):
        print(f"file not found: {src_path}", file=sys.stderr)
        sys.exit(2)
    im = Image.open(src_path).convert("RGB")
    two = detect_two_tones(im)
    src_ground_rgb = two[0][0]   # most common = ground
    src_figure_rgb = two[1][0]   # less common = figure
    # Pick 3 alternate colorways deterministically
    seed = parent_seed_from_path(src_path)
    picks = []
    used = set()
    i = 0
    while len(picks) < 3 and i < 100:
        idx = (seed + i * 7919) % len(COLORWAY_PAIRS)
        slug = COLORWAY_PAIRS[idx][0]
        if slug not in used:
            picks.append(COLORWAY_PAIRS[idx])
            used.add(slug)
        i += 1
    out_paths = []
    for slug, dst_ground_hex, dst_figure_hex in picks:
        dst_g = hex_to_rgb(dst_ground_hex)
        dst_f = hex_to_rgb(dst_figure_hex)
        out_im = recolor(im, src_ground_rgb, src_figure_rgb, dst_g, dst_f)
        out_path = src_path.replace(".png", f".cw-{slug}.png")
        out_im.save(out_path, optimize=True)
        out_paths.append({"slug": slug, "path": out_path, "ground": dst_ground_hex, "figure": dst_figure_hex})
    print(json.dumps({
        "ok": True,
        "input": src_path,
        "src_ground_rgb": list(src_ground_rgb),
        "src_figure_rgb": list(src_figure_rgb),
        "variants": out_paths,
    }))


if __name__ == "__main__":
    main()