274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
#!/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()
|