#!/usr/bin/env python """Generate matching court-card artwork with the local Draw Things server. The Hearts family is already approved. The other suits use the corresponding Heart card as an img2img composition/style reference, while prompts vary the character and garment motifs. Output remains raster artwork; card indices, suit marks and number-card geometry are added later by build_deck.py. Usage: ./.venv/bin/python render_court.py QD ./.venv/bin/python render_court.py JD QD KD JC QC KC JS QS KS ./.venv/bin/python render_court.py --remaining """ import argparse import datetime import hashlib import json import os import plistlib import sys import time import numpy as np from PIL import Image import dt_client as dt import imageService_pb2 as pb HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) CONCEPTS = os.path.join(ROOT, "concepts") OUTDIR = os.path.join(ROOT, "art", "courts", "raw") MODEL = "qwen_image_2512_bf16_i8x.ckpt" DT_APP = "/Applications/Draw Things.app/Contents/Info.plist" WIDTH, HEIGHT = 640, 896 RANKS = ("J", "Q", "K") SUITS = ("H", "D", "C", "S") REMAINING = tuple(rank + suit for suit in "DCS" for rank in RANKS) HEART_REFERENCE = { "J": os.path.join(CONCEPTS, "dt-court-jh-v3.png"), "Q": os.path.join(CONCEPTS, "dt-court-qh-v3.png"), "K": os.path.join(CONCEPTS, "dt-court-kh-v3.png"), } FIGURE = { "J": "a young knave with shoulder-length hair holding an upright halberd", "Q": "a regal queen wearing an ornate golden crown and holding a stylized flower", "K": "a mature bearded king wearing an ornate golden crown and holding an upright sword", } SUIT_DIRECTION = { "D": ("the diamond court, with angular diamond-shaped garment ornament; " "red and gold are dominant with muted-blue accents"), "C": ("the club court, with rounded trefoil garment ornament; black and " "muted blue are dominant with red and gold accents"), "S": ("the spade court, with elegant pointed garment ornament; black and " "muted blue are dominant with red and gold accents"), } STYLE = ( "original Anglo-American playing-card court illustration, crisp flat " "vector-like drawing, precise black keylines, restrained red, black, " "golden-yellow, muted-blue and warm-skin palette on white card stock, " "mirrored two-way composition, richly patterned robes, traditional but " "entirely original character design, clean white corners" ) NEGATIVE = ( "rank letter, corner index, corner marking, suit symbol, heart symbol, " "diamond symbol, club symbol, spade symbol, pip, text, letters, numbers, " "typography, watermark, signature, logo, branded artwork, photorealistic, " "photograph, 3d render, soft gradient shading, blur, noise, drop shadow" ) # Stable, visibly different seeds per card. DEFAULT_SEEDS = { "JD": 181, "QD": 182, "KD": 183, "JC": 281, "QC": 282, "KC": 283, "JS": 381, "QS": 382, "KS": 383, } def sha256_file(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def drawthings_version(): try: with open(DT_APP, "rb") as f: return plistlib.load(f).get("CFBundleShortVersionString", "unknown") except Exception: return "unknown" def checkpoint_hashes(stub, files): response = stub.FilesExist( pb.FileListRequest(files=files, filesWithHash=files), timeout=900 ) hashes = list(response.hashes) result = {} for i, (filename, exists) in enumerate(zip(response.files, response.existences)): digest = hashes[i].hex() if i < len(hashes) and hashes[i] else None result[filename] = {"exists": bool(exists), "sha256": digest} return result def client_revision(): return sha256_file(os.path.join(HERE, "dt_client.py"))[:16] def build_reference(code): """Load the approved matching-rank Heart and paint out only its pip ink. Earlier versions blanked a rectangular region. That could survive img2img as a visible white block when a generated sleeve or face drifted into the area. A heart-shaped mask removes the reference symbol without reserving a rectangle in the composition. """ source = HEART_REFERENCE[code[0]] image = Image.open(source).convert("RGB") pixels = np.asarray(image).copy() r, g, b = pixels[..., 0], pixels[..., 1], pixels[..., 2] red_ink = (r > 90) & (r > g * 1.06) & (r > b * 1.06) # Flood only the red component under each known pip seed. This avoids # touching nearby red garment/crown details and leaves no geometric patch. for seed_x, seed_y in ((177, 155), (480, 745)): stack = [(seed_x, seed_y)] seen = set() while stack: x, y = stack.pop() if (x, y) in seen or not (0 <= x < WIDTH and 0 <= y < HEIGHT): continue seen.add((x, y)) if not red_ink[y, x]: continue pixels[y, x] = (252, 252, 250) for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx or dy: stack.append((x + dx, y + dy)) return Image.fromarray(pixels), source def prompt_for(code): rank, suit = code return "%s. Subject: %s, %s. Constraints: no rank, no text, no suit pips." % ( STYLE, FIGURE[rank], SUIT_DIRECTION[suit] ) def render(stub, code, seed, strength, steps, guidance, sampler, meta_common): reference, reference_path = build_reference(code) prompt = prompt_for(code) config = dt.build_config( model=MODEL, width=WIDTH, height=HEIGHT, steps=steps, guidance_scale=guidance, seed=seed, sampler=sampler, strength=strength, ) started = time.time() images = dt.generate( stub, prompt, NEGATIVE, config, image=np.asarray(reference), timeout=3600, ) elapsed = time.time() - started if not images: raise RuntimeError("Draw Things returned no image for %s" % code) os.makedirs(OUTDIR, exist_ok=True) stem = code.lower() png_path = os.path.join(OUTDIR, stem + ".png") with open(png_path, "wb") as f: f.write(dt.decode_png(images[0])) metadata = dict(meta_common) metadata.update({ "card": code, "output": os.path.relpath(png_path, ROOT), "output_sha256": sha256_file(png_path), "prompt": prompt, "negative_prompt": NEGATIVE, "reference": { "source": os.path.relpath(reference_path, ROOT), "source_sha256": sha256_file(reference_path), "reference_heart_pips_painted_out": True, }, "settings": { "width": WIDTH, "height": HEIGHT, "steps": steps, "guidance_scale": guidance, "seed": seed, "strength": strength, "sampler": sampler, "seed_mode": "ScaleAlike", }, "elapsed_seconds": round(elapsed, 1), }) with open(os.path.join(OUTDIR, stem + ".json"), "w") as f: json.dump(metadata, f, indent=2, sort_keys=True) print("%s %5.0fs -> %s" % (code, elapsed, os.path.relpath(png_path, ROOT)), flush=True) return png_path def valid_code(value): code = value.upper() if len(code) != 2 or code[0] not in RANKS or code[1] not in SUITS: raise argparse.ArgumentTypeError("expected one of J/Q/K plus H/D/C/S") if code[1] == "H": raise argparse.ArgumentTypeError("Hearts are already approved v3 assets") return code def main(): parser = argparse.ArgumentParser() parser.add_argument("cards", nargs="*", type=valid_code) parser.add_argument("--remaining", action="store_true", help="render all nine non-Heart courts") parser.add_argument("--force", action="store_true", help="overwrite existing renders") parser.add_argument("--seed", type=int, help="override the per-card seed") parser.add_argument("--strength", type=float, default=0.68) parser.add_argument("--steps", type=int, default=30) parser.add_argument("--guidance", type=float, default=4.0) parser.add_argument("--sampler", default="DPMPP2MTrailing") args = parser.parse_args() cards = list(REMAINING if args.remaining else args.cards) if not cards: parser.error("provide card codes or --remaining") stub = dt.connect() meta_common = { "generator": "Draw Things gRPC (local)", "drawthings_version": drawthings_version(), "model_file": MODEL, "model_hashes": checkpoint_hashes( stub, [MODEL, "qwen_image_vae_f16.ckpt", "qwen_2.5_vl_7b_q8p.ckpt"] ), "client_revision": client_revision(), "generated_at": datetime.datetime.now().astimezone().isoformat(), "status": "game court artwork", } for code in cards: existing = os.path.join(OUTDIR, code.lower() + ".png") if os.path.exists(existing) and not args.force: print("%s exists; skipping (use --force to replace)" % code, flush=True) continue seed = args.seed if args.seed is not None else DEFAULT_SEEDS[code] render(stub, code, seed, args.strength, args.steps, args.guidance, args.sampler, meta_common) if __name__ == "__main__": main()