open-standards/wai/live-conformance/_wai_mark.py

500 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The registered watermark scheme `wai-mark:tent64` of extensions/synthesis.md §7.5, and the
attacks of its battery (§7.5.7), written from the extension's text.
Shared by the two Python verifiers of the synthesis corpora (synthesis_verify.py and
synthesis_mark_verify.py); it shares no code with wai-rs. Standard library only.
Frames are `bytes` of RGB8, frame after frame. Every function is integer arithmetic, so its
results are the extension's to the byte.
"""
import hashlib
SCHEME = "wai-mark:tent64"
GRID = 64
CELLS = GRID * GRID
LENGTH = 248 # the shortened code's length (§7.5.3)
PARITY = 184
T = 29 # the errors the code corrects
CELLS_PER_BIT = 16
AMPLITUDE = 12 # §7.5.4
MARGIN = (3, 2) # 2·σ·T ≥ 3·Q
MIN_SIDE = 128 # §7.5.1
MAX_FRAME_BYTES = 1 << 28
MAX_INT = (1 << 53) - 1
CLASS_MAX = 3
def sha256(b):
return hashlib.sha256(b).digest()
def stream(label, seed=None):
"""§7.5.3: 32-bit big-endian words, eight from each SHA-256(label ‖ [seed ‖] n)."""
n = 0
while True:
d = hashlib.sha256(label + (b"" if seed is None else seed.to_bytes(4, "big")) + n.to_bytes(4, "big")).digest()
for o in range(0, 32, 4):
yield int.from_bytes(d[o:o + 4], "big")
n += 1
def _layout():
r = stream(b"wai-mark:tent64 stream")
perm = list(range(CELLS))
for i in range(CELLS - 1, 0, -1):
j = next(r) % (i + 1)
perm[i], perm[j] = perm[j], perm[i]
signs = [1 if next(r) % 2 == 0 else -1 for _ in range(CELLS)]
mask = [next(r) & 1 for _ in range(LENGTH)]
return perm, signs, mask
PERM, SIGNS, MASK = _layout()
BIT_CELLS = [PERM[CELLS_PER_BIT * j: CELLS_PER_BIT * j + CELLS_PER_BIT] for j in range(LENGTH)]
# ---------------------------------------------------------------- the code, §7.5.3
EXP = [0] * 510
LOG = [0] * 256
_x = 1
for _i in range(255):
EXP[_i] = _x
LOG[_x] = _i
_x <<= 1
if _x & 0x100:
_x ^= 0x11D # x^8 + x^4 + x^3 + x^2 + 1
for _i in range(255, 510):
EXP[_i] = EXP[_i - 255]
def gmul(a, b):
return 0 if a == 0 or b == 0 else EXP[LOG[a] + LOG[b]]
def generator():
"""The least common multiple of the minimal polynomials of α^1 … α^58, as an integer whose
bit d is the coefficient of x^d."""
g, cosets = 1, []
for i in range(1, 2 * T + 1):
coset, j = [], i % 255
while j not in coset:
coset.append(j)
j = 2 * j % 255
if sorted(coset) in cosets:
continue
cosets.append(sorted(coset))
p = [1] # coefficients from x^0 up
for j in coset:
q = [0] * (len(p) + 1)
for d, c in enumerate(p):
q[d + 1] ^= c
q[d] ^= gmul(c, EXP[j])
p = q
assert all(c in (0, 1) for c in p), "a minimal polynomial is binary"
pm = sum(c << d for d, c in enumerate(p))
prod, a, b = 0, g, pm # product over GF(2)
while b:
if b & 1:
prod ^= a
a <<= 1
b >>= 1
g = prod
return g
G = generator()
def encode(payload):
"""The codeword of an 8-byte payload: bit j the coefficient of x^(247 − j)."""
m = int.from_bytes(payload, "big")
r = m << PARITY
for d in range(r.bit_length() - 1, PARITY - 1, -1):
if r >> d & 1:
r ^= G << (d - PARITY)
c = (m << PARITY) | r
return [c >> (LENGTH - 1 - j) & 1 for j in range(LENGTH)]
def _syndromes(c):
return [_eval(c, i) for i in range(1, 2 * T + 1)]
def _eval(c, i):
v = 0
e = 0
while c:
if c & 1:
v ^= EXP[i * e % 255]
c >>= 1
e += 1
return v
def decode(word):
"""Bounded-distance decoding within 29: (payload, bits corrected), or None where no codeword
is within 29 bits or the payload's first byte is above 3."""
c = 0
for b in word:
c = (c << 1) | b
s = _syndromes(c)
errors = 0
if any(s):
# The error locator: the shortest linear recurrence the syndromes satisfy, over GF(2^8).
lam, prev, L, m, bd = [1], [1], 0, 1, 1
for n in range(2 * T):
d = s[n]
for i in range(1, min(L, len(lam) - 1) + 1):
d ^= gmul(lam[i], s[n - i])
if d == 0:
m += 1
continue
coef = gmul(d, EXP[(255 - LOG[bd]) % 255])
old = lam[:]
lam = lam + [0] * max(0, len(prev) + m - len(lam))
for i, bv in enumerate(prev):
lam[i + m] ^= gmul(coef, bv)
if 2 * L <= n:
L, prev, bd, m = n + 1 - L, old, d, 1
else:
m += 1
while len(lam) > 1 and lam[-1] == 0:
lam.pop()
if len(lam) - 1 != L or L > T:
return None
roots = []
for e in range(255):
v = 0
for i, co in enumerate(lam):
if co:
v ^= gmul(co, EXP[(-e * i) % 255])
if v == 0:
roots.append(e)
if len(roots) != L or any(e >= LENGTH for e in roots):
return None
for e in roots:
c ^= 1 << e
if any(_syndromes(c)):
return None
errors = L
payload = (c >> PARITY).to_bytes(8, "big")
if payload[0] > CLASS_MAX:
return None
return payload, errors
# ---------------------------------------------------------------- frames, §7.5.1 and §7.5.2
def admits(w, h, n):
return w >= MIN_SIDE and h >= MIN_SIDE and n >= 1 and w * h * 3 <= MAX_FRAME_BYTES and w * h * 3 * n <= MAX_INT
def luma(r, g, b):
return (77 * r + 150 * g + 29 * b + 128) >> 8
def tent(u, length):
c = 2 * u + 1 - length
sg = (c > 0) - (c < 0)
return sg * (length - abs(2 * abs(c) - length))
def axis(n):
"""Per sample: (cell index, tent, cell length); and the edges."""
edges = [i * n // GRID for i in range(GRID + 1)]
cell, tents, lens = [], [], []
for i in range(GRID):
L = edges[i + 1] - edges[i]
for x in range(edges[i], edges[i + 1]):
cell.append(i)
tents.append(tent(x - edges[i], L))
lens.append(L)
return cell, tents, lens, edges
def luma_plane(frame):
return [luma(frame[p], frame[p + 1], frame[p + 2]) for p in range(0, len(frame), 3)]
def bit_statistics(y, w, h):
"""T_j over one luma plane of w × h."""
cx, fx, _, _ = axis(w)
cy, fy, _, _ = axis(h)
s = [0] * CELLS
for row in range(h):
acc = [0] * GRID
base = row * w
for col in range(w):
acc[cx[col]] += fx[col] * y[base + col]
r0, f = cy[row] * GRID, fy[row]
for i in range(GRID):
s[r0 + i] += f * acc[i]
return [sum(SIGNS[k] * s[k] for k in BIT_CELLS[j]) for j in range(LENGTH)]
# ---------------------------------------------------------------- embedding, §7.5.4
def embed(unmarked, w, h, n, payload):
"""The marked frames, and each frame's amplitudes (a string of 248 digits as base-36)."""
code = encode(payload)
sigma = [1 if code[j] ^ MASK[j] else -1 for j in range(LENGTH)]
_, fx, lx, ex = axis(w)
_, fy, ly, ey = axis(h)
fb = w * h * 3
out = bytearray(unmarked)
amps = []
for f in range(n):
frame = unmarked[f * fb:(f + 1) * fb]
chosen = []
for j in range(LENGTH):
sg = sigma[j]
pixels = [] # (offset, ε·ω, z, ν)
q = 0
for k in BIT_CELLS[j]:
i, jj = k % GRID, k // GRID
eps = SIGNS[k]
for row in range(ey[jj], ey[jj + 1]):
for col in range(ex[i], ex[i + 1]):
om = fx[col] * fy[row]
q += abs(om)
pixels.append(((row * w + col) * 3, eps * om, sg * eps * om, lx[col] * ly[row]))
def at(a):
t, vals = 0, []
for p, eo, z, nu in pixels:
d = (z > 0) - (z < 0)
d *= (2 * a * abs(z) + nu) // (2 * nu)
r = min(255, max(0, frame[p] + d))
g = min(255, max(0, frame[p + 1] + d))
b = min(255, max(0, frame[p + 2] + d))
t += eo * luma(r, g, b)
vals.append((p, r, g, b))
return t, vals
pick = None
for a in range(AMPLITUDE + 1):
t, vals = at(a)
if MARGIN[1] * sg * t >= MARGIN[0] * q:
pick = (a, vals)
break
if pick is None:
pick = (AMPLITUDE, at(AMPLITUDE)[1])
a, vals = pick
for p, r, g, b in vals:
out[f * fb + p] = r
out[f * fb + p + 1] = g
out[f * fb + p + 2] = b
chosen.append(a)
amps.append(chosen)
return bytes(out), amps
# ---------------------------------------------------------------- detection, §7.5.5
def orient(y, w, h, o):
ow, oh = (h, w) if o & 4 else (w, h)
out = [0] * (w * h)
for yy in range(oh):
y1 = oh - 1 - yy if o & 2 else yy
for xx in range(ow):
x1 = ow - 1 - xx if o & 1 else xx
sx, sy = (y1, x1) if o & 4 else (x1, y1)
out[yy * ow + xx] = y[sy * w + sx]
return out, ow, oh
def read_word(samples, w, h, n, o):
fb = w * h * 3
t = [0] * LENGTH
for f in range(n):
y, ow, oh = orient(luma_plane(samples[f * fb:(f + 1) * fb]), w, h, o)
for j, v in enumerate(bit_statistics(y, ow, oh)):
t[j] += v
return [(1 if t[j] > 0 else 0) ^ MASK[j] for j in range(LENGTH)]
def detect(samples, w, h, n):
"""(payload, orientation, errors) of the first orientation that decodes, or None."""
if not admits(w, h, n) or len(samples) != w * h * 3 * n:
return None
for o in range(8):
got = decode(read_word(samples, w, h, n, o))
if got is not None:
return got[0], o, got[1]
return None
# ---------------------------------------------------------------- the battery, §7.5.7
def rnd(n, d):
"""n / d rounded to the nearest integer, halves away from zero."""
sg = (n > 0) - (n < 0)
return sg * ((2 * abs(n) + d) // (2 * d))
def clamp(v):
return 0 if v < 0 else 255 if v > 255 else v
def int_motion(frame, q):
return bytes(clamp(128 + rnd(v - 128, q) * q) for v in frame)
def upscale2(px, w, h):
"""wai-synthesis:upscale2, §4.1."""
out = bytearray(4 * w * h * 3)
ow = 2 * w
for y in range(h):
y1 = min(y + 1, h - 1)
for x in range(w):
x1 = min(x + 1, w - 1)
for c in range(3):
a, b = px[(y * w + x) * 3 + c], px[(y * w + x1) * 3 + c]
cc, d = px[(y1 * w + x) * 3 + c], px[(y1 * w + x1) * 3 + c]
out[((2 * y) * ow + 2 * x) * 3 + c] = a
out[((2 * y) * ow + 2 * x + 1) * 3 + c] = (a + b + 1) >> 1
out[((2 * y + 1) * ow + 2 * x) * 3 + c] = (a + cc + 1) >> 1
out[((2 * y + 1) * ow + 2 * x + 1) * 3 + c] = (a + b + cc + d + 2) >> 2
return bytes(out)
def denoise3(px, w, h):
"""wai-synthesis:denoise3, §4.2."""
k = ((1, 2, 1), (2, 4, 2), (1, 2, 1))
out = bytearray(w * h * 3)
for y in range(h):
for x in range(w):
for c in range(3):
s = 8
for j in range(3):
yy = min(max(y + j - 1, 0), h - 1)
for i in range(3):
xx = min(max(x + i - 1, 0), w - 1)
s += k[j][i] * px[(yy * w + xx) * 3 + c]
out[(y * w + x) * 3 + c] = s >> 4
return bytes(out)
def _weights(n, m):
out = []
for d in range(m):
lo, hi = d * n, (d + 1) * n
row = []
for s in range(lo // m, -(-hi // m)):
o = min(hi, (s + 1) * m) - max(lo, s * m)
if o > 0:
row.append((s, o))
out.append(row)
return out
def area(px, w, h, nw, nh):
wx, wy = _weights(w, nw), _weights(h, nh)
den = w * h
out = bytearray(nw * nh * 3)
for dy, ry in enumerate(wy):
for dx, rx in enumerate(wx):
for c in range(3):
num = 0
for sy, oy in ry:
for sx, ox in rx:
num += oy * ox * px[(sy * w + sx) * 3 + c]
out[(dy * nw + dx) * 3 + c] = (2 * num + den) // (2 * den)
return bytes(out)
ROTATIONS = {"rotate-0.5": (65534, 572), "rotate-1": (65526, 1144), "rotate-2": (65496, 2287)}
def rotate(px, w, h, cs, sn):
one = 1 << 16
def p(x, y, c):
return 0 if x < 0 or y < 0 or x >= w or y >= h else px[(y * w + x) * 3 + c]
out = bytearray(w * h * 3)
for y in range(h):
for x in range(w):
u, v = 2 * x + 1 - w, 2 * y + 1 - h
X = (cs * u + sn * v + (w - 1) * one) >> 1
Y = (-sn * u + cs * v + (h - 1) * one) >> 1
xi, fx, yi, fy = X >> 16, X & 0xFFFF, Y >> 16, Y & 0xFFFF
for c in range(3):
val = (p(xi, yi, c) * (one - fx) * (one - fy) + p(xi + 1, yi, c) * fx * (one - fy)
+ p(xi, yi + 1, c) * (one - fx) * fy + p(xi + 1, yi + 1, c) * fx * fy)
out[(y * w + x) * 3 + c] = (val + (1 << 31)) >> 32
return bytes(out)
def attack(name, px, w, h, seed):
"""One picture attack of §7.5.7 on a single frame: (result, width, height)."""
if name == "none":
return px, w, h
if name.startswith("int-motion-q"):
return int_motion(px, int(name[len("int-motion-q"):])), w, h
if name == "staged-base-q8":
hw, hh = w // 2, h // 2
half = bytearray(hw * hh * 3)
for y in range(hh):
for x in range(hw):
for c in range(3):
s = sum(px[((2 * y + dy) * w + 2 * x + dx) * 3 + c] for dy in (0, 1) for dx in (0, 1))
half[(y * hw + x) * 3 + c] = (s + 2) >> 2
return upscale2(int_motion(bytes(half), 8), hw, hh), 2 * hw, 2 * hh
if name == "denoise3":
return denoise3(px, w, h), w, h
if name == "denoise3-x4":
for _ in range(4):
px = denoise3(px, w, h)
return px, w, h
if name.startswith("scale-"):
n, d = map(int, name[len("scale-"):].split("/"))
nw, nh = w * n // d, h * n // d
return area(px, w, h, nw, nh), nw, nh
if name == "upscale2":
return upscale2(px, w, h), 2 * w, 2 * h
if name == "aspect-3/4":
nw = w * 3 // 4
return area(px, w, h, nw, h), nw, h
if name == "half-then-int-motion-q16":
nw, nh = w // 2, h // 2
return int_motion(area(px, w, h, nw, nh), 16), nw, nh
if name.startswith("crop-"):
pc = int(name[len("crop-"):-1])
dx, dy = w * pc // 200, h * pc // 200
nw, nh = w - 2 * dx, h - 2 * dy
out = b"".join(px[((y * w) + dx) * 3:((y * w) + dx + nw) * 3] for y in range(dy, dy + nh))
return out, nw, nh
if name == "mirror":
return b"".join(px[(y * w + x) * 3:(y * w + x) * 3 + 3] for y in range(h) for x in range(w - 1, -1, -1)), w, h
if name == "rotate-90":
return b"".join(px[((h - 1 - xp) * w + yp) * 3:((h - 1 - xp) * w + yp) * 3 + 3] for yp in range(w) for xp in range(h)), h, w
if name in ROTATIONS:
cs, sn = ROTATIONS[name]
return rotate(px, w, h, cs, sn), w, h
if name.startswith("noise-"):
s = int(name[len("noise-"):])
r = stream(b"wai-mark:battery noise", seed)
return bytes(clamp(v + next(r) % (2 * s + 1) - s) for v in px), w, h
if name.startswith("brightness"):
b = int(name[len("brightness"):])
return bytes(clamp(v + b) for v in px), w, h
if name.startswith("contrast-"):
n, d = map(int, name[len("contrast-"):].split("/"))
return bytes(clamp(128 + rnd((v - 128) * n, d)) for v in px), w, h
if name == "local-blur":
out = bytearray(px)
x0, x1, y0, y1 = w // 4, 3 * w // 4, h // 4, 3 * h // 4
for by in range(y0, y1, 16):
for bx in range(x0, x1, 16):
ex, ey = min(bx + 16, x1), min(by + 16, y1)
cnt = (ex - bx) * (ey - by)
for c in range(3):
tot = sum(px[(y * w + x) * 3 + c] for y in range(by, ey) for x in range(bx, ex))
mval = (2 * tot + cnt) // (2 * cnt)
for y in range(by, ey):
for x in range(bx, ex):
out[(y * w + x) * 3 + c] = mval
return bytes(out), w, h
raise ValueError("no picture attack named %r" % name)