#!/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''' ''' def svg_start(title): return ''' %s %s ''' % (escape(title), svg_defs()) def suit_use(suit, x, y, scale=1.0, rotate=0): color = SUIT_COLOR[suit] return ('') % ( 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_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 ('%s') % ( 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 = ''' %s %s ''' % ( SUIT_COLOR[suit], rank_paths(rank), suit_use(suit, INDEX_SUIT_X, INDEX_SUIT_Y, INDEX_SUIT_SCALE[suit]) ) bottom = ''' %s''' % 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 += ' \n' text += body text += indices(code[:-1], code[-1]) text += '\n \n\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 = ''' %s %s ''' % ( 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 = '%s\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 = '\n %s\n \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 += (' \n' % (uri, uri)) text += ' \n\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()