481 lines
21 KiB
Python
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()
|