File size: 14,017 Bytes
f918b86 05acee5 f918b86 a9ce211 f918b86 4de4420 f918b86 05acee5 f918b86 a9ce211 f918b86 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | # SPDX-License-Identifier: Apache-2.0
"""End-to-end Ink Bench test in headless Chromium (Playwright for Python).
usage: python3 tests/e2e_bench.py <base url> <screenshot dir>
Drops a blurred, shifted and scaled copy of the published ink and pure noise through the UI,
checks alignment, scores and Python parity, annotation, export, share links, the leaderboard,
the cite panel, and a phone layout."""
import base64, json, os, sys, tempfile, zipfile
import numpy as np
from PIL import Image
from playwright.sync_api import sync_playwright
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.join(HERE, '..')
sys.path.insert(0, HERE); sys.path.insert(0, os.path.join(ROOT, 'tools'))
import synth, inkbench as ib
base, shots = sys.argv[1].rstrip('/') + '/', sys.argv[2]
os.makedirs(shots, exist_ok=True)
tmp = tempfile.mkdtemp()
CASES = {'shift_blur': ('w046', 23.0, -17.0, 1.0, 1.0, 2.0), 'scale_shift': ('w048', -12.5, 9.25, 1.12, 1.12, 1.0)}
for k, (seg, *p) in CASES.items():
ib.save_png16(synth.make(seg, *p), os.path.join(tmp, k + '.png'))
Image.fromarray((synth.noise('w046') * 255).astype(np.uint8)).save(os.path.join(tmp, 'noise.png'))
refs = ib.Refs(root=ROOT)
R = {}; fails = []
def check(c, m):
print(('ok ' if c else 'FAIL ') + m)
if not c: fails.append(m)
def scored(p):
p.wait_for_function("window.unrollBench.state().scored && !window.unrollBench.state().busy", timeout=120000)
def comp(b):
return ib.comparable(b)
with sync_playwright() as pw:
br = pw.chromium.launch()
ctx = br.new_context(viewport={'width': 1440, 'height': 900}, accept_downloads=True)
p = ctx.new_page()
errs = []
p.on('pageerror', lambda e: errs.append(str(e)))
p.goto(base, wait_until='load')
p.wait_for_selector('#btnBench', timeout=60000)
p.wait_for_selector('#loader', state='detached', timeout=120000)
p.wait_for_timeout(500)
p.click('#btnBench', timeout=180000) # software WebGL in headless runs can keep the main thread busy at start
p.wait_for_selector('#bench:not([hidden])')
txt = p.inner_text('#bench')
check('Nothing is uploaded' in txt, 'UI says nothing is uploaded')
check('not ground truth' in txt, 'UI says agreement is not ground truth')
# 1. blurred and shifted copy through the file input
p.select_option('#bSeg', 'w046'); p.wait_for_timeout(500)
p.fill('#bModel', 'synthetic: published ink, blur 2 px, shift 23,-17')
p.set_input_files('#bPick', os.path.join(tmp, 'shift_blur.png'))
scored(p)
b = p.evaluate('window.unrollBench.bundle()')
a, m = b['alignment'], b['metrics']
R['shift_blur'] = {'truth': CASES['shift_blur'][1:], 'got': [a['tx'], a['ty'], a['sx'], a['sy'], m['blurSigmaPx']], 'pearson': m['pearson'], 'pearsonMatchedBlur': m['pearsonMatchedBlur'], 'iou': m['iou'], 'dice': m['dice'], 'ap': m['ap']}
check(abs(a['tx'] - 23) <= 0.25 and abs(a['ty'] + 17) <= 0.25 and abs(a['sx'] - 1) < 0.003 and abs(a['sy'] - 1) < 0.003, f'shift recovered {a["tx"]},{a["ty"]} scale {a["sx"]},{a["sy"]}')
check(m['blurSigmaPx'] == 2.0, f'blur recovered {m["blurSigmaPx"]} px')
check(m['pearsonMatchedBlur'] > 0.99 and m['pearson'] > 0.85 and m['ap'] > 0.8, f'scores high r={m["pearson"]} rMatched={m["pearsonMatchedBlur"]} AP={m["ap"]}')
py = ib.compare(os.path.join(tmp, 'shift_blur.png'), 'w046', model=b['prediction']['model'], refs=refs, name='shift_blur.png')
check(comp(py) == comp(b), 'browser bundle identical to Python bundle (shift_blur)')
p.screenshot(path=os.path.join(shots, 'bench_side.png'))
p.click('[data-view="swipe"]'); p.wait_for_timeout(300)
cv = p.locator('#bCanvases canvas').first.bounding_box()
p.mouse.move(cv['x'] + cv['width'] * 0.5, cv['y'] + 50); p.mouse.down(); p.mouse.move(cv['x'] + cv['width'] * 0.4, cv['y'] + 60, steps=5); p.mouse.up()
p.screenshot(path=os.path.join(shots, 'bench_swipe.png'))
p.click('[data-view="diff"]'); p.wait_for_timeout(300)
p.screenshot(path=os.path.join(shots, 'bench_diff.png'))
# thresholds slider
p.fill('#bTPred', '0.3'); p.dispatch_event('#bTPred', 'input'); p.wait_for_timeout(300)
t = p.evaluate('window.unrollBench.metrics().thresholds')
check(abs(t['pred'] - 0.3) < 1e-9, 'pred threshold slider updates metrics')
b2 = p.evaluate('window.unrollBench.bundle()')
py2 = ib.compare(os.path.join(tmp, 'shift_blur.png'), 'w046', model=b['prediction']['model'], refs=refs, name='shift_blur.png', t_pred=0.3)
check(comp(py2) == comp(b2), 'identical to Python at pred threshold 0.3')
p.fill('#bTPred', '0.5'); p.dispatch_event('#bTPred', 'input'); p.wait_for_timeout(200)
# manual nudge
p.click('[data-n="x+"]'); p.wait_for_timeout(600)
na = p.evaluate('window.unrollBench.state()')
check(abs(na['alignment']['tx'] - (a['tx'] + 1)) < 1e-6 and na['method'] == 'auto+manual', 'nudge moves 1 px and marks auto+manual')
p.click('[data-n="x-"]'); p.wait_for_timeout(600)
# annotation by mouse
p.click('[data-view="side"]'); p.click('#bBox')
cv = p.locator('#bCanvases canvas').first.bounding_box()
p.mouse.move(cv['x'] + cv['width'] * 0.40, cv['y'] + cv['height'] * 0.40); p.mouse.down()
p.mouse.move(cv['x'] + cv['width'] * 0.45, cv['y'] + cv['height'] * 0.47, steps=6); p.mouse.up()
p.wait_for_timeout(200)
p.select_option('#bAnn li select', 'Ε')
p.fill('#bAnn li input[type=range]', '0.4'); p.dispatch_event('#bAnn li input[type=range]', 'input')
ann = p.evaluate('window.unrollBench.annotations()')
check(len(ann) == 1 and ann[0]['letter'] == 'Ε' and ann[0]['confidence'] == 0.4 and ann[0]['status'] == 'candidate for expert review', f'annotation drawn {ann}')
check('candidate for expert review' in p.inner_text('#bAnn'), 'box list reads candidate for expert review')
p.click('#bBox')
p.screenshot(path=os.path.join(shots, 'bench_annotate.png'))
# export
with p.expect_download() as dl:
p.click('#bExport')
zp = os.path.join(tmp, dl.value.suggested_filename); dl.value.save_as(zp)
z = zipfile.ZipFile(zp); names = sorted(n.split('/')[-1] for n in z.namelist())
check(names == ['CITATION.cff', 'README.txt', 'bundle.json', 'figure.png', 'inkbench.bib', 'share-url.txt'], f'zip contents {names}')
get = lambda suf: z.read([n for n in z.namelist() if n.endswith(suf)][0])
zb = json.loads(get('bundle.json'))
check(not ib.validate(zb), 'exported bundle passes inkbench validate')
try:
import jsonschema
jsonschema.validate(zb, json.load(open(os.path.join(ROOT, 'docs/bench-bundle.schema.json')))); check(True, 'exported bundle passes the JSON Schema')
except ImportError:
pass
pyz = ib.compare(os.path.join(tmp, 'shift_blur.png'), 'w046', model=zb['prediction']['model'], refs=refs, name='shift_blur.png', alignment=dict(zb['alignment'], method='auto+manual'), annotations=zb['annotations'])
check(comp(pyz)['metrics'] == comp(zb)['metrics'] and comp(pyz)['annotations'] == comp(zb)['annotations'], 'Python reproduces the exported bundle metrics and annotations')
fig = os.path.join(shots, 'bench_figure.png'); open(fig, 'wb').write(get('figure.png'))
R['figure_px'] = Image.open(fig).size
share_local = get('share-url.txt').decode().strip()
# 2. noise
p.set_input_files('#bPick', os.path.join(tmp, 'noise.png'))
p.wait_for_timeout(300); scored(p)
nb = p.evaluate('window.unrollBench.bundle()')
nm = nb['metrics']
R['noise'] = {k: nm[k] for k in ('pearson', 'pearsonMatchedBlur', 'iou', 'dice', 'ap')}; R['noise']['alignNcc'] = nb['alignment']['ncc']
check(abs(nm['pearson']) < 0.1 and nm['ap'] < 0.2, f'noise scores low r={nm["pearson"]} AP={nm["ap"]}')
check('low' in p.inner_text('#bAlignInfo'), 'noise alignment flagged as low confidence')
pn = ib.compare(os.path.join(tmp, 'noise.png'), 'w046', model=nb['prediction']['model'], refs=refs, name='noise.png', annotations=nb['annotations'])
check(comp(pn) == comp(nb), 'browser bundle identical to Python bundle (noise)')
p.screenshot(path=os.path.join(shots, 'bench_noise.png'))
# 3. scale case through the API with bytes
b64 = base64.b64encode(open(os.path.join(tmp, 'scale_shift.png'), 'rb').read()).decode()
p.evaluate('window.unrollBench.clearAnnotations()')
sb = p.evaluate('async (b) => window.unrollBench.compare({ segment: "w048", bytes: b, name: "scale_shift.png", model: "synthetic scale" })', b64)
sa = sb['alignment']
R['scale_shift'] = {'truth': CASES['scale_shift'][1:], 'got': [sa['tx'], sa['ty'], sa['sx'], sa['sy'], sb['metrics']['blurSigmaPx']], 'pearson': sb['metrics']['pearson'], 'pearsonMatchedBlur': sb['metrics']['pearsonMatchedBlur']}
check(abs(sa['tx'] + 12.5) <= 0.25 and abs(sa['ty'] - 9.25) <= 0.25 and abs(sa['sx'] / 1.12 - 1) < 0.003 and sb['metrics']['blurSigmaPx'] == 1.0, f'API scale case {R["scale_shift"]["got"]}')
pys = ib.compare(os.path.join(tmp, 'scale_shift.png'), 'w048', model={'name': 'synthetic scale'}, refs=refs, name='scale_shift.png')
check(comp(pys) == comp(sb), 'browser bundle identical to Python bundle (scale_shift via API)')
# 4. public layer and share links
gb = p.evaluate('async () => window.unrollBench.compare({ segment: "w049", layer: "gpu" })')
url = p.evaluate('window.unrollBench.shareURL()')
p2 = ctx.new_page(); p2.goto(url, wait_until='load'); scored(p2)
rb = p2.evaluate('window.unrollBench.bundle()')
check(rb['metrics'] == gb['metrics'] and rb['alignment']['tx'] == gb['alignment']['tx'], 'share link restores the public-layer comparison exactly')
R['public_w049'] = {'pearson': gb['metrics']['pearson'], 'ap': gb['metrics']['ap'], 'shareURLchars': len(url)}
p2.screenshot(path=os.path.join(shots, 'bench_share_restore.png')); p2.close()
p3 = ctx.new_page(); p3.goto(share_local, wait_until='load'); p3.wait_for_selector('#bench:not([hidden])'); p3.wait_for_timeout(1500)
check('Drop that same file' in p3.inner_text('#bStatus'), 'local-file share link asks for the same file')
p3.set_input_files('#bPick', os.path.join(tmp, 'shift_blur.png')); scored(p3)
lb = p3.evaluate('window.unrollBench.bundle()')
check(lb['alignment']['method'] == 'shared' and lb['metrics'] == zb['metrics'] and len(lb['annotations']) == 1, 'local share link restores after dropping the same file')
p3.close()
# 5. leaderboard and cite
p.click('[data-tab="board"]'); p.click('#bBoardEx'); p.wait_for_function("document.querySelectorAll('.b-table').length >= 37", timeout=180000); p.wait_for_timeout(800)
rows = p.evaluate("[...document.querySelectorAll('.b-table')].map(t => [...t.querySelectorAll('tbody tr')].map(r => r.children[1].textContent))")
check(len(rows) == 37 and all(r[0].startswith('scrollprize/ink_9um') and r[-1] == 'baseline: uniform noise' for r in rows), 'leaderboard ranks the 111 example bundles over 37 windings by AP, ink_9um first everywhere')
rk = p.evaluate('async () => window.unrollBench.rank([window.unrollBench.bundle()], "ap")')
check('w049' in rk, 'API rank works')
p.screenshot(path=os.path.join(shots, 'bench_leaderboard.png'))
p.click('[data-tab="cite"]'); p.wait_for_timeout(300)
ct = p.inner_text('#bench')
check('Apache License 2.0' in ct and 'CC BY-NC 4.0' in ct and 'Chase Hendrick' in ct, 'cite panel shows Apache-2.0, CC BY-NC 4.0 and attribution')
p.screenshot(path=os.path.join(shots, 'bench_cite.png'))
p.click('#bClose'); p.wait_for_timeout(200); p.click('#btnInfo', timeout=120000); p.wait_for_timeout(400)
check('Why this matters for researchers' in p.inner_text('#about'), 'About panel has the researchers section')
p.evaluate("document.querySelector('#about .bench-why').scrollIntoView()"); p.wait_for_timeout(500); p.screenshot(path=os.path.join(shots, 'bench_about.png'))
R['api'] = p.evaluate("({ shim: typeof window.unrollBench, viaUnroll: !!(window.unroll && window.unroll.bench), hasUnroll: typeof window.unroll })")
check(R['api']['shim'] == 'object' and (R['api']['hasUnroll'] != 'object' or R['api']['viaUnroll']), f'agent API {R["api"]}')
check(not errs, f'no page errors {errs[:3]}')
# 6. phone
ph = br.new_context(**pw.devices['iPhone 13'])
mp = ph.new_page(); merrs = []; mp.on('pageerror', lambda e: merrs.append(str(e)))
mp.goto(base + '?bench', wait_until='load'); mp.wait_for_selector('#bench:not([hidden])', timeout=60000)
mp.select_option('#bSeg', 'w046'); mp.wait_for_timeout(400)
mp.set_input_files('#bPick', os.path.join(tmp, 'shift_blur.png')); scored(mp)
mb = mp.evaluate('window.unrollBench.bundle()')
check(comp(mb)['metrics'] == comp(py)['metrics'], 'phone gives the same metrics')
cvb = mp.locator('#bCanvases canvas').first.bounding_box(); vw = mp.viewport_size['width']
check(cvb['width'] <= vw and cvb['x'] >= 0, f'phone canvas fits the screen ({cvb["width"]:.0f} of {vw} px)')
mp.tap('#bBox')
mp.evaluate("""() => { const c = document.querySelector('#bCanvases canvas'), r = c.getBoundingClientRect();
const ev = (t, x, y) => c.dispatchEvent(new PointerEvent(t, { bubbles: true, pointerId: 7, pointerType: 'touch', clientX: r.left + x * r.width, clientY: r.top + y * r.height }));
ev('pointerdown', .3, .3); ev('pointermove', .33, .33); ev('pointermove', .38, .4); ev('pointerup', .38, .4); }""")
mp.wait_for_timeout(200)
check(len(mp.evaluate('window.unrollBench.annotations()')) == 1, 'phone touch draws a box')
mp.screenshot(path=os.path.join(shots, 'bench_mobile.png'))
mp.evaluate("document.querySelector('.b-metrics').scrollIntoView()"); mp.wait_for_timeout(300)
mp.screenshot(path=os.path.join(shots, 'bench_mobile_metrics.png'))
check(not merrs, f'no phone page errors {merrs[:3]}')
br.close()
R['failures'] = fails
json.dump(R, open(os.path.join(shots, 'bench_results.json'), 'w'), indent=1)
print(json.dumps(R))
print('E2E', 'PASSED' if not fails else 'FAILED %d' % len(fails))
sys.exit(1 if fails else 0)
|