File size: 7,554 Bytes
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 | # SPDX-License-Identifier: Apache-2.0
"""Ink Bench test suite. Run: python3 tests/test_bench.py (needs numpy, Pillow, node 20+)
1. Alignment recovers known synthetic shifts, scales and blurs.
2. Metrics match an independent plain numpy reference.
3. The browser core (lib/bench-core.js, run in Node) and tools/inkbench.py write identical bundles,
across PNG 8/16 bit, RGB, palette, label maps and TIFF (8 bit, 16 bit, float32, LZW).
4. Pure noise scores low.
"""
import json, os, subprocess, sys, tempfile, time
import numpy as np
from PIL import Image
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
refs = ib.Refs(root=ROOT)
results = {'recovery': [], 'reference': [], 'parity': [], 'noise': []}
fails = []
def check(cond, msg):
if not cond:
fails.append(msg); print('FAIL', msg)
# 1. recovery
CASES = [('w046', 23, -17, 1, 1, 2), ('w048', -12.5, 9.25, 1.12, 1.12, 1), ('w030', 0, 0, 0.85, 0.85, 3),
('w040', 35, 20, 1.2, 1.18, 0), ('w050', -30, -25, 0.92, 0.95, 4), ('w035', 7.3, 3.6, 1.05, 1.05, 1.5),
('w055', -40, 31, 0.8, 0.8, 2.5), ('w027', 12, -8, 1.3, 1.3, 1), ('w044', -5.5, 14.2, 0.97, 1.03, 3.5),
('w051', 18, 22, 1.0, 1.0, 6)]
for seg, tx, ty, sx, sy, sg in CASES:
t = time.time()
b = ib.compare(synth.make(seg, tx, ty, sx, sy, sg), seg, refs=refs)
a, m = b['alignment'], b['metrics']
W = b['reference']['width']
e_t = max(abs(a['tx'] - tx), abs(a['ty'] - ty)); e_s = max(abs(a['sx'] / sx - 1), abs(a['sy'] / sy - 1))
e_b = abs(m['blurSigmaPx'] - sg)
r = dict(seg=seg, truth=[tx, ty, sx, sy, sg], got=[a['tx'], a['ty'], a['sx'], a['sy'], m['blurSigmaPx']],
shift_err_px=round(e_t, 3), scale_err_pct=round(100 * e_s, 3), blur_err_px=e_b,
pearson=m['pearson'], pearson_matched_blur=m['pearsonMatchedBlur'], secs=round(time.time() - t, 1))
results['recovery'].append(r); print('recovery', r)
check(e_t <= 0.25, f'{seg} shift error {e_t}')
check(e_s <= 0.003, f'{seg} scale error {e_s}')
check(e_b <= 0.5, f'{seg} blur error {e_b}')
check(m['pearsonMatchedBlur'] >= 0.99, f'{seg} matched-blur pearson {m["pearsonMatchedBlur"]}')
# 2. independent numpy reference for the metric definitions
def reference_metrics(ref, val, valid, tr, tp):
a = ref[valid]; b = val[valid]
out = {'pearson': float(np.corrcoef(a, b)[0, 1])}
A = a >= tr; B = b >= tp
out['iou'] = np.logical_and(A, B).sum() / np.logical_or(A, B).sum()
out['dice'] = 2 * np.logical_and(A, B).sum() / (A.sum() + B.sum())
out['precision'] = np.logical_and(A, B).sum() / B.sum(); out['recall'] = np.logical_and(A, B).sum() / A.sum()
ts = np.arange(50, -1, -1) / 50
P = np.array([(A & (b >= t)).sum() / max((b >= t).sum(), 1) if (b >= t).sum() else 1.0 for t in ts])
R = np.array([(A & (b >= t)).sum() / A.sum() for t in ts])
out['ap'] = float(np.sum(np.diff(np.concatenate([[0], R])) * P))
return out
rng = np.random.default_rng(5)
for seg, tr, tp in [('w046', 0.5, 0.5), ('w030', 0.3, 0.6), ('w050', 0.7, 0.2), ('w048', 0.5, 0.35)]:
ref, _ = refs.layer(seg, 'ink')
H, W = ref.shape
val = np.clip(ref * 0.7 + rng.normal(0, 0.15, ref.shape) + 0.1, 0, 1)
valid = np.ones_like(ref, bool); valid[:, :W // 10] = False
val = np.where(valid, val, 0)
m = ib.metrics(ref, val, valid, tr, tp)
r = reference_metrics(ref, val, valid, tr, tp)
d = {k: abs(m[k] - r[k]) for k in r}
results['reference'].append({'seg': seg, 'tRef': tr, 'tPred': tp, 'ours': {k: m[k] for k in r}, 'max_abs_diff': max(d.values())})
print('reference', seg, {k: (m[k], round(float(r[k]), 6)) for k in r})
check(max(d.values()) <= 1e-6, f'{seg} reference mismatch {d}')
# scipy-free check of the gaussian: unit sum and sigma
w, rr = ib.gauss_kernel(2.0); x = np.arange(-rr, rr + 1)
check(abs(sum(w) - 1) < 1e-12 and abs(np.sqrt((np.array(w) * x * x).sum()) - 2.0) < 0.02, 'gaussian kernel')
# 3. parity with the browser core
tmp = tempfile.mkdtemp()
def node(path, seg, target='ink', tr=0.5, tp=0.5, inv=False):
o = subprocess.run(['node', os.path.join(HERE, 'node_bench.mjs'), path, seg, target, str(tr), str(tp), '1' if inv else '0'],
capture_output=True, text=True, check=True)
return json.loads(o.stdout)
base = synth.make('w047', -9.5, 6.25, 1.07, 1.07, 1.5)
files = []
p = os.path.join(tmp, 'p16.png'); ib.save_png16(base, p); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'p8.png'); Image.fromarray(np.round(base * 255).astype(np.uint8)).save(p); files.append((p, 'w047', 'ink', 0.4, 0.3, False))
rgb = np.stack([base, base * 0.8, base * 0.5], -1)
p = os.path.join(tmp, 'rgb.png'); Image.fromarray(np.round(rgb * 255).astype(np.uint8)).save(p); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'pal.png'); Image.fromarray(np.round(base * 255).astype(np.uint8)).convert('P').save(p); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'label.png'); Image.fromarray((base > 0.45).astype(np.uint8)).save(p); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'f32.tif'); Image.fromarray((base * 7 - 2).astype(np.float32)).save(p); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'u16_lzw.tif'); Image.fromarray(np.round(base * 65535).astype(np.uint16)).save(p, compression='tiff_lzw'); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'u8_deflate.tif'); Image.fromarray(np.round(base * 255).astype(np.uint8)).save(p, compression='tiff_adobe_deflate'); files.append((p, 'w047', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'inv.png'); ib.save_png16(1 - base, p); files.append((p, 'w047', 'ink', 0.5, 0.5, True))
files.append((os.path.join(ROOT, 'data/bench/w049_gpu.png'), 'w049', 'ink', 0.5, 0.5, False))
files.append((os.path.join(ROOT, 'data/bench/w049_gpu.png'), 'w049', 'ct', 0.5, 0.5, False))
big = synth.make('w033', 4, -6, 1.0, 1.0, 1, c=3.3)
p = os.path.join(tmp, 'big.png'); ib.save_png16(big, p); files.append((p, 'w033', 'ink', 0.5, 0.5, False))
p = os.path.join(tmp, 'noise.png'); Image.fromarray((synth.noise('w046') * 255).astype(np.uint8)).save(p); files.append((p, 'w046', 'ink', 0.5, 0.5, False))
for path, seg, target, tr, tp, inv in files:
j = node(path, seg, target, tr, tp, inv)
py = ib.compare(path, seg, target, None, tr, tp, inv, refs=refs)
same = ib.comparable(j) == ib.comparable(py)
diff = [k for k in ib.comparable(py) if ib.comparable(py)[k] != ib.comparable(j).get(k)]
results['parity'].append({'file': os.path.basename(path), 'seg': seg, 'target': target, 'identical': same, 'pearson': py['metrics']['pearson'], 'diff': diff})
print('parity', os.path.basename(path), seg, target, same, py['metrics']['pearson'], diff)
check(same, f'parity {path} {diff}')
# 4. noise
for seed in (1, 2, 3):
b = ib.compare(synth.noise('w046', seed), 'w046', refs=refs)
m = b['metrics']
results['noise'].append({'seed': seed, 'pearson': m['pearson'], 'iou': m['iou'], 'dice': m['dice'], 'ap': m['ap'], 'alignNcc': b['alignment']['ncc']})
print('noise', results['noise'][-1])
check(abs(m['pearson']) < 0.1 and m['ap'] < 0.2, 'noise scored high')
json.dump(results, open(os.path.join(tempfile.gettempdir(), 'inkbench_results.json'), 'w'), indent=1)
print('FAILURES' if fails else 'ALL PASSED', len(fails))
sys.exit(1 if fails else 0)
|