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

481 lines
21 KiB
Python

#!/usr/bin/env python
"""Audit finished Side Cards assets for structural and two-way consistency."""
import hashlib
import json
import math
import re
import tempfile
from pathlib import Path
from xml.etree import ElementTree
import cv2
import numpy as np
from PIL import Image
from build_deck import (
ACE_PIP_SCALE,
COURT_PIP_SCALE,
INDEX_SUIT_SCALE,
NUMBER_PIP_SCALE,
RANK_BASELINE,
RANK_FONT_PATH,
rank_paths,
)
from size_test import render_svg
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
CARD_DIR = ROOT / "exports" / "cards"
COURT_ART_DIR = ROOT / "exports" / "art" / "courts"
RANKS = ("a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k")
SUITS = ("s", "h", "d", "c")
COURTS = ("j", "q", "k")
SVG_NS = "http://www.w3.org/2000/svg"
RANK_FONT_SHA256 = "e6f84f63a76e65bb43c4fe6445e59167a0298a30f172833e5d6e6e08adcf1c8b"
def local_name(element):
return element.tag.rsplit("}", 1)[-1]
def by_id(root, wanted):
return next((element for element in root.iter()
if element.attrib.get("id") == wanted), None)
def fail(message):
raise SystemExit("AUDIT FAILED: " + message)
def outline_signature(element):
return (
element.attrib.get("class"),
element.attrib.get("transform"),
[(child.attrib.get("d"), child.attrib.get("transform"))
for child in element if local_name(child) == "path"],
)
def audit_club_geometry(root, path):
"""Require the traditional bilateral Club construction."""
club = by_id(root, "suit-C")
if club is None:
fail("missing Club definition in " + path.name)
circles = [element for element in club
if local_name(element) == "circle"]
stems = [element for element in club if local_name(element) == "path"]
if len(circles) != 4 or len(stems) != 1:
fail("Club is not three lobes, a centre boss, and one stem in "
+ path.name)
geometry = sorted((float(circle.attrib["cx"]),
float(circle.attrib["cy"]),
float(circle.attrib["r"])) for circle in circles)
expected = sorted(((0.0, -15.0, 15.0),
(-16.5, 6.0, 15.0),
(16.5, 6.0, 15.0),
(0.0, -2.0, 12.0)))
if geometry != expected:
fail("Club has drifted from its bilateral traditional geometry in "
+ path.name)
if stems[0].attrib.get("d") != \
"M-7 8C-6 19-11 25-17 30H17C11 25 6 19 7 8Z":
fail("Club stem is no longer short, centred, and flared in "
+ path.name)
left, right = (-16.5, 6.0), (16.5, 6.0)
lower_gap = math.dist(left, right) - 30.0
boss = (0.0, -2.0)
if lower_gap <= 0 or any(math.dist(boss, point) >= 27.0
for point in (left, right)):
fail("Club centre boss no longer bridges the lower lobes in "
+ path.name)
def audit_card_corner_geometry(root, path):
"""Keep the SVG card edge circular and concentric at poker-card scale."""
clip = by_id(root, "card-clip")
clip_rect = None if clip is None else next(
(element for element in clip if local_name(element) == "rect"), None)
if clip_rect is None or float(clip_rect.attrib.get("rx", 0)) != 24.0:
fail("outer card clip is not radius 24 in " + path.name)
outline = next((element for element in root.iter()
if local_name(element) == "rect"
and element.attrib.get("x") == "2"
and element.attrib.get("y") == "2"
and element.attrib.get("width") == "496"
and element.attrib.get("height") == "696"), None)
if outline is None or float(outline.attrib.get("rx", 0)) != 22.0:
fail("inset card outline is not concentric radius 22 in " + path.name)
def audit_svg_set():
expected = {rank + suit + ".svg" for suit in SUITS for rank in RANKS}
expected.add("back.svg")
actual = {path.name for path in CARD_DIR.glob("*.svg")}
if actual != expected:
fail("wrong SVG set; missing=%s extra=%s" % (
sorted(expected - actual), sorted(actual - expected)))
for path in sorted(CARD_DIR.glob("*.svg")):
try:
root = ElementTree.parse(path).getroot()
except ElementTree.ParseError as exc:
fail("malformed %s: %s" % (path.name, exc))
audit_club_geometry(root, path)
audit_card_corner_geometry(root, path)
for element in root.iter():
if local_name(element) != "image":
continue
hrefs = [value for key, value in element.attrib.items()
if key.rsplit("}", 1)[-1] == "href"]
if not hrefs or any(not value.startswith("data:image/") for value in hrefs):
fail("external or missing image reference in " + path.name)
if path.stem[-1:] not in SUITS:
continue
rank = path.stem[:-1]
top_index = by_id(root, "index-top")
bottom_index = by_id(root, "index-bottom")
if top_index is None or bottom_index is None:
fail("missing index pair in " + path.name)
if bottom_index.attrib.get("transform") != "rotate(180 250 350)":
fail("bottom index is not an exact card-centre rotation in " + path.name)
if any(local_name(element) == "text" for element in root.iter()):
fail("font-dependent text remains in " + path.name)
outline = next((element for element in top_index.iter()
if "rank-outline" in
element.attrib.get("class", "").split()), None)
if outline is None:
fail("vector rank outline missing in " + path.name)
transform = outline.attrib.get("transform", "")
baseline_match = re.match(
r"translate\([\d.-]+ ([\d.-]+)\) scale\(", transform)
if not baseline_match or abs(float(baseline_match.group(1))
- RANK_BASELINE) > 0.001:
fail("rank is not aligned to the shared baseline in " + path.name)
if "rank-q-weight" in outline.attrib.get("class", "").split():
fail("synthetic Q weight remains in " + path.name)
expected_outline = ElementTree.fromstring(rank_paths(rank.upper()))
if outline_signature(outline) != outline_signature(expected_outline):
fail("rank outline does not match Roboto Slab source in " + path.name)
index_suits = [element for element in top_index.iter()
if local_name(element) == "use"]
expected_index_transform = "translate(48 127) rotate(0) scale(%s)" % \
INDEX_SUIT_SCALE[path.stem[-1].upper()]
if len(index_suits) != 1 or index_suits[0].attrib.get("transform") != \
expected_index_transform:
fail("mini index suit size or position drifted in " + path.name)
if rank in COURTS:
audit_court_svg(path, root)
else:
pips = by_id(root, "pips")
count = sum(local_name(element) == "use" for element in pips.iter())
expected_count = 1 if rank == "a" else int(rank)
if count != expected_count:
fail("%s has %d pips, expected %d" % (
path.name, count, expected_count))
scale = (ACE_PIP_SCALE if rank == "a" else NUMBER_PIP_SCALE)[
path.stem[-1].upper()]
expected_scale = "scale(%s)" % scale
if any(expected_scale not in element.attrib.get("transform", "")
for element in pips.iter()
if local_name(element) == "use"):
fail("field-pip scale drifted in " + path.name)
def audit_court_svg(path, root):
frame = by_id(root, "court-frame")
if frame is None:
fail("missing court frame in " + path.name)
rect = next((element for element in frame
if local_name(element) == "rect"), None)
if rect is None:
fail("missing court frame rectangle in " + path.name)
x = float(rect.attrib["x"])
y = float(rect.attrib["y"])
width = float(rect.attrib["width"])
height = float(rect.attrib["height"])
if 2 * x + width != 500 or 2 * y + height != 700:
fail("off-centre court frame in " + path.name)
children = list(root)
frame_position = children.index(frame)
image_position = next((i for i, child in enumerate(children)
if any(local_name(element) == "image"
for element in child.iter())), None)
if image_position is None or frame_position >= image_position:
fail("court figure is not layered in front of frame in " + path.name)
image = next(element for element in root.iter()
if local_name(element) == "image")
transform = image.attrib.get("transform", "")
match = re.fullmatch(
r"translate\(250 350\) scale\(([\d.]+) ([\d.]+)\) "
r"translate\(-250 -350\)", transform)
if not match or any(not 0 < float(value) <= 1 for value in match.groups()):
fail("court art is not scaled about the card centre in " + path.name)
scale_x, scale_y = (float(value) for value in match.groups())
if abs(scale_x - scale_y) > 0.000001:
fail("court art is non-uniformly scaled in " + path.name)
marks = by_id(root, "court-suit-marks")
transforms = [element.attrib.get("transform", "") for element in marks
if local_name(element) == "use"]
suit = path.stem[-1]
top_x = 132 if suit in ("s", "c") else 137
bottom_x = 500 - top_x
expected_scale = "scale(%s)" % COURT_PIP_SCALE[suit.upper()]
if len(transforms) != 2 or "translate(%d 137)" % top_x not in transforms[0] \
or "translate(%d 563) rotate(180)" % bottom_x not in transforms[1] \
or expected_scale not in transforms[0] \
or expected_scale not in transforms[1]:
fail("court suit marks are not a mirrored pair in " + path.name)
def audit_court_art():
expected = {rank + suit + ".png" for suit in SUITS for rank in COURTS}
actual = {path.name for path in COURT_ART_DIR.glob("*.png")}
if actual != expected:
fail("wrong processed court-art set")
for path in sorted(COURT_ART_DIR.glob("*.png")):
rgba = np.asarray(Image.open(path).convert("RGBA"))
delta = np.abs(rgba.astype(np.int16)
- np.rot90(rgba, 2).astype(np.int16))
if int(delta.max()) > 1:
fail("court art is not rotationally exact: " + path.name)
alpha = rgba[..., 3]
count, _, stats, _ = cv2.connectedComponentsWithStats(
(alpha > 0).astype("uint8"), 8)
if count != 2:
islands = sorted(int(value) for value in
stats[1:, cv2.CC_STAT_AREA])
fail("detached court-art specks remain in %s: %s" %
(path.name, islands))
edge = np.concatenate((alpha[0], alpha[-1], alpha[:, 0], alpha[:, -1]))
if np.any(edge):
fail("opaque perimeter residue in " + path.name)
def audit_manifest_and_gallery():
manifest = json.loads((ROOT / "exports" / "manifest.json").read_text())
if manifest.get("count") != 52 or len(manifest.get("cards", [])) != 52:
fail("manifest does not describe 52 card faces")
gallery = (ROOT / "gallery.html").read_text()
if "exports/cards/back.svg" not in gallery:
fail("gallery does not include back.svg")
if "border-radius: 4.8% / 3.4286%" not in gallery:
fail("gallery radius does not match the circular SVG card edge")
font_path = Path(RANK_FONT_PATH)
if hashlib.sha256(font_path.read_bytes()).hexdigest() != RANK_FONT_SHA256:
fail("Roboto Slab source font fingerprint changed")
if not (ROOT / "assets" / "fonts" / "RobotoSlab-Apache-2.0.txt").exists():
fail("Roboto Slab Apache-2.0 license is missing")
def isolated_layer_mask(path, layer):
"""Render one SVG layer against green and return its painted silhouette."""
tree = ElementTree.parse(path)
root = tree.getroot()
children = list(root)
definitions = next(element for element in children
if local_name(element) == "defs")
if layer == "suit":
target = next(element for element in children
if element.attrib.get("id") == "court-suit-marks")
elif layer == "index-suit":
index = next(element for element in children
if element.attrib.get("id") == "index-top")
target = next(element for element in index.iter()
if local_name(element) == "use")
elif layer == "index-rank":
index = next(element for element in children
if element.attrib.get("id") == "index-top")
target = next(element for element in index.iter()
if "rank-outline" in
element.attrib.get("class", "").split())
elif layer == "pips":
target = next(element for element in children
if element.attrib.get("id") == "pips")
else:
target = next(element for element in children
if any(local_name(child) == "image"
for child in element.iter()))
for element in list(root):
root.remove(element)
root.append(definitions)
root.append(ElementTree.Element("{%s}rect" % SVG_NS, {
"width": "500", "height": "700", "fill": "#00ff00"}))
root.append(target)
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as handle:
temporary = Path(handle.name)
try:
tree.write(temporary, encoding="utf-8", xml_declaration=True)
rendered = np.asarray(render_svg(str(temporary), 500)).astype("int16")
finally:
temporary.unlink(missing_ok=True)
difference = np.max(np.abs(rendered - np.array([0, 255, 0])), axis=2)
mask = (difference > 20).astype("uint8")
count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
cleaned = np.zeros_like(mask, dtype=bool)
for label in range(1, count):
if stats[label, cv2.CC_STAT_AREA] >= 20:
cleaned |= labels == label
return cleaned
def mask_metrics(mask):
"""Return painted area, width and height for one rendered silhouette."""
y, x = np.nonzero(mask)
if not len(x):
fail("empty rendered pip silhouette")
return {
"area": int(len(x)),
"width": int(x.max() - x.min() + 1),
"height": int(y.max() - y.min() + 1),
}
def audit_rendered_pips():
"""Guard optical suit balance, not merely source transform values."""
field = {}
aces = {}
for suit in SUITS:
field_mask = isolated_layer_mask(
CARD_DIR / ("2" + suit + ".svg"), "pips")
field_mask[350:] = False
field[suit] = mask_metrics(field_mask)
aces[suit] = mask_metrics(isolated_layer_mask(
CARD_DIR / ("a" + suit + ".svg"), "pips"))
heart_aspect = field["h"]["width"] / field["h"]["height"]
heart_to_spade = field["h"]["area"] / field["s"]["area"]
diamond_to_spade = field["d"]["area"] / field["s"]["area"]
field_areas = [value["area"] for value in field.values()]
field_spread = max(field_areas) / min(field_areas)
ace_areas = [value["area"] for value in aces.values()]
ace_spread = max(ace_areas) / min(ace_areas)
if not 0.90 <= heart_aspect <= 1.05:
fail("Heart field pip is not approximately as tall as it is wide")
if not 0.95 <= heart_to_spade <= 1.05:
fail("Heart field-pip ink is not balanced against Spade")
if not 0.90 <= diamond_to_spade <= 1.05:
fail("Diamond field-pip ink is not balanced against Spade")
if field_spread > 1.12:
fail("number-card field-pip ink varies by more than twelve percent")
if ace_spread > 1.08:
fail("Ace pip ink varies by more than eight percent")
print("rendered pips: Heart %dx%d (area %.1f%% of Spade), "
"Diamond area %.1f%% of Spade, field spread %.1f%%, "
"Ace spread %.1f%%" % (
field["h"]["width"], field["h"]["height"],
heart_to_spade * 100, diamond_to_spade * 100,
(field_spread - 1) * 100, (ace_spread - 1) * 100))
def audit_rendered_indices():
"""Check the mini suit marks that repeat in every card corner."""
metrics = {}
for suit in SUITS:
metrics[suit] = mask_metrics(isolated_layer_mask(
CARD_DIR / ("a" + suit + ".svg"), "index-suit"))
areas = [value["area"] for value in metrics.values()]
widths = [value["width"] for value in metrics.values()]
heights = [value["height"] for value in metrics.values()]
ink_spread = max(areas) / min(areas)
width_spread = max(widths) / min(widths)
height_spread = max(heights) / min(heights)
# Dense silhouettes such as Clubs should not be shrunk merely to equalise
# ink. Apparent footprint is the readability constraint; Diamond remains
# naturally taller while all four marks retain comparable width.
if ink_spread > 1.25 or width_spread > 1.20 or height_spread > 1.30:
fail("corner suit marks no longer have comparable apparent footprints")
rank_heights = {}
for rank in ("a", "10", "j", "q", "k"):
mask = isolated_layer_mask(
CARD_DIR / (rank + "s.svg"), "index-rank")
mask[int(RANK_BASELINE):] = False
rank_heights[rank] = mask_metrics(mask)["height"]
cap_spread = max(rank_heights.values()) / min(rank_heights.values())
if cap_spread > 1.08:
fail("representative corner-rank cap heights vary by more than eight percent")
print("rendered corner suits: ink spread %.1f%%, width %d-%dpx, "
"height %d-%dpx (S/H/D/C areas %d/%d/%d/%d); "
"cap heights A/10/J/Q/K %d/%d/%d/%d/%d" % (
(ink_spread - 1) * 100, min(widths), max(widths),
min(heights), max(heights),
*(metrics[suit]["area"] for suit in SUITS),
*(rank_heights[rank] for rank in ("a", "10", "j", "q", "k"))))
def audit_rendered_layout():
"""Check the visual relationships that source-coordinate checks miss."""
suit_masks = {}
for suit in SUITS:
mask = isolated_layer_mask(CARD_DIR / ("j" + suit + ".svg"), "suit")
mask[350:] = False
mask[:, 250:] = False
suit_masks[suit] = mask
court_suit_areas = [int(suit_masks[suit].sum()) for suit in SUITS]
court_suit_spread = max(court_suit_areas) / min(court_suit_areas)
if court_suit_spread > 1.08:
fail("court suit-mark ink varies by more than eight percent")
metrics = {}
for suit in SUITS:
for rank in COURTS:
code = rank + suit
figure = isolated_layer_mask(CARD_DIR / (code + ".svg"), "figure")
upper_y, upper_x = np.nonzero(figure[:350])
if not len(upper_x):
fail("empty rendered court figure: " + code)
distance = cv2.distanceTransform(
(~figure).astype("uint8"), cv2.DIST_L2, 5)
clearance = max(0.0, float(distance[suit_masks[suit]].min()) - 1.0)
metrics[code] = {
"clearance": clearance,
"width": int(upper_x.max() - upper_x.min() + 1),
"area": int(len(upper_x)),
}
all_values = list(metrics.values())
areas = [value["area"] for value in all_values]
widths = [value["width"] for value in all_values]
if max(areas) / min(areas) > 1.06:
fail("rendered court painted areas vary by more than six percent")
width_ratio = max(widths) / min(widths)
if width_ratio > 1.08:
fail("rendered court widths vary by more than eight percent")
if min(value["clearance"] for value in all_values) < 2.5:
fail("a court figure crowds its suit emblem")
king_clearances = sorted(metrics["k" + suit]["clearance"] for suit in SUITS)
king_median = (king_clearances[1] + king_clearances[2]) / 2
if min(king_clearances) < 5 or king_median < 7:
fail("King suit clearance is still inconsistent")
print("rendered layout: court-suit spread %.1f%%, "
"figure width %d-%dpx (%.1f%% spread), "
"painted-area spread %.1f%%, "
"King clearance %.1f-%.1fpx (median %.1fpx)" % (
(court_suit_spread - 1) * 100,
min(widths), max(widths),
(width_ratio - 1) * 100,
(max(areas) / min(areas) - 1) * 100,
min(king_clearances), max(king_clearances), king_median))
def main():
audit_svg_set()
audit_court_art()
audit_manifest_and_gallery()
audit_rendered_pips()
audit_rendered_indices()
audit_rendered_layout()
print("audit passed: 52 faces + back, centred two-way geometry, correct pips,")
print("embedded assets, mirrored court art, clean perimeters, and gallery back")
if __name__ == "__main__":
main()