← 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()