Files
CoCoCapitalQuiz/tools/prepare_assets.py
T

155 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Offline, standard-library-only conversion of Fla-shop SVG to identity spans.
SVG input uses straight M/L/H/V/Z paths. Flood regions at original resolution,
associate via documented seed coordinates, then reduce to guest scanline runs.
"""
import collections, json, math, re, struct, zlib
from pathlib import Path
import xml.etree.ElementTree as ET
ROOT=Path(__file__).resolve().parents[1]
W,H=2000,1200
rows=[s.split('\t') for s in (ROOT/'data/states.tsv').read_text().splitlines()]
assert len(rows)==50 and len({r[0] for r in rows})==50
ids={r[0]:i+1 for i,r in enumerate(rows)}
def paths(d):
tok=re.findall(r'[MmLlHhVvZz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',d)
i=0;x=y=0;out=[];poly=[];cmd=''
while i<len(tok):
if tok[i].isalpha():
cmd=tok[i];i+=1
if cmd.lower()=='z':
if poly:poly.append(poly[0]);out.append(poly);poly=[]
x,y=out[-1][0];continue
rel=cmd.islower(); c=cmd.lower()
if c in ('m','l'):
a,b=map(float,tok[i:i+2]);i+=2
x,y=(x+a,y+b) if rel else (a,b)
if c=='m':
if poly:out.append(poly)
poly=[];cmd='l' if rel else 'L'
elif c=='h':
a=float(tok[i]);i+=1;x=x+a if rel else a
elif c=='v':
a=float(tok[i]);i+=1;y=y+a if rel else a
else:raise ValueError(cmd)
poly.append((x,y))
if poly:out.append(poly)
return out
def png(path,w,h,pixels):
def chunk(tag,b):return struct.pack('>I',len(b))+tag+b+struct.pack('>I',zlib.crc32(tag+b)&0xffffffff)
raw=b''.join(b'\0'+bytes(pixels[y*w*3:(y+1)*w*3]) for y in range(h))
path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
def main():
svg=ET.parse(ROOT/'assets/usa-source.svg').getroot()
elements={e.get('id'):e for e in svg.iter() if e.get('id')}
land=bytearray(W*H)
outline=paths(elements['outline'].get('d'))
# Even-odd scan conversion, coast and islands included.
edges=[(a,b) for p in outline for a,b in zip(p,p[1:]+p[:1]) if a[1]!=b[1]]
buckets=[[] for _ in range(H)]
for a,b in edges:
for y in range(max(0,math.ceil(min(a[1],b[1])-.5)),min(H,math.ceil(max(a[1],b[1])-.5))):
buckets[y].append(a[0]+(y+.5-a[1])*(b[0]-a[0])/(b[1]-a[1]))
for y,xx in enumerate(buckets):
xx.sort()
for a,b in zip(xx[::2],xx[1::2]):
lo=max(0,math.ceil(a-.5));hi=min(W,math.ceil(b-.5));land[y*W+lo:y*W+hi]=b'\1'*(hi-lo)
coast=bytearray(land)
# Three-source-pixel boundary stroke closes subpixel joins before flood fill.
for p in paths(elements['boundaries'].get('d')):
for a,b in zip(p,p[1:]):
n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1]))))
for t in range(n+1):
x=round(a[0]+(b[0]-a[0])*t/n);y=round(a[1]+(b[1]-a[1])*t/n)
for yy in range(max(0,y-1),min(H,y+2)):
for xx in range(max(0,x-1),min(W,x+2)):land[yy*W+xx]=0
seeds={}
for e in svg.iter():
if e.tag.endswith('tspan'):
ab=''.join(e.itertext()).strip()
if ab in ids:seeds[ab]=[round(float(e.get('x'))+15),round(float(e.get('y'))-12)]
# Callout labels are outside their states. Seed actual interiors instead.
seeds.update({'VT':[1800,280],'NH':[1840,280],'MA':[1830,340],
'RI':[1860,360],'CT':[1830,375],'NJ':[1780,440],
'DE':[1764,509],'MD':[1720,536],'HI':[739,1130]})
seeds.update(json.loads((ROOT/'data/map-seeds.json').read_text()) if (ROOT/'data/map-seeds.json').exists() else {})
owners=bytearray(W*H);claimed={};counts={}
for ab,(x,y) in seeds.items():
start=y*W+x
assert land[start],(ab,'seed is not land',x,y)
assert not owners[start],(ab,'shares component with',claimed.get(owners[start]))
val=ids[ab];q=collections.deque([start]);owners[start]=val;count=0
while q:
pos=q.popleft();count+=1
for nxt in (pos-1,pos+1,pos-W,pos+W):
if 0<=nxt<W*H and land[nxt] and not owners[nxt]:owners[nxt]=val;q.append(nxt)
claimed[val]=ab;counts[ab]=count
# Unassigned land = detached islands/peninsulas: nearest assigned land,
# using a multi-source breadth-first expansion. The source mask is retained.
q=collections.deque(i for i in range(W*H) if owners[i] and any(0<=j<W*H and not owners[j] for j in (i-1,i+1,i-W,i+W)))
nearest=bytearray(owners)
while q:
p=q.popleft()
for n in (p-1,p+1,p-W,p+W):
if 0<=n<W*H and not nearest[n]:nearest[n]=nearest[p];q.append(n)
# Detached Alaska/Hawaii insets and Michigan's Upper Peninsula require
# explicit geographic regions; proximity alone mislabels small islands.
# Assign detached original land by geographic ownership, not nearest capital.
for y in range(H):
for x in range(W):
p=y*W+x
if land[p] and not owners[p]:
if 560<x<785 and 995<y<1190:owners[p]=ids['HI']
elif x<560 and y>850:owners[p]=ids['AK']
elif 1175<x<1465 and 180<y<330:owners[p]=ids['MI']
else:owners[p]=nearest[p]
(ROOT/'data/map-seeds.json').write_text(json.dumps(seeds,indent=2)+'\n')
# Three views share the exact same owner raster and stable TSV IDs.
views=[('national',200,110,(0,0,2000,1200)),('northeast',100,100,(1540,120,1980,600)),('hawaii',100,82,(565,995,785,1185))]
arrays=[];meta={};preview=[]
for name,w,h,(x0,y0,x1,y1) in views:
small=[]
for y in range(h):
for x in range(w):
# Sample one source interior pixel at pixel center.
sx=int(x0+(x+.5)*(x1-x0)/w);sy=int(y0+(y+.5)*(y1-y0)/h)
small.append((owners[sy*W+sx] or nearest[sy*W+sx]) if coast[sy*W+sx] else 0)
if name=='hawaii':small=[v if v==ids['HI'] else 0 for v in small]
# One guest pixel on the right/bottom of a state seam gives continuous
# boundaries. Preserve tiny national masks; their detail view is larger.
areas=collections.Counter(small); original=small[:]
for y in range(h-1):
for x in range(w-1):
pos=y*w+x;val=original[pos]
if val and areas[val]>8 and any(v and v!=val for v in (original[pos+1],original[pos+w])):
small[pos]=0
spans=[];offsets=[0];total={}
for val in range(1,51):
cnt=0
for y in range(h):
x=0
while x<w:
if small[y*w+x]!=val:x+=1;continue
first=x
while x<w and small[y*w+x]==val:x+=1
spans.extend([y,first,x-first]);cnt+=x-first
offsets.append(len(spans));total[rows[val-1][0]]=cnt
arrays.append('const unsigned char '+name+'_spans[] = {\n'+','.join(map(str,spans))+'\n};\nconst unsigned '+name+'_offsets[51] = {'+','.join(map(str,offsets))+'};\n')
meta[name]={'size':[w,h],'source_box':[x0,y0,x1,y1],'pixels':total,'bytes':len(spans)+102}
colors=[(8,19,34),(44,116,142),(73,145,155),(103,164,169),(36,92,126)]
pix=[]
for v in small:pix.extend(colors[0] if not v else colors[1+(v%4)])
png(ROOT/'assets'/('preview-'+name+'.png'),w,h,pix)
assert all(meta['national']['pixels'].values()),meta['national']['pixels']
(ROOT/'src/map_data.c').write_text('/* Adapted from Fla-shop.com, CC BY 4.0. See assets/CREDITS.md. */\n#include "map_data.h"\n'+''.join(arrays))
(ROOT/'map_data.h').write_text('\n'.join('extern const unsigned char '+n+'_spans[];\nextern const unsigned '+n+'_offsets[51];' for n,*_ in views)+'\n')
(ROOT/'src/states.c').write_text('#include "quiz.h"\nconst State states[50] = {\n'+',\n'.join('{"%s","%s","%s"}'%tuple(r) for r in rows)+'\n};\n')
meta['source_component_pixels']=counts
(ROOT/'data/map-manifest.json').write_text(json.dumps(meta,indent=2)+'\n')
print({n:meta[n]['bytes'] for n,*_ in views})
if __name__=='__main__':main()