Files
random_tools/@sideCards/tools/build_deck.py
T
2026-08-11 09:53:42 -04:00

564 lines
23 KiB
Python

#!/usr/bin/env python
"""Assemble a usable custom deck from raster courts plus SVG card geometry.
Court illustrations remain PNGs. The SVG wrapper supplies dependable rank
indices and suit marks; number cards are fully deterministic SVG. This is the
practical game-asset pipeline—there is no hand tracing of generated figures.
Usage:
./.venv/bin/python build_deck.py
"""
import base64
from collections import deque
import json
import os
from functools import lru_cache
from xml.sax.saxutils import escape
from xml.etree import ElementTree
import cv2
import numpy as np
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
EXPORTS = os.path.join(ROOT, "exports")
CARD_DIR = os.path.join(EXPORTS, "cards")
ART_DIR = os.path.join(EXPORTS, "art")
COURT_ART_DIR = os.path.join(ART_DIR, "courts")
RANK_FONT_PATH = os.path.join(ROOT, "assets", "fonts", "RobotoSlab-SemiBold.ttf")
RANKS = ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")
SUITS = ("S", "H", "D", "C")
COURTS = ("J", "Q", "K")
CARD_WIDTH = 500
CARD_HEIGHT = 700
COURT_FRAME_X = 86
COURT_FRAME_Y = 76
# Derive the far edges from the approved top/left inset. Hard-coding the old
# width and height shifted the frame's centre and made otherwise mirrored
# indices and suit marks look uneven at the lower end of every court card.
COURT_FRAME_WIDTH = CARD_WIDTH - 2 * COURT_FRAME_X
COURT_FRAME_HEIGHT = CARD_HEIGHT - 2 * COURT_FRAME_Y
COURT_SUIT_TOP = {
# Club and Spade silhouettes need a small optical shift away from the
# figure. Their left edges remain inside the frame at this position.
"S": (132, 137),
"H": (137, 137),
"D": (137, 137),
"C": (132, 137),
}
COURT_SUIT_BOTTOM = {
suit: (CARD_WIDTH - point[0], CARD_HEIGHT - point[1])
for suit, point in COURT_SUIT_TOP.items()
}
# Uniform, centred scales preserve the original figure proportions. Measured
# painted area—not bounding-box width—is the comparison metric: after these
# small reductions all twelve courts sit within roughly five percent.
COURT_ART_SCALE = {
"KC": 0.95,
"KD": 0.95,
"KH": 0.97,
"KS": 0.97,
}
RANK_BASELINE = 85.0
RANK_TARGET_HEIGHT = 64.0
RANK_MAX_WIDTH = 63.0
TEN_MAX_WIDTH = 76.0
INDEX_SUIT_X = 48
INDEX_SUIT_Y = 127
# Suit silhouettes have very different native widths and painted areas. These
# role-specific optical scales make them read as peers without distorting any
# individual mark. In particular, the fuller heart needs less geometric scale
# while the narrow diamond needs a little more.
INDEX_SUIT_SCALE = {"S": 0.78, "H": 0.71, "D": 0.92, "C": 0.78}
NUMBER_PIP_SCALE = {"S": 1.50, "H": 1.28, "D": 1.75, "C": 1.43}
ACE_PIP_SCALE = {"S": 3.20, "H": 2.75, "D": 3.72, "C": 2.92}
COURT_PIP_SCALE = {"S": 1.45, "H": 1.24, "D": 1.71, "C": 1.31}
HEART_COURTS = {
"JH": os.path.join(ROOT, "concepts", "dt-court-jh-v3.png"),
"QH": os.path.join(ROOT, "concepts", "dt-court-qh-v3.png"),
"KH": os.path.join(ROOT, "concepts", "dt-court-kh-v3.png"),
}
SUIT_NAME = {"S": "spades", "H": "hearts", "D": "diamonds", "C": "clubs"}
SUIT_COLOR = {"S": "black", "H": "red", "D": "red", "C": "black"}
def ensure_dirs():
for path in (CARD_DIR, ART_DIR, COURT_ART_DIR):
os.makedirs(path, exist_ok=True)
def validate_geometry():
"""Fail the build if two-way court geometry stops being centred."""
checks = {
"court frame horizontal centre":
2 * COURT_FRAME_X + COURT_FRAME_WIDTH == CARD_WIDTH,
"court frame vertical centre":
2 * COURT_FRAME_Y + COURT_FRAME_HEIGHT == CARD_HEIGHT,
}
for suit in SUITS:
checks["%s suit horizontal mirror" % suit] = (
COURT_SUIT_TOP[suit][0] + COURT_SUIT_BOTTOM[suit][0] == CARD_WIDTH)
checks["%s suit vertical mirror" % suit] = (
COURT_SUIT_TOP[suit][1] + COURT_SUIT_BOTTOM[suit][1] == CARD_HEIGHT)
failed = [name for name, passed in checks.items() if not passed]
if failed:
raise ValueError("asymmetric card geometry: " + ", ".join(failed))
def court_source(code):
if code in HEART_COURTS:
return HEART_COURTS[code]
return os.path.join(ROOT, "art", "courts", "raw", code.lower() + ".png")
def prepare_court_art(code):
"""Extract one clean upper court half for exact SVG mirroring.
Draw Things often stops or restarts the rectangular frame for no visual
reason. Long neutral runs in the known frame bands are removed and repaired
from the figure-facing side where they crossed clothing. Edge-connected
card stock is then made transparent. Only the upper half is retained; the
SVG rotates that exact artwork for the bottom half.
"""
source = court_source(code)
if not os.path.exists(source):
raise FileNotFoundError("missing court render for %s: %s" % (code, source))
image = Image.open(source).convert("RGB")
pixels = np.asarray(image).copy()
# The approved Heart sources contain generated red pips. Remove the upper
# pip now; the lower source half is discarded, and SVG supplies both exact
# suit marks later. Flooding a connected red component avoids a rectangular
# knockout and preserves nearby crown/garment details.
if code.endswith("H"):
r, g, b = pixels[..., 0], pixels[..., 1], pixels[..., 2]
red = (r > 85) & (r > g * 1.05) & (r > b * 1.05)
component = np.zeros(red.shape, dtype=bool)
queue = deque([(177, 155)])
while queue:
x, y = queue.popleft()
if not (0 <= x < 640 and 0 <= y < 896):
continue
if component[y, x] or not red[y, x]:
continue
component[y, x] = True
queue.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))
# Two-pixel dilation catches the anti-aliased red edge.
expanded = component.copy()
for dy in range(-2, 3):
for dx in range(-2, 3):
expanded[max(0, dy):896 + min(0, dy), max(0, dx):640 + min(0, dx)] |= \
component[max(0, -dy):896 - max(0, dy), max(0, -dx):640 - max(0, dx)]
pixels[expanded] = (252, 252, 250)
dark = (pixels.max(axis=2) - pixels.min(axis=2) < 34) & (pixels.mean(axis=2) < 125)
left = np.zeros(dark.shape, dtype=bool)
right = np.zeros(dark.shape, dtype=bool)
top = np.zeros(dark.shape, dtype=bool)
bottom = np.zeros(dark.shape, dtype=bool)
def mark_long_runs(target, view, offset_y, offset_x, vertical, minimum=14):
"""Mark straight dark runs while ignoring curved figure keylines."""
major = view.shape[1] if vertical else view.shape[0]
minor = view.shape[0] if vertical else view.shape[1]
for fixed in range(major):
line = view[:, fixed] if vertical else view[fixed, :]
start = None
for pos in range(minor + 1):
on = pos < minor and bool(line[pos])
if on and start is None:
start = pos
elif not on and start is not None:
if pos - start >= minimum:
if vertical:
y0, y1 = offset_y + start, offset_y + pos
x0 = offset_x + fixed
target[y0:y1, max(0, x0 - 2):x0 + 3] = True
else:
y0 = offset_y + fixed
x0, x1 = offset_x + start, offset_x + pos
target[max(0, y0 - 2):y0 + 3, x0:x1] = True
start = None
# Raw 640x896 coordinates. The bands are narrow enough to exclude props
# while accommodating the small frame drift across generated cards.
mark_long_runs(left, dark[70:835, 96:126], 70, 96, vertical=True)
mark_long_runs(right, dark[70:835, 524:554], 70, 524, vertical=True)
mark_long_runs(top, dark[82:116, 90:555], 82, 90, vertical=False)
mark_long_runs(bottom, dark[802:838, 90:555], 802, 90, vertical=False)
# Reconstruct what lies beneath the generated frame. Inpainting is
# deliberately limited to the detected straight-line mask: on blank stock
# it restores stock; over a robe, sword, or hand it extends the surrounding
# artwork through the narrow band. A one-pixel dilation also removes the
# stroke's anti-aliased fringe without disturbing nearby figure keylines.
frame_mask = (left | right | top | bottom).astype("uint8")
frame_mask = cv2.dilate(frame_mask, np.ones((3, 3), dtype="uint8"), iterations=1)
pixels = cv2.inpaint(pixels, frame_mask * 255, 3, cv2.INPAINT_TELEA)
# Flood edge-connected neutral stock. White garment areas are enclosed by
# keylines, so they are intentionally not reached by this flood.
neutral = (pixels.max(axis=2) - pixels.min(axis=2) < 42) & (pixels.min(axis=2) > 90)
background = np.zeros(neutral.shape, dtype=bool)
queue = deque()
height, width = neutral.shape
for x in range(width):
queue.append((x, 0)); queue.append((x, height - 1))
for y in range(height):
queue.append((0, y)); queue.append((width - 1, y))
while queue:
x, y = queue.popleft()
if not (0 <= x < width and 0 <= y < height):
continue
if background[y, x] or not neutral[y, x]:
continue
background[y, x] = True
queue.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))
# Eliminate any residual outer-card shadow/perimeter.
background[:24, :] = True
background[-24:, :] = True
background[:, :24] = True
background[:, -24:] = True
alpha = np.where(background, 0, 255).astype("uint8")
# Build an exact two-way figure from the approved upper character. A
# per-card dynamic-programming seam follows areas where the upright art and
# its 180° rotation already agree (usually a sash or garment fold), then a
# narrow crossfade removes the hard splice. The seam is constrained to be
# rotationally symmetric, so the finished court is exact in both directions.
upright = np.dstack((pixels, alpha))
rotated = np.rot90(upright, 2)
y_start, y_stop = 330, 566
upright_rgb = upright[y_start:y_stop, :, :3].astype("int16")
rotated_rgb = rotated[y_start:y_stop, :, :3].astype("int16")
upright_alpha = upright[y_start:y_stop, :, 3] > 0
rotated_alpha = rotated[y_start:y_stop, :, 3] > 0
cost = np.abs(upright_rgb - rotated_rgb).mean(axis=2).astype("float64")
cost += (upright_alpha != rotated_alpha) * 600
cost[(~upright_alpha) & (~rotated_alpha)] = 0
half = 320
choices = y_stop - y_start
paired_cost = np.empty((half, choices), dtype="float64")
for x in range(half):
paired_cost[x] = cost[:, x] + cost[::-1, 639 - x]
infinity = 1e18
dp = np.full_like(paired_cost, infinity)
previous = np.full(paired_cost.shape, -1, dtype="int16")
centre = 447 - y_start
dp[0] = paired_cost[0] + np.abs(np.arange(choices) - centre) * 0.5
for x in range(1, half):
for candidate in range(choices):
lo, hi = max(0, candidate - 4), min(choices, candidate + 5)
prior = dp[x - 1, lo:hi] + np.abs(np.arange(lo, hi) - candidate) * 2.5
best = lo + int(prior.argmin())
dp[x, candidate] = paired_cost[x, candidate] + prior[best - lo]
previous[x, candidate] = best
candidate = int(np.argmin(dp[-1] + np.abs(np.arange(choices) - centre) * 20))
seam = np.empty(640, dtype="int16")
for x in range(half - 1, -1, -1):
seam[x] = y_start + candidate
if x:
candidate = int(previous[x, candidate])
for x in range(half):
seam[639 - x] = 895 - seam[x]
yy = np.arange(896, dtype="float32")[:, None]
blend = 10.0
upright_weight = np.clip((blend - (yy - seam[None, :])) / (2 * blend), 0, 1)
rotated_weight = 1.0 - upright_weight
ua = upright[..., 3].astype("float32") / 255.0
ra = rotated[..., 3].astype("float32") / 255.0
out_alpha = upright_weight * ua + rotated_weight * ra
premultiplied = (
upright_weight[..., None] * upright[..., :3].astype("float32") * ua[..., None]
+ rotated_weight[..., None] * rotated[..., :3].astype("float32") * ra[..., None]
)
out_rgb = np.zeros_like(premultiplied, dtype="uint8")
visible = out_alpha > 0.001
out_rgb[visible] = np.clip(
premultiplied[visible] / out_alpha[visible, None], 0, 255
).astype("uint8")
rgba = np.dstack((out_rgb, np.clip(out_alpha * 255, 0, 255).astype("uint8")))
# Frame removal and background flooding can leave a handful of detached
# opaque pixels behind. Every approved figure is one connected painted
# component; reject any unexpectedly substantial detached artwork, then
# discard the tiny islands so they cannot appear as dust in final exports.
count, labels, stats, _ = cv2.connectedComponentsWithStats(
(rgba[..., 3] > 0).astype("uint8"), 8)
if count <= 1:
raise ValueError("court extraction produced no figure for " + code)
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
detached_areas = [int(stats[label, cv2.CC_STAT_AREA])
for label in range(1, count) if label != largest]
if detached_areas and max(detached_areas) > 100:
raise ValueError("court extraction detached substantial artwork for %s: %s"
% (code, detached_areas))
rgba[labels != largest] = 0
output = os.path.join(COURT_ART_DIR, code.lower() + ".png")
Image.fromarray(rgba).save(output, optimize=True)
return output
def data_uri(path, mime="image/png"):
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("ascii")
return "data:%s;base64,%s" % (mime, encoded)
def court_art_scale(code, art_path):
"""Return a uniform centred display scale for one court illustration."""
scale = COURT_ART_SCALE.get(code, 1.0)
return scale, scale
def svg_defs():
return r'''
<defs>
<clipPath id="card-clip"><rect width="500" height="700" rx="24"/></clipPath>
<g id="suit-H"><path d="M0 36C-7 27-32 10-32-9C-32-27-11-38 0-19C11-38 32-27 32-9C32 10 7 27 0 36Z"/></g>
<g id="suit-D"><path d="M0-32L23 0L0 32L-23 0Z"/></g>
<g id="suit-C">
<circle cx="0" cy="-15" r="15"/>
<circle cx="-16.5" cy="6" r="15"/>
<circle cx="16.5" cy="6" r="15"/>
<circle cx="0" cy="-2" r="12"/>
<path d="M-7 8C-6 19-11 25-17 30H17C11 25 6 19 7 8Z"/>
</g>
<g id="suit-S">
<path d="M0-32C-8-18-31-7-27 10C-24 23-9 22-2 13C-2 24-8 29-14 34H14C8 29 2 24 2 13C9 22 24 23 27 10C31-7 8-18 0-32Z"/>
</g>
</defs>'''
def svg_start(title):
return '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 500 700" width="500" height="700" role="img">
<title>%s</title>
<style>
.stock{fill:#fcfcfa}.black{fill:#111;color:#111}.red{fill:#b51620;color:#b51620}
</style>
%s
''' % (escape(title), svg_defs())
def suit_use(suit, x, y, scale=1.0, rotate=0):
color = SUIT_COLOR[suit]
return ('<use href="#suit-%s" xlink:href="#suit-%s" class="%s" '
'transform="translate(%s %s) rotate(%s) scale(%s)"/>') % (
suit, suit, color, x, y, rotate, scale)
@lru_cache(maxsize=None)
def rank_paths(rank):
"""Draw one rank as self-contained Roboto Slab vector outlines."""
font = TTFont(RANK_FONT_PATH)
glyph_set = font.getGlyphSet()
cmap = font.getBestCmap()
metrics = font["hmtx"].metrics
cursor = 0
pieces = []
bounds = []
for position, character in enumerate(rank):
glyph_name = cmap[ord(character)]
glyph = glyph_set[glyph_name]
path_pen = SVGPathPen(glyph_set)
glyph.draw(path_pen)
bounds_pen = BoundsPen(glyph_set)
glyph.draw(bounds_pen)
if bounds_pen.bounds is None:
continue
x_min, y_min, x_max, y_max = bounds_pen.bounds
bounds.append((cursor + x_min, y_min, cursor + x_max, y_max))
pieces.append('<path d="%s" transform="translate(%s 0)"/>' % (
path_pen.getCommands(), cursor))
cursor += metrics[glyph_name][0]
if position < len(rank) - 1:
cursor -= 35
if not bounds:
raise ValueError("rank has no drawable glyphs: " + rank)
x_min = min(item[0] for item in bounds)
y_min = min(item[1] for item in bounds)
x_max = max(item[2] for item in bounds)
y_max = max(item[3] for item in bounds)
target_height = RANK_TARGET_HEIGHT
max_width = TEN_MAX_WIDTH if rank == "10" else RANK_MAX_WIDTH
# Size against the cap height above the font baseline. Descenders (most
# visibly Q's tail) must not make an otherwise matching capital smaller.
scale = min(target_height / y_max, max_width / (x_max - x_min))
translate_x = 48 - (x_min + x_max) * scale / 2
translate_y = RANK_BASELINE
return ('<g class="rank-outline" transform="translate(%.4f %.4f) '
'scale(%.6f -%.6f)">%s</g>') % (
translate_x, translate_y, scale, scale, ''.join(pieces))
def indices(rank, suit):
# Roboto Slab SemiBold outlines keep exports independent of installed fonts
# while providing large, clear slab-serif indices.
top = '''
<g id="index-top" class="%s">
%s
%s
</g>''' % (
SUIT_COLOR[suit], rank_paths(rank),
suit_use(suit, INDEX_SUIT_X, INDEX_SUIT_Y, INDEX_SUIT_SCALE[suit])
)
bottom = '''
<g id="index-bottom" transform="rotate(180 250 350)">%s</g>''' % top.replace(
'id="index-top"', 'id="index-mirrored"'
)
return top + bottom
def write_svg(code, body):
path = os.path.join(CARD_DIR, code.lower() + ".svg")
title = "%s of %s" % (code[0:-1], SUIT_NAME[code[-1]])
text = svg_start(title)
text += ' <rect class="stock" width="500" height="700" rx="24"/>\n'
text += body
text += indices(code[:-1], code[-1])
text += '\n <rect x="2" y="2" width="496" height="696" rx="22" fill="none" stroke="#b7b2aa" stroke-width="4"/>\n</svg>\n'
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return path
def court_svg(code, art_path):
uri = data_uri(art_path)
suit = code[-1]
scale_x, scale_y = court_art_scale(code, art_path)
body = '''
<g id="court-frame" fill="none" stroke="#111" stroke-width="3"
stroke-linejoin="round">
<rect x="%d" y="%d" width="%d" height="%d" rx="4"/>
</g>
<g clip-path="url(#card-clip)">
<image href="%s" xlink:href="%s" x="0" y="0" width="500" height="700"
preserveAspectRatio="none"
transform="translate(250 350) scale(%.6f %.6f) translate(-250 -350)"/>
</g>
<g id="court-suit-marks">
%s
%s
</g>
''' % (
COURT_FRAME_X,
COURT_FRAME_Y,
COURT_FRAME_WIDTH,
COURT_FRAME_HEIGHT,
uri,
uri,
scale_x,
scale_y,
suit_use(suit, COURT_SUIT_TOP[suit][0], COURT_SUIT_TOP[suit][1],
COURT_PIP_SCALE[suit]),
suit_use(suit, COURT_SUIT_BOTTOM[suit][0], COURT_SUIT_BOTTOM[suit][1],
COURT_PIP_SCALE[suit], 180),
)
return write_svg(code, body)
# Standard, readable pip layouts in 500x700 card coordinates. A third value
# of True turns the pip upside down for the lower half of the card.
PIP_LAYOUTS = {
"2": ((250, 175, False), (250, 525, True)),
"3": ((250, 155, False), (250, 350, False), (250, 545, True)),
"4": ((165, 175, False), (335, 175, False), (165, 525, True), (335, 525, True)),
"5": ((165, 165, False), (335, 165, False), (250, 350, False), (165, 535, True), (335, 535, True)),
"6": ((165, 145, False), (335, 145, False), (165, 350, False), (335, 350, False), (165, 555, True), (335, 555, True)),
"7": ((165, 135, False), (335, 135, False), (250, 245, False), (165, 350, False), (335, 350, False), (165, 565, True), (335, 565, True)),
"8": ((165, 125, False), (335, 125, False), (250, 235, False), (165, 350, False), (335, 350, False), (250, 465, True), (165, 575, True), (335, 575, True)),
"9": ((165, 115, False), (335, 115, False), (165, 275, False), (335, 275, False), (250, 350, False), (165, 425, True), (335, 425, True), (165, 585, True), (335, 585, True)),
"10": ((165, 105, False), (335, 105, False), (250, 200, False), (165, 270, False), (335, 270, False), (165, 430, True), (335, 430, True), (250, 500, True), (165, 595, True), (335, 595, True)),
}
def number_svg(code):
rank, suit = code[:-1], code[-1]
if rank == "A":
body = '<g id="pips">%s</g>\n' % suit_use(
suit, 250, 350, ACE_PIP_SCALE[suit])
else:
pips = []
for x, y, inverted in PIP_LAYOUTS[rank]:
pips.append(suit_use(
suit, x, y, NUMBER_PIP_SCALE[suit], 180 if inverted else 0))
body = '<g id="pips">\n %s\n </g>\n' % '\n '.join(pips)
return write_svg(code, body)
def build_back():
source = os.path.join(ROOT, "concepts", "calibration-v1.png")
# Crop to the card itself. The sheet's two rows are not the same height --
# the top row of cards is 460px tall but the bottom row (which holds the
# backs) is only 431px. Using the top row's height here ran 28px past the
# card and baked a band of sheet background into the bottom of every back,
# which then read as a shadow under the border stroke. Bounds below are
# measured from the sheet's background gutters.
image = Image.open(source).convert("RGB").crop((780, 524, 1109, 955))
art_path = os.path.join(ART_DIR, "back.png")
image.save(art_path, optimize=True)
uri = data_uri(art_path)
path = os.path.join(CARD_DIR, "back.svg")
text = svg_start("Card back")
text += (' <g clip-path="url(#card-clip)"><image href="%s" xlink:href="%s" '
'width="500" height="700" preserveAspectRatio="none"/></g>\n' % (uri, uri))
text += ' <rect x="2" y="2" width="496" height="696" rx="22" fill="none" stroke="#b7b2aa" stroke-width="4"/>\n</svg>\n'
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return path
def main():
validate_geometry()
ensure_dirs()
generated = []
for suit in SUITS:
for rank in RANKS:
code = rank + suit
if rank in COURTS:
art = prepare_court_art(code)
generated.append(court_svg(code, art))
else:
generated.append(number_svg(code))
generated.append(build_back())
manifest = {
"cards": [os.path.basename(path) for path in generated if os.path.basename(path) != "back.svg"],
"back": "back.svg",
"format": "standalone SVG; court SVGs contain embedded PNG artwork",
"count": 52,
}
with open(os.path.join(EXPORTS, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
# Fail immediately on malformed exports rather than discovering a broken
# card in the browser later.
for path in generated:
ElementTree.parse(path)
print("built %d cards and one back in %s" % (52, os.path.relpath(CARD_DIR, ROOT)))
if __name__ == "__main__":
main()