Benchmarks
Read this if you need the measured figures behind a Laidline claim: latency, what each watermark survives, false positives, validator agreement and image sizes. You will find every number with its corpus, hardware and interval, and the command that reruns a suite on your own media. Skip if you want to know why a mark fails and what could recover it (Robustness) or how a result becomes a grade (Grading). Time: 10 minutes to find a figure; a suite run takes minutes to hours by size. Needs:
pip install laidline-benchto rerun a suite; nothing to read the figures.
In short
- Every figure carries its corpus, sample size and Wilson 95 percent interval; read the interval before the rate.
- Latency is measured on named hardware and moves 15 to 20 percent between runs; compare rows only within one table.
- Robustness runs use public corpora (COCO, LibriSpeech, Goldberg Variations, Xiph derf, C4) and one seed; in the pilot a GPU run gave the same decisions as the CPU.
- False positives are measured on unmarked media: 0.16 percent for the TrustMark chain, 0 of 5,000 for PixelSeal in the earlier run, and 0 of 38,500 photos, 0 of 4,854 clips and 0 grade B on 40,000 reads of 20,000 texts in the 2026-10 null campaign (image, video, audio and text sections below, which also state the results that fall short of their lines).
laidline benchreruns the image, audio, video and text suites on your own media.
Reproduce with laidline-bench
The four robustness suites ship as a separate package, so a production install stays small:
pip install laidline-bench
Expected output ends with:
Successfully installed ... laidline-bench-1.0.0
What just happened: pip installed the benchmark harness and, through it, laidline with the image, audio, video
and text extras. Without it laidline bench prints laidline bench needs the laidline-bench package: pip install laidline-bench to stderr and exits 2.
laidline bench --help
Expected output:
Usage: laidline bench [OPTIONS] [suite] Run a watermark robustness benchmark. Image, audio and video: embed, transform and decode on a directory of photos, audio or video clips. Text: generate, transform and detect on a C4 shard. Corpora and published results: https://laidline.com/docs/robustness. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ suite <str> Benchmark suite: image (TrustMark), audio (AudioSeal), │ │ video (VideoSeal, keyframe TrustMark) or text (SynthID │ │ and KGW). │ │ [default: image] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --corpus DIR Directory of unmarked media (text: │ │ a C4 shard or JSON-lines file). │ │ --n <int> Images, clips or prompts to sample │ │ (seeded; 50, or 20 for video and │ │ text). │ │ --workers <int> Worker processes, each with its │ │ own watermark models. │ │ [default: 2] │ │ --modes <str> Audio only: batch,streaming │ │ (default both). │ │ --transforms <str> Comma-separated transform names, │ │ or all. │ │ --out <str> JSON result (default: │ │ SUITE-robustness-DATE.json). │ │ --device <str> Where the suite's models run: cpu, │ │ cuda or auto (video also cuda:N; │ │ text: the generating model, │ │ detection stays on CPU). Default: │ │ the harness's own. │ │ --schemes <str> Video only: │ │ videoseal,trustmark_keyframes. │ │ --model <str> Text only: the generating model, a │ │ Hugging Face id or local │ │ directory. │ │ --music-bed FILE Audio only: the music file that │ │ music_bed_10db mixes in (the row │ │ is not run without one). │ │ --sweeps --no-sweeps Run the sweeps (image: off by │ │ default; video and text: on; │ │ audio: on the first --sweep-n │ │ clips). │ │ --sweep-families <str> Image and video only: │ │ comma-separated sweep families │ │ (video: or points). │ │ --sweep-n <int> Items of the sample that run the │ │ sweeps (default: the harness's │ │ own). │ │ --resume Continue the interrupted run │ │ checkpointed next to --out (same │ │ arguments). │ │ --gen-batch <int> Text only: prompts per generate │ │ call (default 1 on cpu, 8 on │ │ cuda). │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯
The suites, and the section of this page whose figures each one reproduces:
| suite | command | what it runs | figures |
|---|---|---|---|
image |
laidline bench image --corpus DIR --n 500 --workers 8 --sweeps |
embed with TrustMark Q, B and C, transform, decode; --sweeps adds the parameter ladders |
Image robustness |
audio |
laidline bench audio --corpus DIR --n 500 --workers 8 |
AudioSeal in batch and streaming mode through each transform | Audio robustness |
video |
laidline bench video --corpus DIR --n 20 --device auto |
VideoSeal and the keyframe TrustMark scheme through each transform | Video robustness |
text |
laidline bench text --corpus FILE --n 200 --gen-batch 8 --device auto |
SynthID and KGW: generate, transform, detect | Text robustness |
Every suite needs a corpus of your own unmarked media (--corpus DIR; for text a C4 shard or a JSON-lines file), samples
it with a fixed seed and writes one JSON result next to your working directory (--out renames it). The corpora behind
the published figures are named in each section. Each suite also takes --device cpu|cuda|auto; a GPU run gives the same
decisions as a CPU run but not bit-identical soft outputs, so a few rows move inside their intervals (see the "Changes
from the published" sections). Your rates differ with your media, so compare them with the intervals below, not the
point values. The sections for phantom decodes, latency, Vault and S3 sinks, validators and Docker images describe
measurements made on Laidline's own test hardware; they are not laidline bench suites, and their figures stand as
measured.
What the watermarks survive, why they fail and how much a decode-side fix could recover: Robustness baseline. The robustness sections below hold the full tables of those runs. The tables are the published figures; the per-item records behind them are not distributed with the package.
Image latency
PixelSeal, the default scheme
| op (1024 px, real photos, TSA per asset) | 2 CPU threads p50 / p95 | 16 CPU threads p50 / p95 | RTX 5080 p50 / p95 |
|---|---|---|---|
| PixelSeal mark | 980 / 1300 ms (repeat 1165 / 1285) | 646 / 682 ms | 530 / 589 ms |
| PixelSeal detect | 245 / 278 ms | not measured | 246 / 310 ms |
| TrustMark Q mark | 600 / 679 ms | not measured | not measured |
Measured on a Ryzen 7 7800X3D, real COCO photos, TSA per asset; the 2-thread rows were pinned to two cores; run-to-run spread 15 to 20 percent.
TrustMark Q, random noise
20 marks of a 1024x1024 random-noise PNG, TrustMark Q on CPU, local PEM signer, evidence file sink. Measured on a test machine with 16 logical CPUs.
Without a timestamp authority:
| stage | p50 ms | p95 ms |
|---|---|---|
| watermark | 97 | 97 |
| encode (PNG) | 249 | 249 |
| sign | 43 | 43 |
| total | 319 | 341 |
With DigiCert as the timestamp authority (tsa.required: true):
| stage | p50 ms | p95 ms |
|---|---|---|
| watermark | 122 | 152 |
| encode (PNG) | 99 | 106 |
| sign (incl. TSA round trip) | 166 | 169 |
| total | 397 | 425 |
Notes:
- Encode time is PNG compression of incompressible noise at 1024 px; real renders compress faster (the second run shows 99 ms for the same input, the first run overlapped with another process).
- The TSA adds about 120 ms per asset from this network.
tsa.mode: checkpoint_onlyremoves it (tsa.required: falsedoes not: a reachable TSA is still called; it only lets the mark succeed when none answers);checkpoint_onlythen timestamps one head hash per checkpoint instead, off the marking path, and the C2PA signal grades B instead of A (Grading). - Both runs are well inside the 1.2 s p50 budget Laidline designs for, on a machine faster than the target. On 2 CPU threads the sidecar's 1024 px mark (1.54 s p50) is over that budget (see "Sidecar latency", below); 16 threads bring the proxy overhead to about 0.6 s (16 threads, unpinned, host load about 1.5; not a pinned measurement).
- These runs used the local PEM signer. Vault Transit and the S3 evidence sink add the latencies measured below.
Sidecar latency
| op (PixelSeal, 1024 px, TSA per asset; server and client on the same two cores) | p50 ms | p95 ms |
|---|---|---|
/v1/mark/image (http-mark) |
1542 | 1821 |
/detect (http-detect) |
335 | 392 |
| proxied image generation (proxy-gen) | 1469 | 1596 |
Measured on a Ryzen 7 7800X3D, real COCO photos; server and client were pinned to the same two cores; run-to-run spread 15 to 20 percent. A separate run with 16 CPU threads, unpinned, on a host at load about 1.5 (not a pinned measurement, so not comparable with the table) put the proxy at 832 ms p50 against 249 ms direct to the upstream.
Signing through Vault or OpenBao Transit
A live run signs 1000 times through VaultTransitBackend against a Vault and an OpenBao dev server on the same
machine (ECDSA P-256 key, prehashed SHA-256, JWS marshaling). Measured on a test machine:
| server | calls | p50 ms | p95 ms |
|---|---|---|---|
| Vault 2.1.1 | 1000 | 1.45 | 2.76 |
| OpenBao 2.7.0 | 1000 | 1.33 | 2.12 |
These are loopback numbers. Over a network, each sign call adds a round trip to the Vault server, like KMS does.
S3 evidence sink writes
A live run appends marked entries to an Object Lock evidence bucket (GOVERNANCE) on real AWS S3 in eu-central-1.
Each write is two pointer PUTs in parallel, then the entry PUT. Measured from a test machine outside AWS (about 124 ms
round trip to eu-central-1):
| write | p50 ms | p95 ms |
|---|---|---|
| marked entry (2 pointers + entry) | 533 | 556 |
The time is network-bound (533 ms is about four 124 ms round trips across the two sequential phases). Measured with Object Lock and the bucket-default GOVERNANCE retention; no comparison without the lock was run. Writes stay synchronous under the chain lock, so no asset is returned before its entry is stored. This bounds one process to roughly two marked entries per second at this distance, less from further away. Run the worker close to the bucket, or use Postgres as the primary sink for volume and for detect at scale, with the S3 Object Lock bucket as a replica.
What this means. An async writer for secondary S3 sinks is worth having only if the write p95 exceeds 150 ms. This measurement crosses that line, but it is network-bound from a remote client, so it says little about the sink's own cost. Writes stay synchronous, so an S3 sink, primary or replica, adds its full write latency to every mark under the chain lock. Run the marker in the bucket's region, or use Postgres as the primary sink. The in-region measurement follows.
S3 evidence sink writes, in region (Lambda eu-central-1, 3008 MB)
A live run invokes the deployed function with the test-only bench event {"laidline": "bench-s3-sink", "n": 200}
(honoured only with LAIDLINE_LAMBDA_BENCH=1): 200 synthetic marked entries appended to a
fresh chain on the function's S3 sink (the Object Lock evidence bucket, GOVERNANCE default retention, prefix
lambda-it/bench-<id>/), timed per append inside the function. Same write path as above (two pointer PUTs in
parallel, then the entry PUT), same bucket, but from inside eu-central-1. Measured (container image,
x86_64, 3008 MB):
| invoke | appends | p50 ms | p95 ms | max ms | REPORT Duration | Init Duration |
|---|---|---|---|---|---|---|
| cold (fresh execution environment) | 200 | 54.9 | 77.0 | 109.0 | 12186 ms | 2583 ms |
| warm | 200 | 54.7 | 77.4 | 108.3 | 11620 ms | none |
In region a marked entry costs about 55 ms at the median and 77 ms at p95, against 533 and 556 ms from the remote machine above: the remote numbers were round trips, not the sink. The bench does not load the Marker, so its cold start is the runtime init only.
The same run, for the storage hook itself (REPORT lines from the function's log; 512x384 images):
| invocation | Duration | Init Duration |
|---|---|---|
| first mark after a deploy (three uploads, three new environments in parallel) | 61.4 to 67.4 s | 3.0 to 9.1 s |
| first mark in an environment whose image is already cached (Marker and TrustMark load, then one mark) | 17.1 s | none |
| warm mark (one PNG: KMS sign, evidence entry, checkpoint with a DigiCert token, S3 get and put) | 0.93 to 0.97 s | none |
| warm, already-marked key skipped by metadata (and the write-back's own notification) | 14 to 44 ms | none |
The first mark after a deploy takes about a minute, 44 to 50 s more than a first mark on a cached image: Lambda loads
container image layers on first access, and this one is 2.7 GB (torch and the TrustMark weights). The 120 s timeout
leaves room, but a larger image or a slower first read would not.
From upload to marked in place (S3 notification, SQS and the event-source mapping included): 77 s on the first run
after a deploy (the live test's three uploads); in a separate manual run right after it (three PNG uploads one after
another, each polled with head_object until marked; the 17.1 s and 0.93 to 0.97 s rows above are its REPORT
lines), 20.9 s for the first, a cold Marker on a cached image, and 3.4 s for each of the two warm ones.
Recommendation. Async writes for secondary S3 sinks pay off only if the
in-region p95 exceeds 150 ms. The in-region p95 is 77 ms (p50 55 ms), under the line. Synchronous S3 writes stay the
recommendation in region, primary or replica: an in-region S3 sink adds about 55 to 77 ms to a mark under the chain
lock, which bounds one chain to roughly 13 to 18 marked entries per second. The async replica writer
(evidence.replicas: async) is for S3 replicas in another region or reached over a long network path, where each write
costs the round trips measured from the remote machine above.
Replica recommendation (evidence.replicas). Rule: if the in-region S3 write p95 is at
most 150 ms, sync stays the recommendation in region and async is recommended for cross-region or remote
replicas; otherwise async is recommended for every S3 replica. Measured: from a remote machine to eu-central-1, p50 533 ms and
p95 556 ms (above); in region, from a Lambda in eu-central-1 against the same Object Lock bucket, p50 54.9 ms, p95
77.0 ms, max 109 ms (n=200). The in-region p95 is under 150 ms, so the default and the in-region recommendation stay
sync: every replica holds every entry before the mark returns, at about 77 ms added per mark at p95. Set
evidence.replicas: async when an S3 replica is in another region or reached over a remote link. Each replica then
gets its own FIFO queue (evidence.replica_queue_max, default 10,000) and writer thread, so the mark pays only the
primary write. The trade: the replica lags the primary; a full queue drops entries, each counted in
laidline_evidence_sink_failures_total and laidline_evidence_replica_dropped_total; entries still queued after
evidence.replica_drain_timeout_s (default 30 s) at shutdown are counted in laidline_evidence_sink_failures_total
only (a crash or SIGKILL before close() loses whatever is queued without counting it); and verify on that replica
reports those gaps later, not at mark time. Watch laidline_evidence_replica_queue_depth (or replica_queue_depth in
GET /admin/evidence/status). The primary is always written synchronously, whatever this setting.
TrustMark quality on CPU (variant Q, BCH_5, 61-bit payload)
| input | PSNR dB |
|---|---|
| 256 px pure noise | 28.0 (below the 38 dB floor, watermark skipped) |
| 384 px pure noise | 39.2 |
| 512 px pure noise | 41.6 |
| 1024 px pure noise | 45.2 |
| 256 to 1024 px smooth gradients | 46.8 to 47.7 |
These are single-image probes on synthetic inputs. On 500 COCO photos the embed PSNR of variant Q averages 42.02 dB (5th percentile 38.94 dB) and 7 of 500 fall below the 38 dB floor; what the payload survives (crop, resize, recompression, rotation, blur, screenshots) is measured in "Image robustness" below.
Phantom decode rate (TrustMark, unmarked photos)
Neural watermark decoders emit payloads on images that were never marked (Krawetz, "Meta's Un-Stable Signature",
hackerfactor.com, June 30, 2026, reports about 10 to 20 percent for TrustMark on his data). This bench measures it
for Laidline's exact decoder configuration: TrustMark 0.9.2, BCH_5, 61-bit payload, binary mode, Q then B then C as
detect runs them, a payload accepted only when the decoder reports it detected and it is 61 bits long.
The phantom run is not one of the four laidline bench suites; its figures stand as measured on the corpus below.
Earlier run. This is the earlier false-positive basis for photos (5,000 COCO val2017 photos). The 2026-10 null campaign measured PixelSeal, the default scheme, and keyed PixelSeal on 38,500 further unmarked photos: 0 of 38,500 each (Wilson 95 % upper bound 0.0100 %; Image null campaign). The figures below stand as the history of this run.
COCO val2017, 5,000 unmarked JPEGs, 0 unreadable. Measured on a test machine (16 logical CPUs, 8 workers): machine.decode_seconds 239.6 (3 variants per image), machine.wall_seconds 473.7
including worker start-up (101.2 s). Run again on the RTX 5080 (torch 2.14.0+cu130, 8 workers;
the bench turns TF32 off on cuda, but this run's machine block does not record the TF32 or cuDNN flags) with
--device cuda --save-logits: the same 8 phantoms (Q 1, B 5, C 2) and every
statistic in the table below identical; machine.decode_seconds 108.5, machine.wall_seconds 257.8 including
worker start-up (56.6 s).
The result ships as laidline/compliance/data/phantom.json (now the GPU run); the detect caveat and
the marking-method text of the evidence export quote it.
| decoder | n | phantoms | rate | 95% interval (Wilson) | payload version 1 | distinct payloads | largest Hamming-6 cluster | share of ones | monobit chi2 (p) | serial chi2 (p) | detected under any TrustMark schema |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Q | 5000 | 1 | 0.02% | 0.0035% to 0.11% | 0 of 1 | 1 | 1 | 49.2% | 0.02 (0.90) | 3.60 (0.31) | 126 (2.5%) |
| B | 5000 | 5 | 0.10% | 0.043% to 0.23% | 1 of 5 | 5 | 1 | 51.8% | 0.40 (0.53) | 1.41 (0.70) | 162 (3.2%) |
| C | 5000 | 2 | 0.04% | 0.011% to 0.15% | 2 of 2 | 2 | 1 | 41.8% | 3.28 (0.07) | 3.07 (0.38) | 145 (2.9%) |
| chain Q/B/C | 5000 | 8 | 0.16% | 0.081% to 0.32% | 3 of 8 | 8 | 1 | 49.0% | 0.20 (0.65) | 0.87 (0.83) | 425 (8.5%) |
Reading the table:
- The TrustMark fast decoder chain (Q/B/C) accepts 8 of 5,000 unmarked photos (0.16 percent); the TrustMark hardened decode 0 of 5,000; PixelSeal, the default scheme, 0 of 5,000. The fast decoder's rate is 0.02 to 0.10 percent per variant. (Earlier run; the PixelSeal figure is 0 of 38,500 in the 2026-10 null campaign.) The chain's first hits came from Q 1, B 5, C 2; no image decoded under two variants.
- Attractors: none at this sample size. All 8 phantom payloads are distinct, no two are within Hamming distance 6
of each other, and the 8 issuer ids are all different. The top payload in the chain list (
166cc2b25f475710, issuer 2542635, seq 10188445456) occurred once. - Bias and serial correlation: not detectable here. Monobit (pooled ones against 50/50, 1 dof) and serial
(non-overlapping adjacent pairs within each payload against uniform, 3 dof) chi-square over the phantom payload bits
give p between 0.07 and 0.90. With 8 phantoms (488 bits) these tests have little power; they would show a
Krawetz-style bias only if phantoms were far more common. This run used TrustMark's public
decode, which does not return the pre-ECC bits in binary mode, so it did not test the raw decoder output that Krawetz tested. Laidline's decoder now keeps the 100 raw bits in the same pass (TrustMarkImageWatermarker.decode_variant), which the image robustness bench below uses. The GPU run saved the 100 pre-threshold decoder outputs of every image and decoder (--save-logits), so the raw-bit statistics can now be computed offline; they are not computed yet. The phantom bit strings are stored in the JSON (phantom_bits), so every statistic can be recomputed without a re-run. - TrustMark's own "detected" flag fires far more often than Laidline accepts a payload. The decoder reads the BCH schema from two of the packet's own version bits and reports "detected" whenever the word falls within the correction radius of that schema's code. Counting every such detection (last column) gives 2.5 to 3.2 percent per variant and 8.5 percent for the chain, mostly schema 3 (75 bits, 3-bit correction: 110, 142 and 113 detections for Q, B, C) and schema 2 (68 bits: 15, 15, 30). Laidline rejects everything that is not a 61-bit BCH_5 payload, which leaves 8. This any-schema rate is a likely contributor to the 10 to 20 percent Krawetz reports for TrustMark (his exact decoder settings are not published), not a full explanation of it.
- The decoder output is not random. For uniformly random decoder output the acceptance per decode is roughly 0.047 percent as BCH_5 and 2.1 percent under any schema (about 2.3 and 106 expected detections per variant in 5,000 images). These figures are approximate: they model a bounded-distance BCH decoder on 96 bits and ignore TrustMark's padding of data and ECC to whole bytes before the bchlib decode. BCH_5 hits (1, 5, 2) match the random expectation, but the any-schema detections (126, 162, 145) are a significant excess. Schema 3 alone expects about 88 per variant, and B's 142 is about 5.7 standard deviations high (Q 2.4, C 2.7); C's 30 schema-2 detections against about 16 expected are 3.4 high. So on unmarked photos the decoder's output clusters near schema-3 and schema-2 codewords more than chance allows, and the 61-bit BCH_5 check is what keeps Laidline's rate low.
- Payload version. Laidline only writes payload version 1, yet 5 of the 8 chain phantoms carry another version
(0, 2, 5, 5, 6). Detect now refuses a payload whose version is not 1 (
payload_version_ok: false, grade D). - Parse rate is 100 percent: every 61-bit string maps to some
(version, issuer_id, asset_seq).
Registry false positives without confirmation (what marked_by_this_system would have meant before a registry hit
had to be confirmed): the expected share of unmarked images whose phantom resolves to a registered asset, under a
uniform asset_seq model (P(hit) = N / 2^36 per phantom) and assuming every phantom carries your issuer id (the
worst case; for a uniformly random issuer id multiply by a further 2^-22):
| registry size N | P(hit) per phantom | expected false hits per unmarked image | one in |
|---|---|---|---|
| 10,000 | 1.46e-07 | 2.33e-10 | 4,294,967,296 |
| 1,000,000 | 1.46e-05 | 2.33e-08 | 42,949,673 |
| 10,000,000 | 0.000146 | 2.33e-07 | 4,294,967 |
| worst case: the top phantom payload itself registered | 1 | 0.0002 (1 image) | 5,000 |
So the uniform model makes chance registry hits rare, and the risk sits in non-uniform decoder output (an attractor payload that some real asset happens to carry). None showed up in 5,000 photos, but the worst-case row is why detect confirms every registry hit by perceptual fingerprint or exact bytes.
Fingerprint separation (phash64/v1, 64-bit DCT perceptual hash, Hamming distance; 500 randomly sampled COCO images;
computed with the bench's implementation of the phash64/v1 spec, which is bit-for-bit equal to
laidline.media.fingerprint across image modes):
| pair | p50 | p95 | max | p5 | min | share at distance <= 10 |
|---|---|---|---|---|---|---|
| image vs its JPEG q75 re-encode | 0 | 2 | 2 | 100% | ||
| image vs its 60 percent resize | 0 | 0 | 2 | 100% | ||
| image vs a random other image | 32 | 24 | 20 | 0% |
Related images stay within 2 bits and unrelated images never come closer than 20, so detect.fingerprint_max_distance: 10 sits in a wide gap on this corpus. Heavy crops are not covered by this test (pHash is weak to them; a cropped
marked asset can drop to grade C, which is the fail-toward-unverified direction; in keyed mode that C is shown on the
full tier only, and the public tier shows D or nothing, Payload keys; the same holds for an unconfirmed own video payload
in keyed mode).
Image robustness (TrustMark, marked COCO photos)
What the watermark survives. For 500 photos sampled from COCO val2017 (seed 0), each variant embeds a seeded 61-bit payload on its own (no fallback, production PSNR floor 38 dB), then every marked image goes through each transform and is decoded by that variant's decoder (keeping the 100 raw decoder bits) and by detect's Q/B/C chain. Sweeps rerun the geometric, compression and tone families over a parameter ladder on the first 100 images. What the failures mean and how much a decode-side search could recover is in Robustness baseline.
# with the cu130 torch wheels; drop --device cuda to run on the CPU laidline bench image --corpus DIR --n 500 --workers 8 --device cuda --sweeps
Point --corpus at a directory of your own unmarked photos; the published run sampled COCO val2017.
Measured on the RTX 5080 (torch 2.14.0+cu130, TF32 off, cuDNN deterministic, 8 workers):
machine.run_seconds 933.8, machine.wall_seconds 994.5; 0 unreadable images. The result ships as
laidline/compliance/data/robustness.json (with the sweeps and "Where it breaks", without the per-image records and
soft outputs) and the marking-method text of the evidence export quotes variant Q from it. The first published run
(CPU) had 18 rows; the GPU gives the same decisions as the CPU (pilot: 75 identical, 0 differ)
but not bit-identical soft outputs, so a few of those rows moved slightly (below).
Columns: decode rate is the payload recovered exactly by the variant's own decoder, with its Wilson 95 percent
interval; mis-decode is a detected but different payload; raw bits is the agreement of the 100 raw decoder bits
with the embedded BCH codeword (mean and 5th percentile; about 50 percent is chance); chain TPR is detect's Q/B/C
chain recovering the exact payload, at the operating point whose false-positive rate (FPR) is the measured chain
phantom rate above. Rows starting oracle_ are not product numbers: they undo the attack with its true parameters
before decoding, which estimates what a perfect decode-side resync could recover.
Transforms (transform_definitions in the JSON):
| transform | definition |
|---|---|
| identity | PNG round trip |
| resize_0.5 | resize to 0.5x each side (Lanczos) |
| resize_0.75 | resize to 0.75x each side (Lanczos) |
| resize_1.5 | resize to 1.5x each side (Lanczos) |
| crop_90 | centre crop keeping 90 percent of each side |
| crop_75 | centre crop keeping 75 percent of each side |
| crop_50 | centre crop keeping 50 percent of each side |
| jpeg_90 | JPEG q90, 4:2:0 |
| jpeg_75 | JPEG q75, 4:2:0 |
| jpeg_50 | JPEG q50, 4:2:0 |
| webp_80 | WebP q80 |
| webp_50 | WebP q50 |
| rotate_+5 | rotate 5 degrees counter-clockwise (bicubic, same canvas, black fill) |
| rotate_-5 | rotate 5 degrees clockwise (bicubic, same canvas, black fill) |
| rotate_90 | rotate 90 degrees (lossless transpose) |
| blur_1 | Gaussian blur, radius 1 |
| blur_2 | Gaussian blur, radius 2 |
| screenshot | fit into 90 percent of a 1920x1080 white canvas, centred, rescale x0.97, JPEG q85 |
| flip_h | horizontal mirror (lossless) |
| rotate_+2 | rotate 2 degrees counter-clockwise (bicubic, same canvas, black fill) |
| rotate_-2 | rotate 2 degrees clockwise (bicubic, same canvas, black fill) |
| rotate_+15 | rotate 15 degrees counter-clockwise (bicubic, same canvas, black fill) |
| rotate_180 | rotate 180 degrees (lossless transpose) |
| crop_75_corner | top-left crop keeping 75 percent of each side |
| aspect_0.8 | height resized to 0.8x, width kept (Lanczos) |
| pad_10 | black border added on every edge, 10 percent of the width left and right, 10 percent of the height top and bottom |
| overlay_caption | white bar over the bottom 15 percent of the height with the black text 'caption text' (Pillow built-in font, size half the bar) |
| overlay_logo | opaque flat red square, side 12 percent of the shorter side, over the bottom-right corner |
| brightness_+20 | brightness x1.2 (Pillow ImageEnhance.Brightness) |
| contrast_0.7 | contrast x0.7 about the mean grey (Pillow ImageEnhance.Contrast) |
| saturation_0 | saturation 0, greyscale kept as RGB (Pillow ImageEnhance.Color) |
| gamma_1.5 | gamma 1.5 per channel, out = 255 * (in / 255) ** 1.5 (darkens) |
| sharpen | Pillow SHARPEN 3x3 filter |
| noise_5 | additive Gaussian noise, sigma 5 of 255 per pixel and channel, seed 0, rounded and clipped |
| palette_256 | quantise to an adaptive 256-colour palette (Pillow median cut, no dither, GIF-like), back to RGB |
| jpeg_75x2 | JPEG q75, 4:2:0, twice |
| oracle_rotate_+5 | Oracle (not a product number): rotate_+5, then rotated back 5 degrees clockwise (bicubic, same canvas, black fill) with the true angle |
| oracle_crop_50 | Oracle (not a product number): crop_50, then pasted at its true position on a mid-grey (128, 128, 128) canvas of the original size |
| oracle_screenshot | Oracle (not a product number): screenshot, then the photo's true box on the capture cut out and resized to the original size (Lanczos) |
TrustMark 0.9.2 robustness (BCH_5, 61-bit payload, each variant forced, PSNR floor 38 dB): corpus COCO val2017, n=500, seed 0, measured.
Operating point: FPR 0.16% (95% interval 0.081% to 0.32%), the phantom rate of detect's Q/B/C chain on n=5000 unmarked photos (COCO val2017); TPR is the chain rate at that FPR.
Variant Q: embed PSNR mean 42.02 dB, p5 38.94 dB; skipped 7 of 500 (below 38 dB or too small).
| transform | n | decode rate | 95% interval | mis-decode | raw bits mean | raw bits p5 | chain TPR | FPR |
|---|---|---|---|---|---|---|---|---|
| identity | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| resize_0.5 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| resize_0.75 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| resize_1.5 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| crop_90 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| crop_75 | 493 | 31.8% | 27.9% to 36.1% | 0% | 88.4% | 70.0% | 31.8% | 0.16% |
| crop_50 | 493 | 0% | 0% to 0.77% | 0% | 50.6% | 43.0% | 0% | 0.16% |
| jpeg_90 | 493 | 99.8% | 98.9% to 100.0% | 0% | 99.9% | 99.0% | 99.8% | 0.16% |
| jpeg_75 | 493 | 98.8% | 97.4% to 99.4% | 0% | 99.7% | 99.0% | 98.8% | 0.16% |
| jpeg_50 | 493 | 96.8% | 94.8% to 98.0% | 0% | 99.2% | 97.0% | 96.8% | 0.16% |
| webp_80 | 493 | 98.2% | 96.6% to 99.0% | 0% | 99.6% | 98.0% | 98.2% | 0.16% |
| webp_50 | 493 | 93.9% | 91.4% to 95.7% | 0% | 98.4% | 94.0% | 93.9% | 0.16% |
| rotate_+5 | 493 | 1.2% | 0.56% to 2.6% | 0% | 65.2% | 47.0% | 1.2% | 0.16% |
| rotate_-5 | 493 | 2.6% | 1.5% to 4.5% | 0.2% | 65.6% | 47.0% | 2.6% | 0.16% |
| rotate_90 | 493 | 0% | 0% to 0.77% | 0% | 50.4% | 43.0% | 0% | 0.16% |
| blur_1 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| blur_2 | 493 | 99.6% | 98.5% to 99.9% | 0% | 99.9% | 99.0% | 99.6% | 0.16% |
| screenshot | 493 | 0% | 0% to 0.77% | 0.2% | 51.9% | 44.0% | 0% | 0.16% |
| flip_h | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| rotate_+2 | 493 | 90.1% | 87.1% to 92.4% | 0% | 98.5% | 93.0% | 90.1% | 0.16% |
| rotate_-2 | 493 | 93.1% | 90.5% to 95.0% | 0% | 98.6% | 94.0% | 93.1% | 0.16% |
| rotate_+15 | 493 | 0% | 0% to 0.77% | 0% | 49.9% | 42.0% | 0% | 0.16% |
| rotate_180 | 493 | 0% | 0% to 0.77% | 0% | 51.1% | 42.0% | 0% | 0.16% |
| crop_75_corner | 493 | 0% | 0% to 0.77% | 0% | 53.2% | 43.0% | 0% | 0.16% |
| aspect_0.8 | 493 | 93.9% | 91.4% to 95.7% | 0% | 97.5% | 68.0% | 93.9% | 0.16% |
| pad_10 | 493 | 0% | 0% to 0.77% | 0% | 51.1% | 43.0% | 0% | 0.16% |
| overlay_caption | 493 | 99.6% | 98.5% to 99.9% | 0% | 99.9% | 99.0% | 99.6% | 0.16% |
| overlay_logo | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| brightness_+20 | 493 | 95.7% | 93.6% to 97.2% | 0% | 99.2% | 95.0% | 95.7% | 0.16% |
| contrast_0.7 | 493 | 100.0% | 99.2% to 100.0% | 0% | 99.9% | 99.0% | 100.0% | 0.16% |
| saturation_0 | 493 | 94.5% | 92.1% to 96.2% | 0% | 99.0% | 95.0% | 94.5% | 0.16% |
| gamma_1.5 | 493 | 99.8% | 98.9% to 100.0% | 0% | 99.7% | 98.0% | 99.8% | 0.16% |
| sharpen | 493 | 100.0% | 99.2% to 100.0% | 0% | 99.9% | 99.0% | 100.0% | 0.16% |
| noise_5 | 493 | 100.0% | 99.2% to 100.0% | 0% | 99.9% | 99.0% | 100.0% | 0.16% |
| palette_256 | 493 | 99.4% | 98.2% to 99.8% | 0% | 99.7% | 99.0% | 99.4% | 0.16% |
| jpeg_75x2 | 493 | 98.8% | 97.4% to 99.4% | 0% | 99.7% | 98.0% | 98.8% | 0.16% |
| oracle_rotate_+5 | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| oracle_crop_50 | 493 | 0% | 0% to 0.77% | 0.2% | 82.1% | 74.0% | 0% | 0.16% |
| oracle_screenshot | 493 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
Variant B: embed PSNR mean 41.18 dB, p5 38.32 dB; skipped 16 of 500 (below 38 dB or too small).
| transform | n | decode rate | 95% interval | mis-decode | raw bits mean | raw bits p5 | chain TPR | FPR |
|---|---|---|---|---|---|---|---|---|
| identity | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 99.8% | 0.16% |
| resize_0.5 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| resize_0.75 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| resize_1.5 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| crop_90 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| crop_75 | 484 | 68.2% | 63.9% to 72.2% | 0% | 94.6% | 78.0% | 68.2% | 0.16% |
| crop_50 | 484 | 0% | 0% to 0.79% | 0% | 50.3% | 42.0% | 0% | 0.16% |
| jpeg_90 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 99.8% | 0.16% |
| jpeg_75 | 484 | 99.4% | 98.2% to 99.8% | 0% | 99.9% | 99.0% | 99.2% | 0.16% |
| jpeg_50 | 484 | 99.2% | 97.9% to 99.7% | 0% | 99.8% | 99.0% | 99.2% | 0.16% |
| webp_80 | 484 | 99.8% | 98.8% to 100.0% | 0% | 99.9% | 99.0% | 99.8% | 0.16% |
| webp_50 | 484 | 99.0% | 97.6% to 99.6% | 0% | 99.6% | 98.0% | 98.8% | 0.16% |
| rotate_+5 | 484 | 45.0% | 40.7% to 49.5% | 0% | 89.3% | 66.0% | 45.0% | 0.16% |
| rotate_-5 | 484 | 40.3% | 36.0% to 44.7% | 0.41% | 88.1% | 65.0% | 40.3% | 0.16% |
| rotate_90 | 484 | 0% | 0% to 0.79% | 0% | 49.8% | 41.0% | 0% | 0.16% |
| blur_1 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 99.8% | 0.16% |
| blur_2 | 484 | 99.8% | 98.8% to 100.0% | 0% | 100.0% | 100.0% | 99.8% | 0.16% |
| screenshot | 484 | 0.21% | 0.036% to 1.2% | 0% | 52.7% | 44.0% | 0.21% | 0.16% |
| flip_h | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| rotate_+2 | 484 | 99.6% | 98.5% to 99.9% | 0% | 99.9% | 99.0% | 99.6% | 0.16% |
| rotate_-2 | 484 | 100.0% | 99.2% to 100.0% | 0% | 99.8% | 99.0% | 99.8% | 0.16% |
| rotate_+15 | 484 | 0% | 0% to 0.79% | 0.21% | 49.7% | 41.0% | 0% | 0.16% |
| rotate_180 | 484 | 0% | 0% to 0.79% | 0% | 50.0% | 41.0% | 0% | 0.16% |
| crop_75_corner | 484 | 0.83% | 0.32% to 2.1% | 0% | 62.4% | 46.0% | 0.83% | 0.16% |
| aspect_0.8 | 484 | 94.2% | 91.8% to 96.0% | 0% | 97.5% | 64.0% | 94.0% | 0.16% |
| pad_10 | 484 | 0% | 0% to 0.79% | 0% | 51.5% | 43.0% | 0% | 0.16% |
| overlay_caption | 484 | 99.8% | 98.8% to 100.0% | 0% | 99.9% | 99.0% | 99.8% | 0.16% |
| overlay_logo | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| brightness_+20 | 484 | 97.7% | 96.0% to 98.7% | 0% | 99.5% | 97.0% | 97.7% | 0.16% |
| contrast_0.7 | 484 | 99.8% | 98.8% to 100.0% | 0% | 99.9% | 99.0% | 99.8% | 0.16% |
| saturation_0 | 484 | 98.8% | 97.3% to 99.4% | 0% | 99.7% | 98.0% | 98.3% | 0.16% |
| gamma_1.5 | 484 | 99.6% | 98.5% to 99.9% | 0% | 99.9% | 99.0% | 99.6% | 0.16% |
| sharpen | 484 | 100.0% | 99.2% to 100.0% | 0% | 99.9% | 99.0% | 100.0% | 0.16% |
| noise_5 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| palette_256 | 484 | 100.0% | 99.2% to 100.0% | 0% | 99.9% | 99.0% | 100.0% | 0.16% |
| jpeg_75x2 | 484 | 99.4% | 98.2% to 99.8% | 0% | 99.9% | 99.0% | 99.2% | 0.16% |
| oracle_rotate_+5 | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| oracle_crop_50 | 484 | 1.4% | 0.7% to 3.0% | 0% | 83.3% | 76.0% | 1.4% | 0.16% |
| oracle_screenshot | 484 | 100.0% | 99.2% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
Variant C: embed PSNR mean 40.11 dB, p5 37.11 dB; skipped 62 of 500 (below 38 dB or too small).
| transform | n | decode rate | 95% interval | mis-decode | raw bits mean | raw bits p5 | chain TPR | FPR |
|---|---|---|---|---|---|---|---|---|
| identity | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.9% | 99.0% | 99.1% | 0.16% |
| resize_0.5 | 438 | 99.8% | 98.7% to 100.0% | 0% | 99.9% | 99.0% | 99.5% | 0.16% |
| resize_0.75 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.9% | 99.0% | 99.3% | 0.16% |
| resize_1.5 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.8% | 99.0% | 99.3% | 0.16% |
| crop_90 | 438 | 100.0% | 99.1% to 100.0% | 0% | 100.0% | 100.0% | 100.0% | 0.16% |
| crop_75 | 438 | 75.3% | 71.1% to 79.1% | 0% | 96.5% | 89.0% | 75.3% | 0.16% |
| crop_50 | 438 | 0% | 0% to 0.87% | 0% | 50.0% | 42.0% | 0% | 0.16% |
| jpeg_90 | 438 | 99.3% | 98.0% to 99.8% | 0% | 99.8% | 99.0% | 99.3% | 0.16% |
| jpeg_75 | 438 | 99.8% | 98.7% to 100.0% | 0% | 99.7% | 98.0% | 99.3% | 0.16% |
| jpeg_50 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.5% | 98.0% | 99.5% | 0.16% |
| webp_80 | 438 | 99.3% | 98.0% to 99.8% | 0% | 99.6% | 98.0% | 99.3% | 0.16% |
| webp_50 | 438 | 97.7% | 95.8% to 98.8% | 0% | 99.0% | 97.0% | 97.3% | 0.16% |
| rotate_+5 | 438 | 27.2% | 23.2% to 31.5% | 0% | 87.7% | 73.0% | 26.9% | 0.16% |
| rotate_-5 | 438 | 28.3% | 24.3% to 32.7% | 0% | 88.0% | 73.0% | 28.3% | 0.16% |
| rotate_90 | 438 | 0% | 0% to 0.87% | 0% | 49.8% | 42.0% | 0% | 0.16% |
| blur_1 | 438 | 99.8% | 98.7% to 100.0% | 0% | 99.9% | 99.0% | 99.8% | 0.16% |
| blur_2 | 438 | 99.3% | 98.0% to 99.8% | 0% | 99.8% | 99.0% | 99.3% | 0.16% |
| screenshot | 438 | 0% | 0% to 0.87% | 0% | 53.1% | 43.0% | 0% | 0.16% |
| flip_h | 438 | 99.1% | 97.7% to 99.6% | 0% | 99.8% | 99.0% | 99.1% | 0.16% |
| rotate_+2 | 438 | 97.9% | 96.1% to 98.9% | 0% | 99.2% | 97.0% | 97.9% | 0.16% |
| rotate_-2 | 438 | 99.1% | 97.7% to 99.6% | 0% | 99.1% | 97.0% | 99.1% | 0.16% |
| rotate_+15 | 438 | 0% | 0% to 0.87% | 0% | 50.2% | 43.0% | 0% | 0.16% |
| rotate_180 | 438 | 0% | 0% to 0.87% | 0.23% | 49.3% | 41.0% | 0% | 0.16% |
| crop_75_corner | 438 | 2.3% | 1.2% to 4.2% | 0% | 79.6% | 59.0% | 2.3% | 0.16% |
| aspect_0.8 | 438 | 94.1% | 91.4% to 95.9% | 0% | 97.7% | 67.0% | 93.6% | 0.16% |
| pad_10 | 438 | 0% | 0% to 0.87% | 0% | 52.1% | 43.0% | 0% | 0.16% |
| overlay_caption | 438 | 99.3% | 98.0% to 99.8% | 0% | 99.7% | 98.0% | 99.3% | 0.16% |
| overlay_logo | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.9% | 99.0% | 99.3% | 0.16% |
| brightness_+20 | 438 | 93.8% | 91.2% to 95.7% | 0% | 98.9% | 95.0% | 93.6% | 0.16% |
| contrast_0.7 | 438 | 99.1% | 97.7% to 99.6% | 0% | 99.7% | 98.0% | 98.9% | 0.16% |
| saturation_0 | 438 | 97.7% | 95.8% to 98.8% | 0% | 99.4% | 97.0% | 97.7% | 0.16% |
| gamma_1.5 | 438 | 97.0% | 95.0% to 98.3% | 0% | 99.3% | 97.0% | 97.0% | 0.16% |
| sharpen | 438 | 99.3% | 98.0% to 99.8% | 0% | 99.7% | 98.0% | 99.1% | 0.16% |
| noise_5 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.8% | 99.0% | 99.5% | 0.16% |
| palette_256 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.7% | 98.0% | 99.5% | 0.16% |
| jpeg_75x2 | 438 | 99.8% | 98.7% to 100.0% | 0% | 99.7% | 98.0% | 99.8% | 0.16% |
| oracle_rotate_+5 | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.8% | 99.0% | 99.5% | 0.16% |
| oracle_crop_50 | 438 | 0% | 0% to 0.87% | 0% | 82.0% | 75.0% | 0% | 0.16% |
| oracle_screenshot | 438 | 99.5% | 98.4% to 99.9% | 0% | 99.8% | 99.0% | 99.5% | 0.16% |
Unmarked originals (identity, n=500): detect's Q/B/C chain decoded a payload from 2 of 500 (0.4% (0.11 to 1.4%)); per decoder: Q 0, B 2, C 0.
Reading the table:
- Compression, resizing, mild crops, tone edits, overlays and blur: survived. Variant Q recovers the exact payload from at least 93.9 percent of photos after JPEG q90 to q50 (and q75 twice), WebP q80 and q50, resizing to 0.5x, 0.75x and 1.5x, a 90 percent crop, blur up to radius 2, noise, sharpening, a 256-colour palette, a caption bar or a logo, brightness, contrast, saturation and gamma changes, and a 0.8 aspect squash (WebP q50 and the squash are the lowest at 93.9 percent). B and C hold up about as well on compression, at a lower embed PSNR.
- Horizontal flip survives (Q 100.0 percent). Vertical flip is not a row; a development probe found it does not decode.
crop_75is partial andcrop_50, the corner crop and a 10 percent border are lost (Q 31.8, 0, 0 and 0 percent). The decoder resizes the whole frame to its fixed input size, so a crop or a border presents the embedded pattern at another scale and position, and production decode searches neither.oracle_crop_50(the crop pasted back at its true position) is also 0 percent, although its raw bits rise to 82.1 percent: at 50 percent the mark needs a new embedding (tiling), not only a search.- Rotation: 2 degrees mostly survives, 5 degrees and quarter turns are lost. Q holds 90.1 and 93.1 percent at
+2 and -2 degrees, 1.2 and 2.6 percent at 5 degrees, 0 percent at 15, 90 and 180 degrees. Production decode runs
without TrustMark's rotation search (
decode_variantmirrorsdecodewithoutROTATION).oracle_rotate_+5(rotated back by the true angle) is 100.0 percent. B (45.0 and 40.3 percent) and C (27.2 and 28.3 percent) tolerate 5 degrees better than Q. screenshotis lost (Q 0 percent, B 0.21 percent, C 0 percent) andoracle_screenshot(the photo's box cut out of the capture) recovers 100.0 percent. The canvas dilutes the mark: a 4:3 COCO photo fitted into 90 percent of a 1920x1080 screen covers only part of the decoder input and the rest is white canvas, which the decoder resizes as a whole. Canvas detection would recover it.- Wrong payloads are rare. The variant's own decoder returned a different payload for at most 0.41 percent of
images (B,
rotate_-5); on the lost transforms the raw bits sit at chance and the decoder mostly reports nothing. Detect's chain occasionally takes an earlier variant's wrong payload on a B- or C-marked image (chain_mis_decodedin the JSON), which is why the chain TPR can sit slightly below the decode rate. Production marks with Q and falls back to B or C only below the PSNR floor. - Unmarked originals. Detect's chain decoded a payload from 2 of the 500 unmarked originals (0.4 percent, both from B). The published operating point stays the 5,000-photo phantom rate (0.16 percent).
- Embed skips. Each forced variant fell below the 38 dB floor on some photos (Q 7, B 16, C 62 of 500), and those are excluded from that variant's rates. Production tries Q first and then the fallbacks, so an image goes unmarked only when every variant falls below the floor.
- Raw bit accuracy. After JPEG q75 the raw decoder bits of Q agree with the codeword in 99.7 percent of positions
on average (5th percentile 99.0 percent). Detect reports the same quantity as the informational
bit_accuracy; it is not a confidence, because a phantom decode also lies within the BCH radius (Grading). - img2img (
--img2img, Stable Diffusion 1.5 at strengths 0.2 and 0.4) has not been run yet.
Where it breaks (variant Q)
Each sweep's harshest parameter that still holds at least 90 and 50 percent, the oracle curve at the same points,
how far the failing images missed at the first failing point (raw bit errors: decoded at most 5, near at most 10, mid
at most 25, far above), and a heuristic hint. The sweep tables, and the same analysis for B and C, are in the JSON
(sweeps, where_it_breaks) and summarised in Robustness baseline.
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint | target |
|---|---|---|---|---|---|---|
| rotation_ccw | 2 (90.8%) | 2 (90.8%) | >= 90% up to 15; 100.0% at 3 | at 3: decoded 42, near 22, mid 31, far 3 | heuristic: resync/search candidate (22 near, 31 mid, 3 far of 56 failing; oracle 100.0%) (4 schema misses: a fixed-schema decode fixes them) | 5 >= 90%: 1.0%, missed |
| rotation_cw | -2 (93.9%) | -2 (93.9%) | >= 90% up to -15; 100.0% at -3 | at -3: decoded 47, near 22, mid 24, far 5 | heuristic: resync/search candidate (22 near, 24 mid, 5 far of 51 failing; oracle 100.0%) | -5 >= 90%: 1.0%, missed |
| quarter_turn | none (first below at 90) | none (first below at 90) | >= 90% up to 270; 100.0% at 90 | at 90: far 98 | heuristic: resync/search candidate (0 near, 0 mid, 98 far of 98 failing; oracle 100.0%) | n/a |
| crop_centre | 80 (100.0%) | 80 (100.0%) | >= 90% up to 70; 100.0% at 75 | at 75: decoded 35, near 13, mid 35, far 15 | heuristic: resync/search candidate (13 near, 35 mid, 15 far of 63 failing; oracle 100.0%) | 50 >= 80%: 0%, missed |
| crop_corner | 90 (100.0%) | 80 (85.7%) | >= 90% up to 80; 99.0% at 80 | at 80: decoded 84, near 7, mid 3, far 4 | heuristic: soft voting or stronger ECC candidate (7 near, 3 mid, 4 far of 14 failing; oracle 99.0%) (2 schema misses: a fixed-schema decode fixes them) | n/a |
| scale_down | 0.25 (98.0%, whole sweep) | 0.25 (98.0%, whole sweep) | n/a | n/a | n/a | n/a |
| scale_up | 3 (100.0%, whole sweep) | 3 (100.0%, whole sweep) | n/a | n/a | n/a | n/a |
| aspect | 0.8 (94.9%) | 0.7 (62.2%, whole sweep) | n/a | at 0.7: decoded 61, far 37 | heuristic: embedding change candidate (tiling, redundancy, sync) (0 near, 0 mid, 37 far of 37 failing; oracle absent) | n/a |
| screenshot_fill | none (first below at 90) | none (first below at 90) | >= 90% up to 30; 100.0% at 90 | at 90: far 98 | heuristic: resync/search candidate (0 near, 0 mid, 98 far of 98 failing; oracle 100.0%) | 90 >= 80%: 0%, missed |
| jpeg | 35 (92.9%) | 25 (77.6%) | n/a | at 25: decoded 76, near 14, mid 7, far 1 | heuristic: soft voting or stronger ECC candidate (14 near, 7 mid, 1 far of 22 failing; oracle absent) (2 schema misses: a fixed-schema decode fixes them) | n/a |
| webp | 70 (94.9%) | 30 (79.6%, whole sweep) | n/a | at 50: decoded 88, near 4, mid 5, far 1 | heuristic: no single candidate (mixed margins) (4 near, 5 mid, 1 far of 10 failing; oracle absent) (1 schema miss: a fixed-schema decode fixes it) | n/a |
| blur | 3 (92.9%) | 4 (76.5%, whole sweep) | n/a | at 4: decoded 75, near 11, mid 6, far 6 | heuristic: no single candidate (mixed margins) (11 near, 6 mid, 6 far of 23 failing; oracle absent) (3 schema misses: a fixed-schema decode fixes them) | n/a |
| noise | 15 (94.9%, whole sweep) | 15 (94.9%, whole sweep) | n/a | n/a | n/a | n/a |
| brightness_down | 0.6 (100.0%, whole sweep) | 0.6 (100.0%, whole sweep) | n/a | n/a | n/a | n/a |
| brightness_up | 1.2 (98.0%) | 1.4 (87.8%, whole sweep) | n/a | at 1.4: decoded 86, near 10, mid 2 | heuristic: soft voting or stronger ECC candidate (10 near, 2 mid, 0 far of 12 failing; oracle absent) (1 schema miss: a fixed-schema decode fixes it) | n/a |
| contrast_down | 0.6 (100.0%, whole sweep) | 0.6 (100.0%, whole sweep) | n/a | n/a | n/a | n/a |
| contrast_up | 1.2 (95.9%, whole sweep) | 1.2 (95.9%, whole sweep) | n/a | n/a | n/a | n/a |
Hints are a heuristic from the margin mix and the oracle rate at the first failing point, not a measurement: mostly near (>= 50% of failing items) suggests soft voting or a stronger ECC; mostly mid or far with an oracle >= 80% suggests a resync search; mostly far with a low or no oracle suggests an embedding change.
Schema-bit misses (variant Q; not decoded with at most 5 raw bit errors because bit 98 or 99, which pick TrustMark's schema and are outside the BCH, flipped; counted as near above): rotation_ccw at 2 (3), rotation_ccw at 3 (4), oracle crop_centre at 70 (2), oracle crop_centre at 65 (1), crop_corner at 80 (2), oracle crop_corner at 80 (1), oracle crop_corner at 70 (1), oracle screenshot_fill at 30 (1), jpeg at 95 (1), jpeg at 75 (1), jpeg at 65 (2), jpeg at 55 (1), jpeg at 45 (1), jpeg at 25 (2), jpeg at 15 (1), webp at 70 (1), webp at 50 (1), webp at 30 (2), blur at 4 (3), noise at 15 (1), brightness_up at 1.2 (1), brightness_up at 1.4 (1).
Oracle below the attack (variant Q; not a ceiling there, so the oracle ceiling column uses the attack's rate; the grey canvas blanks what a plain crop keeps at a new scale): none.
Targets on the main rows (variant Q):
| row | n | variant decode rate | chain TPR |
|---|---|---|---|
| crop_50 | 493 | crop_50 >= 80%: 0%, missed | crop_50 >= 80%: 0%, missed |
| rotate_+5 | 493 | rotate_+5 >= 90%: 1.2%, missed | rotate_+5 >= 90%: 1.2%, missed |
| rotate_-5 | 493 | rotate_-5 >= 90%: 2.6%, missed | rotate_-5 >= 90%: 2.6%, missed |
| screenshot | 493 | screenshot >= 80%: 0%, missed | screenshot >= 80%: 0%, missed |
Chain FPR target: chain FPR <= 0.5%: 0.16%, met (packaged phantom chain rate, n=5000); this bench's unmarked originals: 2 of 500 decoded by the chain, chain FPR <= 0.5%: 0.4%, met.
Changes from the published CPU run
The same seed and images on the GPU. Decisions differ only where the GPU embed's few-LSB differences meet a lossy codec or a borderline image; every change is inside the earlier Wilson interval.
| variant | row | column | CPU | GPU |
|---|---|---|---|---|
| Q | webp_80 | decode rate and chain TPR | 98.4% | 98.2% |
| Q | webp_50 | decode rate and chain TPR | 94.3% | 93.9% |
| B | crop_75 | decode rate and chain TPR | 68.0% | 68.2% |
| B | webp_80 | decode rate and chain TPR | 99.6% | 99.8% |
| B | jpeg_90 | chain TPR | 100.0% | 99.8% |
| C | webp_50 | chain TPR | 97.5% | 97.3% |
Raw-bit columns moved by at most one point on a few other rows (for example Q crop_50 mean 50.7 to 50.6 percent).
Audio robustness (AudioSeal, LibriSpeech and Goldberg)
What the AudioSeal watermark survives. Each clip is marked with the 16-bit issuer payload (issuer 4021) in two
modes, then goes through each transform and is decoded by AudioSealWatermarker.decode (the batch detector, then the
streaming one), as detect runs it. What the failures mean and how much a decode-side fix could recover is in
Robustness baseline.
batch: the whole clip at once, asmarker.mark_audio,/v1/mark/audio, ingest and the buffered speech proxy mark it.streaming: the streaming embedder (watermark.audio.stream_kind: block, the only kind), each 20 ms block on its own, as the streaming speech proxy,laidline-pipecatandlaidline-livekitmark it.streaming_ctx: the experimental context kind (the batch card on each 20 ms block with 1000 ms of left context). It was measured and removed from the product: 22.4 % identity. Only the bench keeps it (laidline_bench.audio_robustness.ContextStream); its rows are kept below as a historical measurement.
# with the cu130 torch wheels; drop --device cuda to run on the CPU laidline bench audio --corpus DIR --n 500 --workers 8 --device cuda
Point --corpus at a directory of your own unmarked clips; the published run sampled LibriSpeech and the Goldberg
Variations (below). --music-bed FILE supplies the music that music_bed_10db mixes in.
Corpora: speech is 500 clips sampled (seed 0) from LibriSpeech test-clean (CC BY 4.0,
https://www.openslr.org/resources/12/test-clean.tar.gz); music is 30 seeded 30 s excerpts of the Open Goldberg
Variations (Kimiko Ishizaka, CC0, https://archive.org/metadata/OpenGoldbergVariations/files). The music bed of
music_bed_10db is a seeded excerpt of a Goldberg track outside the scored 30. Every clip is also decoded unmarked,
which gives the false-positive rate. Measured on the RTX 5080 (torch 2.14.0+cu130, TF32 off,
cuDNN deterministic, 8 workers): machine.run_seconds 3012.7; 0 unreadable clips. The first published run (September
27, 2026, CPU) had the rows up to lowpass_4k plus the neural codecs; the rows from trim_10ms to compressor and
the oracle are new. The result ships as laidline/compliance/data/audio_robustness.json (with the sweeps and "Where
it breaks"), and the marking-method text of the evidence export quotes speech in batch mode from it. The neural codecs
(encodec_6, mimi) run only in the full published run; their weights are downloaded for the bench and are
never used by Laidline itself (Third-party licences). The Twilio row re-decodes saved phone-call recordings (see "Twilio PSTN
loopback" below).
Transforms (transform_definitions in the JSON):
| transform | definition |
|---|---|
| identity | WAV (PCM s16le) round trip |
| resample_8k | resample to 8 kHz and back (libswresample) |
| resample_44k1 | resample to 44.1 kHz and back (libswresample) |
| resample_48k | resample to 48 kHz and back (libswresample) |
| opus_12 | Opus 12 kbps, application voip (libopus, Ogg, 48 kHz) |
| opus_24 | Opus 24 kbps, application voip (libopus, Ogg, 48 kHz) |
| opus_48 | Opus 48 kbps, application voip (libopus, Ogg, 48 kHz) |
| g711_mulaw | G.711 mu-law, 8 kHz mono (a PSTN leg), back to the source rate |
| g711_alaw | G.711 A-law, 8 kHz mono (a PSTN leg), back to the source rate |
| mp3_64 | MP3 64 kbps (libmp3lame) |
| mp3_128 | MP3 128 kbps (libmp3lame) |
| aac_64 | AAC-LC 64 kbps in MP4 (FFmpeg aac) |
| aac_128 | AAC-LC 128 kbps in MP4 (FFmpeg aac) |
| tempo_0.9 | tempo x0.9 without pitch change (libavfilter atempo), 11 percent longer |
| tempo_1.1 | tempo x1.1 without pitch change (libavfilter atempo), 9 percent shorter |
| noise_20db | white Gaussian noise at 20 dB SNR (seed 0) |
| lowpass_4k | low-pass at 4 kHz (libavfilter lowpass, two-pole) |
| trim_10ms | the first 10 ms cut (half of one 20 ms AudioSeal bit block, so the bit grid is out of phase) |
| trim_1s | the first 1 s cut |
| crop_middle_50 | the middle half kept (the first and last quarters cut) |
| splice_half | the marked first half joined to the same clip's unmarked second half (a partially marked edit) |
| music_bed_10db | a music bed mixed in 10 dB below the clip's RMS: the file named in the result's music_bed (the repo runner's is a seeded 30 s Open Goldberg Variations excerpt, CC0, from a track outside the scored music sample), mono, resampled to the clip's rate and looped to its length, added to every channel, clipped to [-1, 1] |
| pitch_+2st | pitch up 2 semitones at the same length (libavfilter asetrate, aresample, atempo) |
| speed_1.1 | speed x1.1 by resampling, so the pitch rises with it (about 1.65 semitones), 9 percent shorter |
| volume_-12db | gain -12 dB (every sample x 0.251) |
| volume_+6db_clip | gain +6 dB (every sample x 1.995), clipped to [-1, 1] |
| reverb | room-like echo: reflections at 23, 37, 53 and 71 ms with decays 0.5, 0.4, 0.3 and 0.25 (libavfilter aecho, gains 0.8 in, 0.9 out), the tail cut to the input's length, clipped to [-1, 1] |
| bandpass_phone | telephone band 300 to 3400 Hz (libavfilter highpass then lowpass, two-pole each) |
| compressor | compressor: threshold -24 dB, ratio 4:1, attack 5 ms, release 100 ms, makeup +6 dB (libavfilter acompressor), clipped to [-1, 1] |
| oracle_trim_10ms | Oracle (not a product number): trim_10ms, then a further 10 ms cut so the total is one whole 20 ms block (320 samples at 16 kHz) and the bit grid is back in phase; bounds a decode-side offset search |
| encodec_6 | EnCodec 24 kHz at 6 kbps (facebook/encodec_24khz, transformers, cuda), mono |
| mimi | Mimi, all 32 quantizers, about 4.4 kbps (kyutai/mimi, transformers, cuda), mono |
| twilio_loopback | Twilio PSTN loopback recorded, re-decoded from the saved recordings: Twilio number-to-number call recorded at the callee (G.711, 8 kHz; carrier routing unconfirmed, possibly on-net) |
AudioSeal 0.2.0 robustness (16-bit issuer payload 4021, modes batch/streaming): corpora speech n=500, music n=30, seed 0, measured.
Operating point: detection threshold 0.39, at which 0.75% of n=530 unmarked clips are flagged (95% interval 0.29 to 1.9%; target below 1.0%); at 0.5: 0.38%. This is the earlier false-positive basis for audio; the 2026-10 null campaign measured 5,000 unmarked units in each of four strata, with results above and below their lines (Audio null campaign).
speech, batch
n=500, skipped 0; embed SNR mean 26.6 dB, p5 22.2 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| resample_8k | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| resample_44k1 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| resample_48k | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| opus_12 | 99.6% (98.6 to 99.9%) | 96.8% | 96.2% | 99.8% / 100.0% | 0.78 |
| opus_24 | 100.0% (99.2 to 100.0%) | 100.0% | 99.8% | 100.0% / 100.0% | 0.99 |
| opus_48 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| g711_mulaw | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| g711_alaw | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| mp3_64 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| mp3_128 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| aac_64 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| aac_128 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| tempo_0.9 | 1.4% (0.68 to 2.9%) | 0% | 0.2% | 97.9% / 93.8% | 0.17 |
| tempo_1.1 | 2.0% (1.1 to 3.6%) | 0% | 0.6% | 98.6% / 93.8% | 0.18 |
| noise_20db | 76.6% (72.7 to 80.1%) | 48.2% | 57.6% | 97.3% / 87.5% | 0.50 |
| lowpass_4k | 100.0% (99.2 to 100.0%) | 100.0% | 98.8% | 99.9% / 100.0% | 1.00 |
| trim_10ms | 100.0% (99.2 to 100.0%) | 99.0% | 0% | 62.6% / 50.0% | 0.94 |
| trim_1s | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| crop_middle_50 | 54.4% (50.0 to 58.7%) | 45.2% | 5.8% | 63.1% / 31.2% | 0.44 |
| splice_half | 100.0% (99.2 to 100.0%) | 88.2% | 99.6% | 99.8% / 100.0% | 0.51 |
| music_bed_10db | 41.8% (37.6 to 46.2%) | 8.6% | 11.6% | 90.8% / 75.0% | 0.37 |
| pitch_+2st | 0.4% (0.11 to 1.4%) | 0% | 0% | 70.9% / 43.8% | 0.01 |
| speed_1.1 | 1.2% (0.55 to 2.6%) | 0.2% | 0% | 59.4% / 43.8% | 0.01 |
| volume_-12db | 100.0% (99.2 to 100.0%) | 100.0% | 99.8% | 100.0% / 100.0% | 0.94 |
| volume_+6db_clip | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| reverb | 100.0% (99.2 to 100.0%) | 99.8% | 5.8% | 91.9% / 87.5% | 0.80 |
| bandpass_phone | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| compressor | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| oracle_trim_10ms | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| encodec_6 | 70.8% (66.7 to 74.6%) | 48.4% | 0% | 58.4% / 37.5% | 0.50 |
| mimi | 1.0% (0.43 to 2.3%) | 0.8% | 0% | 71.6% / 43.8% | 0.02 |
| twilio_loopback | 99.0% (94.6 to 99.8%) | 96.0% | 98.0% | 99.9% / 100.0% | 0.92 |
speech, streaming
n=500, skipped 0; embed SNR mean 17.4 dB, p5 8.7 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| resample_8k | 0% (0 to 0.76%) | 0% | 0% | 58.3% / 31.2% | 0.03 |
| resample_44k1 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| resample_48k | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| opus_12 | 100.0% (99.2 to 100.0%) | 98.8% | 70.6% | 97.9% / 93.8% | 0.80 |
| opus_24 | 100.0% (99.2 to 100.0%) | 100.0% | 99.6% | 100.0% / 100.0% | 0.99 |
| opus_48 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| g711_mulaw | 0% (0 to 0.76%) | 0% | 0% | 59.6% / 31.2% | 0.03 |
| g711_alaw | 0% (0 to 0.76%) | 0% | 0% | 60.4% / 31.2% | 0.03 |
| mp3_64 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| mp3_128 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| aac_64 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| aac_128 | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.98 |
| tempo_0.9 | 99.8% (98.9 to 100.0%) | 98.4% | 90.4% | 99.3% / 93.8% | 0.85 |
| tempo_1.1 | 100.0% (99.2 to 100.0%) | 98.2% | 94.0% | 99.0% / 93.8% | 0.83 |
| noise_20db | 100.0% (99.2 to 100.0%) | 99.8% | 99.2% | 100.0% / 100.0% | 0.89 |
| lowpass_4k | 100.0% (99.2 to 100.0%) | 100.0% | 0% | 69.4% / 50.0% | 0.97 |
| trim_10ms | 48.4% (44.0 to 52.8%) | 29.4% | 0% | 51.3% / 43.8% | 0.38 |
| trim_1s | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| crop_middle_50 | 71.4% (67.3 to 75.2%) | 67.6% | 4.4% | 51.6% / 31.2% | 0.63 |
| splice_half | 100.0% (99.2 to 100.0%) | 2.6% | 99.2% | 100.0% / 100.0% | 0.49 |
| music_bed_10db | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| pitch_+2st | 100.0% (99.2 to 100.0%) | 100.0% | 0% | 51.8% / 43.8% | 0.93 |
| speed_1.1 | 100.0% (99.2 to 100.0%) | 100.0% | 0% | 60.7% / 50.0% | 0.97 |
| volume_-12db | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| volume_+6db_clip | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.98 |
| reverb | 100.0% (99.2 to 100.0%) | 100.0% | 31.4% | 91.9% / 81.2% | 0.99 |
| bandpass_phone | 100.0% (99.2 to 100.0%) | 100.0% | 0% | 60.3% / 50.0% | 0.92 |
| compressor | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.98 |
| oracle_trim_10ms | 100.0% (99.2 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| encodec_6 | 100.0% (99.2 to 100.0%) | 100.0% | 0% | 55.5% / 50.0% | 0.96 |
| mimi | 0% (0 to 0.76%) | 0% | 0% | 72.4% / 50.0% | 0.00 |
music, batch
n=30, skipped 0; embed SNR mean 26.0 dB, p5 24.9 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| resample_8k | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| resample_44k1 | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| resample_48k | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| opus_12 | 100.0% (88.6 to 100.0%) | 100.0% | 33.3% | 94.2% / 87.5% | 0.83 |
| opus_24 | 100.0% (88.6 to 100.0%) | 100.0% | 26.7% | 92.5% / 87.5% | 0.99 |
| opus_48 | 100.0% (88.6 to 100.0%) | 100.0% | 30.0% | 94.8% / 87.5% | 1.00 |
| g711_mulaw | 100.0% (88.6 to 100.0%) | 100.0% | 80.0% | 98.8% / 93.8% | 1.00 |
| g711_alaw | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| mp3_64 | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 98.8% / 93.8% | 1.00 |
| mp3_128 | 100.0% (88.6 to 100.0%) | 100.0% | 83.3% | 99.0% / 93.8% | 1.00 |
| aac_64 | 100.0% (88.6 to 100.0%) | 100.0% | 50.0% | 96.2% / 87.5% | 1.00 |
| aac_128 | 100.0% (88.6 to 100.0%) | 100.0% | 70.0% | 97.9% / 93.8% | 1.00 |
| tempo_0.9 | 0% (0 to 11.4%) | 0% | 0% | 81.5% / 56.2% | 0.18 |
| tempo_1.1 | 0% (0 to 11.4%) | 0% | 0% | 82.7% / 56.2% | 0.17 |
| noise_20db | 100.0% (88.6 to 100.0%) | 100.0% | 76.7% | 98.1% / 93.8% | 0.99 |
| lowpass_4k | 100.0% (88.6 to 100.0%) | 100.0% | 73.3% | 97.9% / 87.5% | 1.00 |
| trim_10ms | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 48.1% / 31.2% | 0.99 |
| trim_1s | 100.0% (88.6 to 100.0%) | 100.0% | 20.0% | 92.3% / 81.2% | 1.00 |
| crop_middle_50 | 100.0% (88.6 to 100.0%) | 100.0% | 23.3% | 92.3% / 81.2% | 1.00 |
| splice_half | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.51 |
| music_bed_10db | 100.0% (88.6 to 100.0%) | 100.0% | 6.7% | 88.3% / 81.2% | 0.83 |
| pitch_+2st | 0% (0 to 11.4%) | 0% | 0% | 71.7% / 50.0% | 0.01 |
| speed_1.1 | 0% (0 to 11.4%) | 0% | 0% | 60.0% / 43.8% | 0.01 |
| volume_-12db | 100.0% (88.6 to 100.0%) | 100.0% | 66.7% | 97.5% / 87.5% | 0.98 |
| volume_+6db_clip | 100.0% (88.6 to 100.0%) | 100.0% | 76.7% | 98.3% / 93.8% | 1.00 |
| reverb | 100.0% (88.6 to 100.0%) | 100.0% | 26.7% | 91.5% / 81.2% | 0.92 |
| bandpass_phone | 100.0% (88.6 to 100.0%) | 100.0% | 20.0% | 95.0% / 93.8% | 1.00 |
| compressor | 100.0% (88.6 to 100.0%) | 100.0% | 80.0% | 98.5% / 93.8% | 1.00 |
| oracle_trim_10ms | 100.0% (88.6 to 100.0%) | 100.0% | 50.0% | 96.0% / 87.5% | 1.00 |
| encodec_6 | 76.7% (59.1 to 88.2%) | 73.3% | 0% | 44.2% / 31.2% | 0.63 |
| mimi | 6.7% (1.8 to 21.3%) | 0% | 0% | 44.4% / 25.0% | 0.21 |
music, streaming
n=30, skipped 0; embed SNR mean 34.3 dB, p5 29.9 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| resample_8k | 3.3% (0.59 to 16.7%) | 0% | 0% | 56.5% / 43.8% | 0.04 |
| resample_44k1 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| resample_48k | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| opus_12 | 100.0% (88.6 to 100.0%) | 100.0% | 80.0% | 98.8% / 93.8% | 0.89 |
| opus_24 | 100.0% (88.6 to 100.0%) | 100.0% | 3.3% | 85.2% / 75.0% | 0.90 |
| opus_48 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 0.99 |
| g711_mulaw | 0% (0 to 11.4%) | 0% | 0% | 68.5% / 50.0% | 0.01 |
| g711_alaw | 0% (0 to 11.4%) | 0% | 0% | 67.1% / 50.0% | 0.00 |
| mp3_64 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| mp3_128 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| aac_64 | 100.0% (88.6 to 100.0%) | 100.0% | 80.0% | 98.8% / 93.8% | 0.95 |
| aac_128 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| tempo_0.9 | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 82.1% / 75.0% | 0.99 |
| tempo_1.1 | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 87.9% / 75.0% | 0.99 |
| noise_20db | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| lowpass_4k | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 81.5% / 75.0% | 1.00 |
| trim_10ms | 70.0% (52.1 to 83.3%) | 30.0% | 0% | 57.9% / 50.0% | 0.45 |
| trim_1s | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| crop_middle_50 | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| splice_half | 100.0% (88.6 to 100.0%) | 13.3% | 100.0% | 100.0% / 100.0% | 0.50 |
| music_bed_10db | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| pitch_+2st | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 50.0% / 37.5% | 0.99 |
| speed_1.1 | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 63.7% / 62.5% | 1.00 |
| volume_-12db | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| volume_+6db_clip | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| reverb | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 85.2% / 81.2% | 1.00 |
| bandpass_phone | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 77.7% / 68.8% | 1.00 |
| compressor | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| oracle_trim_10ms | 100.0% (88.6 to 100.0%) | 100.0% | 100.0% | 100.0% / 100.0% | 1.00 |
| encodec_6 | 100.0% (88.6 to 100.0%) | 100.0% | 0% | 50.2% / 43.8% | 0.93 |
| mimi | 0% (0 to 11.4%) | 0% | 0% | 44.6% / 31.2% | 0.12 |
plugin gate: PASS (streaming (block): opus_24 = 100.0%, opus_48 = 100.0%, g711_mulaw = 0%, PASS; required 90% / 95%)
Columns: detection at 0.39 is the share of marked clips detected at the operating point, with its Wilson 95 percent
interval; at 0.5 is the same at detect_threshold: 0.5, the configuration default; issuer exact counts clips
detected at the operating point with all 16 issuer bits right, which is what detect needs to name the issuer;
bits is the agreement of the 16 decoded bits with the payload (mean and 5th percentile; about 50 percent is chance);
prob is the detector's score. oracle_trim_10ms is not a product number: it cuts a further 10 ms so the trim is one
whole 20 ms block, which estimates what a decode-side offset search could recover.
Reading the tables:
- Batch mode survives the codecs, the phone band, level changes and a compressor. On speech the batch mark is detected in 100 percent of clips after Opus 24 and 48 kbps, G.711 mu-law and A-law, the 8 kHz round trip, MP3 64 and 128 kbps, AAC 64 and 128 kbps, a 300 to 3400 Hz band-pass, -12 and +6 dB and the compressor, and in 99.6 percent after Opus 12 kbps (issuer exact 96.2 percent).
- Batch mode does not survive a tempo, pitch or speed change or the neural codecs. Tempo x0.9 and x1.1 leave 1.4 and 2.0 percent, pitch +2 semitones 0.4 percent, speed x1.1 1.2 percent; EnCodec 6 kbps is detected in 70.8 percent with no clip naming the issuer (0 percent issuer exact); Mimi leaves 1.0 percent. White noise at 20 dB SNR leaves 76.6 percent, and a music bed 10 dB below the speech 41.8 percent (issuer exact 11.6 percent).
- Trimming shifts the bit grid. AudioSeal reads its 16 bits against a 20 ms grid anchored at the first sample it
sees. Cutting the first 10 ms keeps detection at 100 percent but no clip names the issuer (
trim_10ms, 0 percent issuer exact); cutting a whole second keeps both (trim_1s, 100 percent), andoracle_trim_10ms(a whole 20 ms block cut) is back at 100 percent. Keeping the middle half, cut off the grid, leaves 54.4 percent detected and 5.8 percent exact. The start-offset sweep below shows the issuer lost at every offset that is not a multiple of 20 ms. The same shift explainsaacoutput: raw ADTS AAC carries 1024 frames of encoder priming, so Laidline's own.aacoutput is detected but its issuer bits do not read back, and it carries the warningwatermark_payload_unverified:aac. AAC in MP4 (m4a, theaac_64andaac_128rows) is not affected. The hardened decode's phase search reads such an offset; only the fast read does not. - Partial marks. A clip whose first half is marked and second half unmarked (
splice_half) is detected in 100 percent at 0.39 but 88.2 percent at 0.5, because the score is the share of marked samples over the whole clip. - Reverb keeps detection and loses the issuer (100 percent detected, 5.8 percent exact).
- The default streaming mark (
block) does not survive a simulated phone leg. It is detected in at least 99.8 percent after Opus 24 and 48 kbps, MP3 and AAC, and after tempo, pitch and speed changes and a music bed, but in 0 percent after G.711 mu-law, G.711 A-law and the 8 kHz round trip, where the batch mark stays at 100 percent: the streaming card's residual sits mostly above 4 kHz, which an 8 kHz leg removes. EnCodec, a 4 kHz low-pass, the phone band, pitch and speed keep detection at 100 percent but no clip names the issuer. A 10 ms trim leaves 48.4 percent detected. - The streaming mark is louder. Embed SNR is mean 17.4 dB, 5th percentile 8.7 dB on speech, against 26.6 and 22.2 dB for the batch mark. Listen to your own voices before streaming them.
- The
contextstream kind failed on real audio and was removed. It survived G.711 on synthetic tones in the unit tests, but on LibriSpeech it is detected in 22.4 percent of clips even without any transform (6.7 percent on music).stream_kindaccepts onlyblock. Its rows are in the historical tables below. - Music. Batch detection is 100 percent on the Goldberg excerpts for every transform except tempo, pitch and speed (0 percent), EnCodec (76.7 percent) and Mimi (6.7 percent), but the issuer is exact on fewer clips than on speech (83.3 percent with no transform, 26.7 to 33.3 percent after Opus). n=30, so the intervals are wide.
- No loudness normalisation. AudioSeal's normaliser is off in Laidline's checkpoints: -12 dB keeps 100 percent, and the volume sweep below loses the issuer from -20 dB and detection at -30 dB.
Plugin gate. The Pipecat and LiveKit plugins were conditional on the streaming mark reaching at least 90
percent after Opus 24 kbps and 95 percent after Opus 48 kbps on speech, at the operating point. The block kind passed
(100 and 100 percent); the context kind failed (10.6 and 15.4 percent). G.711 is reported and does
not gate. Both plugins ship with the block kind (Pipecat, LiveKit Agents).
Detection threshold: 0.5 in the configuration, 0.39 in the published numbers. watermark.audio.detect_threshold
defaults to 0.5, and detect reports a watermark only when AudioSeal's score reaches it. The bench picks the operating
point as the lowest threshold (on a 0.01 grid) whose false-positive rate on the 530 unmarked clips (speech and music
pooled) is below 1 percent: 0.39, at which 4 of 500 speech clips and 0 of 30 music clips were flagged (0.75 percent,
95 percent interval 0.29 to 1.9 percent). At 0.5 the rate is 0.38 percent. The marking-method text quotes detection at the
deployment's detect_threshold when the bench counted at it (0.39, or 0.5 from the at 0.5 column; another value is
quoted at 0.39 and said to be unmeasured). Set detect_threshold: 0.39 for more detection after heavy degradation;
keep 0.5 for the lower false-positive rate and read the at 0.5 column (for example Opus 12 kbps drops from 99.6 to
96.8 percent in batch mode, and EnCodec from 70.8 to 48.4 percent). A detected watermark alone never gives
marked_by_this_system (Grading).
Where it breaks
Sweeps on the first 100 speech clips and all 30 music clips. A point holds a clip when it is detected at 0.39 with all 16 bits right; margins are the failing clips' bit errors (near 1 to 2, mid 3 to 4, far 5 or more; unknown: not detected with the bits right). The start-offset oracle is each offset rounded up to a whole 20 ms block. The hint is a heuristic, not a measurement.
Where it breaks: speech, batch
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| offset_ms | 20 (100.0%; first below at 1, after 0) | 20 (100.0%; first below at 1, after 0) | >= 90% up to 20; 100.0% at 1 | at 1: near 59, mid 41 | heuristic: soft voting or stronger ECC candidate (59 near, 41 mid, 0 far of 100 failing; oracle 100.0%) |
| tempo slower | none (first below at 0.99) | none (first below at 0.99) | n/a | at 0.99: decoded 17, near 2, far 1, unknown 80 | heuristic: soft voting or stronger ECC candidate (2 near, 0 mid, 1 far of 3 failing; oracle absent) |
| tempo faster | none (first below at 1.01) | none (first below at 1.01) | n/a | at 1.01: decoded 18, near 5, far 1, unknown 76 | heuristic: soft voting or stronger ECC candidate (5 near, 0 mid, 1 far of 6 failing; oracle absent) |
| pitch_st down | none (first below at -0.5) | none (first below at -0.5) | n/a | at -0.5: near 9, mid 46, far 45 | heuristic: no single candidate (mixed margins) (9 near, 46 mid, 45 far of 100 failing; oracle absent) |
| pitch_st up | none (first below at 0.5) | none (first below at 0.5) | n/a | at 0.5: near 4, mid 29, far 67 | heuristic: embedding change candidate (tiling, redundancy, sync) (4 near, 29 mid, 67 far of 100 failing; oracle absent) |
| speed slower | none (first below at 0.98) | none (first below at 0.98) | n/a | at 0.98: near 12, mid 48, far 40 | heuristic: no single candidate (mixed margins) (12 near, 48 mid, 40 far of 100 failing; oracle absent) |
| speed faster | none (first below at 1.02) | none (first below at 1.02) | n/a | at 1.02: near 12, mid 50, far 38 | heuristic: no single candidate (mixed margins) (12 near, 50 mid, 38 far of 100 failing; oracle absent) |
| noise_snr_db | 30 (99.0%) | 25 (89.0%) | n/a | at 25: decoded 89, near 11 | heuristic: soft voting or stronger ECC candidate (11 near, 0 mid, 0 far of 11 failing; oracle absent) |
| music_bed_db | none (first below at 20) | 20 (79.0%) | n/a | at 20: decoded 79, near 18, mid 2, unknown 1 | heuristic: soft voting or stronger ECC candidate (18 near, 2 mid, 0 far of 20 failing; oracle absent) |
| opus_kbps | 12 (95.0%) | 10 (72.0%) | n/a | at 10: decoded 72, near 9, mid 1, unknown 18 | heuristic: soft voting or stronger ECC candidate (9 near, 1 mid, 0 far of 10 failing; oracle absent) |
| mp3_kbps | 32 (100.0%, whole sweep) | 32 (100.0%, whole sweep) | n/a | n/a | n/a |
| volume_db quieter | -12 (100.0%) | -20 (56.0%) | n/a | at -20: decoded 56, near 40, mid 1, unknown 3 | heuristic: soft voting or stronger ECC candidate (40 near, 1 mid, 0 far of 41 failing; oracle absent) |
| volume_db louder | 6 (100.0%, whole sweep) | 6 (100.0%, whole sweep) | n/a | n/a | n/a |
| lowpass_khz | 4 (99.0%) | 3 (63.0%) | n/a | at 3: decoded 63, near 37 | heuristic: soft voting or stronger ECC candidate (37 near, 0 mid, 0 far of 37 failing; oracle absent) |
| highpass_hz | 100 (100.0%) | 300 (88.0%) | n/a | at 300: decoded 88, near 12 | heuristic: soft voting or stronger ECC candidate (12 near, 0 mid, 0 far of 12 failing; oracle absent) |
| kept_s | 0.5 (100.0%, whole sweep) | 0.5 (100.0%, whole sweep) | n/a | n/a | n/a |
| spliced_pct | 50 (99.0%) | 50 (99.0%) | n/a | at 25: decoded 1, far 1, unknown 98 | heuristic: embedding change candidate (tiling, redundancy, sync) (0 near, 0 mid, 1 far of 1 failing; oracle absent) |
Where it breaks: speech, streaming
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| offset_ms | 20 (100.0%; first below at 1, after 0) | 20 (100.0%; first below at 1, after 0) | >= 90% up to 20; 100.0% at 1 | at 1: far 100 | heuristic: resync/search candidate (0 near, 0 mid, 100 far of 100 failing; oracle 100.0%) |
| tempo slower | 0.95 (94.0%, whole sweep) | 0.95 (94.0%, whole sweep) | n/a | n/a | n/a |
| tempo faster | 1.05 (96.0%, whole sweep) | 1.05 (96.0%, whole sweep) | n/a | n/a | n/a |
| noise_snr_db | 15 (94.0%) | 10 (57.0%) | n/a | at 10: decoded 57, near 7, unknown 36 | heuristic: soft voting or stronger ECC candidate (7 near, 0 mid, 0 far of 7 failing; oracle absent) |
| opus_kbps | 16 (94.0%) | 12 (70.0%) | n/a | at 12: decoded 70, near 29, mid 1 | heuristic: soft voting or stronger ECC candidate (29 near, 1 mid, 0 far of 30 failing; oracle absent) |
| lowpass_khz | 8 (99.0%) | 8 (99.0%) | n/a | at 6: near 27, mid 73 | heuristic: no single candidate (mixed margins) (27 near, 73 mid, 0 far of 100 failing; oracle absent) |
Where it breaks: music, batch
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| tempo slower | none (first below at 0.99) | none (first below at 0.99) | n/a | at 0.99: near 18, mid 2, unknown 10 | heuristic: soft voting or stronger ECC candidate (18 near, 2 mid, 0 far of 20 failing; oracle absent) |
| tempo faster | none (first below at 1.01) | none (first below at 1.01) | n/a | at 1.01: near 16, mid 2, unknown 12 | heuristic: soft voting or stronger ECC candidate (16 near, 2 mid, 0 far of 18 failing; oracle absent) |
| opus_kbps | none (first below at 24) | none (first below at 24) | n/a | at 24: decoded 8, near 22 | heuristic: soft voting or stronger ECC candidate (22 near, 0 mid, 0 far of 22 failing; oracle absent) |
Where it breaks: music, streaming
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| tempo slower | none (first below at 0.99) | 0.99 (83.3%) | n/a | at 0.99: decoded 25, near 5 | heuristic: soft voting or stronger ECC candidate (5 near, 0 mid, 0 far of 5 failing; oracle absent) |
| tempo faster | none (first below at 1.01) | 1.02 (50.0%) | n/a | at 1.01: decoded 25, near 5 | heuristic: soft voting or stronger ECC candidate (5 near, 0 mid, 0 far of 5 failing; oracle absent) |
| opus_kbps | 16 (100.0%; first below at 24) | 10 (60.0%; first below at 24) | n/a | at 24: decoded 1, near 15, mid 14 | heuristic: soft voting or stronger ECC candidate (15 near, 14 mid, 0 far of 29 failing; oracle absent) |
Hints are a heuristic from the margin mix and the oracle rate at the first failing point, not a measurement: mostly near (>= 50% of failing items) suggests soft voting or a stronger ECC; mostly mid or far with an oracle >= 80% suggests a resync search; mostly far with a low or no oracle suggests an embedding change.
The start-offset sweep (speech, n=100): the issuer reads back only when the cut is a whole number of 20 ms blocks.
| offset_ms | batch detected | batch exact | streaming detected | streaming exact |
|---|---|---|---|---|
| 0 | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) |
| 1 | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) |
| 2 | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) |
| 5 | 53.0% (43.3 to 62.5%) | 0% (0 to 3.7%) | 41.0% (31.9 to 50.8%) | 0% (0 to 3.7%) |
| 8 | 37.0% (28.2 to 46.8%) | 0% (0 to 3.7%) | 99.0% (94.6 to 99.8%) | 0% (0 to 3.7%) |
| 10 | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) | 54.0% (44.3 to 63.4%) | 0% (0 to 3.7%) |
| 12 | 61.0% (51.2 to 70.0%) | 0% (0 to 3.7%) | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) |
| 15 | 94.0% (87.5 to 97.2%) | 0% (0 to 3.7%) | 0% (0 to 3.7%) | 0% (0 to 3.7%) |
| 18 | 95.0% (88.8 to 97.8%) | 0% (0 to 3.7%) | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) |
| 20 | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) | 100.0% (96.3 to 100.0%) |
| 30 | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) | 71.0% (61.5 to 79.0%) | 0% (0 to 3.7%) |
| 50 | 100.0% (96.3 to 100.0%) | 0% (0 to 3.7%) | 67.0% (57.3 to 75.4%) | 0% (0 to 3.7%) |
The other sweeps (tempo, pitch, speed, noise, music bed, Opus, MP3, volume, low-pass, high-pass, kept duration and
spliced share, and tempo and Opus on music) are in the JSON (corpora.<corpus>.sweeps) and summarised in
Robustness baseline.
Changes from the published CPU run
The same seed and clips on the GPU. The pilot's decisions were identical on the two devices (75 identical, 0 differ); in the full run these rows moved, the detection rates within the earlier run's Wilson interval (the issuer-exact column has no printed interval; music is n=30):
| corpus, mode | row | column | CPU | GPU |
|---|---|---|---|---|
| speech, batch | opus_12 | detection at 0.39 | 99.4% | 99.6% |
| speech, batch | opus_12 | at 0.5 | 97.2% | 96.8% |
| speech, batch | opus_12 | issuer exact | 95.6% | 96.2% |
| speech, batch | opus_24 | issuer exact | 100.0% | 99.8% |
| speech, batch | encodec_6 | at 0.5 | 48.2% | 48.4% |
| speech, streaming | opus_12 | detection at 0.39 | 99.8% | 100.0% |
| speech, streaming | opus_12 | issuer exact | 69.4% | 70.6% |
| music, batch | opus_12 | issuer exact | 30.0% | 33.3% |
| music, batch | opus_24 | issuer exact | 33.3% | 26.7% |
| music, batch | opus_48 | issuer exact | 33.3% | 30.0% |
| music, batch | mp3_128 | issuer exact | 80.0% | 83.3% |
| music, streaming | opus_12 | issuer exact | 70.0% | 80.0% |
Bit-accuracy means moved by at most 0.7 points and prob means by at most 0.01 on a few other rows, and speech streaming opus_12 at 0.5 went from 98.6% to 98.8%. The Twilio row is unchanged.
Historical: streaming_ctx (measured on the CPU)
The removed context kind, from the first published run (torch 2.14.0+cpu, 6 workers). Not re-run.
speech, streaming_ctx
n=500, skipped 0; embed SNR mean 30.0 dB, p5 25.9 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 22.4% (19.0 to 26.3%) | 5.8% | 13.8% | 95.7% / 81.2% | 0.25 |
| resample_8k | 22.2% (18.8 to 26.0%) | 4.8% | 13.6% | 95.5% / 81.2% | 0.24 |
| resample_44k1 | 22.6% (19.2 to 26.5%) | 5.8% | 13.8% | 95.7% / 81.2% | 0.25 |
| resample_48k | 22.6% (19.2 to 26.5%) | 5.6% | 13.8% | 95.7% / 81.2% | 0.25 |
| opus_12 | 3.8% (2.4 to 5.9%) | 0.6% | 0.4% | 90.5% / 75.0% | 0.08 |
| opus_24 | 10.6% (8.2 to 13.6%) | 1.6% | 3.6% | 93.9% / 81.2% | 0.15 |
| opus_48 | 15.4% (12.5 to 18.8%) | 2.8% | 8.4% | 94.7% / 81.2% | 0.19 |
| g711_mulaw | 20.0% (16.7 to 23.7%) | 4.4% | 12.6% | 95.8% / 87.5% | 0.24 |
| g711_alaw | 23.2% (19.7 to 27.1%) | 5.0% | 13.6% | 96.0% / 87.5% | 0.24 |
| mp3_64 | 21.6% (18.2 to 25.4%) | 4.0% | 13.0% | 95.6% / 81.2% | 0.24 |
| mp3_128 | 23.0% (19.5 to 26.9%) | 5.4% | 14.0% | 95.9% / 81.2% | 0.24 |
| aac_64 | 20.2% (16.9 to 23.9%) | 4.6% | 11.2% | 95.0% / 81.2% | 0.23 |
| aac_128 | 20.6% (17.3 to 24.4%) | 5.0% | 12.2% | 95.1% / 81.2% | 0.23 |
| tempo_0.9 | 0.2% (0.035 to 1.1%) | 0.2% | 0% | 76.7% / 50.0% | 0.04 |
| tempo_1.1 | 0.2% (0.035 to 1.1%) | 0% | 0% | 78.3% / 56.2% | 0.05 |
| noise_20db | 0% (0 to 0.76%) | 0% | 0% | 85.7% / 75.0% | 0.00 |
| lowpass_4k | 19.8% (16.5 to 23.5%) | 3.8% | 7.4% | 92.2% / 75.0% | 0.23 |
| encodec_6 | 12.2% (9.6 to 15.4%) | 4.6% | 0% | 48.6% / 31.2% | 0.22 |
| mimi | 1.2% (0.55 to 2.6%) | 0% | 0% | 70.4% / 43.8% | 0.01 |
music, streaming_ctx
n=30, skipped 0; embed SNR mean 29.5 dB, p5 27.8 dB.
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| identity | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.9% / 68.8% | 0.16 |
| resample_8k | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.2% / 68.8% | 0.16 |
| resample_44k1 | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.5% / 68.8% | 0.16 |
| resample_48k | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.9% / 68.8% | 0.16 |
| opus_12 | 0% (0 to 11.4%) | 0% | 0% | 82.5% / 68.8% | 0.03 |
| opus_24 | 3.3% (0.59 to 16.7%) | 0% | 3.3% | 79.4% / 68.8% | 0.10 |
| opus_48 | 0% (0 to 11.4%) | 0% | 0% | 76.5% / 62.5% | 0.13 |
| g711_mulaw | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.0% / 68.8% | 0.17 |
| g711_alaw | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.2% / 68.8% | 0.16 |
| mp3_64 | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 83.3% / 68.8% | 0.15 |
| mp3_128 | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.0% / 68.8% | 0.16 |
| aac_64 | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.2% / 68.8% | 0.16 |
| aac_128 | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 81.5% / 68.8% | 0.17 |
| tempo_0.9 | 0% (0 to 11.4%) | 0% | 0% | 69.0% / 43.8% | 0.02 |
| tempo_1.1 | 0% (0 to 11.4%) | 0% | 0% | 70.2% / 50.0% | 0.01 |
| noise_20db | 3.3% (0.59 to 16.7%) | 0% | 0% | 79.4% / 68.8% | 0.15 |
| lowpass_4k | 6.7% (1.8 to 21.3%) | 0% | 3.3% | 77.9% / 62.5% | 0.19 |
| encodec_6 | 80.0% (62.7 to 90.5%) | 70.0% | 0% | 39.8% / 25.0% | 0.63 |
| mimi | 0% (0 to 11.4%) | 0% | 0% | 43.1% / 31.2% | 0.18 |
plugin gate: PASS (streaming (block): opus_24 = 100.0%, opus_48 = 100.0%, g711_mulaw = 0%, PASS; streaming_ctx (context): opus_24 = 10.6%, opus_48 = 15.4%, g711_mulaw = 20.0%, FAIL; required 90% / 95%)
Twilio PSTN loopback (recorded, re-decoded)
A real phone leg, with the batch mark: 100 batch-marked speech clips (16 kHz mono WAV from the LibriSpeech sample
above) plus 10 unmarked control clips, each played into a Twilio number-to-number call and recorded at the callee
(number B's recording, G.711 at 8 kHz), with the bench's telephony recorder.
Twilio does not say how a call between two of its own numbers is routed, so the leg may have stayed on Twilio's
network (carrier routing unconfirmed, possibly on-net): the recording is G.711 at 8 kHz, but it is not proof of a
carrier PSTN path. Each recording is aligned to its clip by cross-correlation and decoded like the rows above; a call
whose clip did not arrive would have counted as an error, not a miss. The GPU run placed no calls: it re-decoded the saved recordings on the GPU, and the row equals the first
measurement. The result is the twilio_loopback row of the speech, batch table above:
| transform | detection at 0.39 | at 0.5 | issuer exact | bits mean/p5 | prob mean |
|---|---|---|---|---|---|
| twilio_loopback | 99.0% (94.6 to 99.8%) | 96.0% | 98.0% | 99.9% / 100.0% | 0.92 |
- n=100 calls, 0 call errors. Detection 99.0 percent (95 percent interval 94.6 to 99.8 percent) at 0.39 and 96.0 percent at 0.5; issuer exact 98.0 percent; bits mean 99.9 percent; prob mean 0.92 (5th percentile 0.5792).
- 10 unmarked control calls: 0 flagged (highest score 0.0055).
- The streaming mark's phone evidence is the G.711 simulation above (0 percent), so the streaming speech proxy and the Pipecat and LiveKit plugins keep "does not survive a simulated phone leg"; a real call carried the batch mark at 99 percent. That is the unkeyed 16-bit streaming mark; the keyed buffered stream of a
laidline initdeployment reads through simulated G.711 (100 of 100 units, "Voice calls" below).
Voice calls
The voice-call matrix of the 2026-10 null campaign (campaign voice, run on 2026-10-28 UTC, rules and units fixed before
the first read) measures the keyed audioseal-p70-r1 mark the way a voice bot sends it: a clip fed through the buffered
stream marker in chunks (20 ms unless named), at 16 or 24 kHz, then a simulated phone path (codecs, packet loss, noise,
reverb), then read by Marker.detect with the shipped defaults of 2026-10-27 (decode: hardened, hardened_read: off) on a GPU. 10,200 units, each read keyed and unkeyed: 6,200 marked units and 4,000 unmarked ones. The codecs are
ffmpeg's encoders (simulated paths, not carrier paths); the real-call runs are
above. "Payload read" means the tag verified under the deployment's key and the payload equals the one written
(tag_accept). Every cell has n = 100 unless named. The figures are measured on the shipped read; the phase search added
since (Payload keys, "Audio") was not in the run; the v2 matrix of 2026-10-30 measured it (off by default, payload.phase_search; numbers under "Voice calls" in Audio).
The four headline lines passed; two pre-registered lines failed. Payload read at 10 s fed: 16 kHz stream 100/100, 24 kHz stream 100/100, G.711 mu-law 100/100, Opus 24 kbit/s 100/100 (Wilson 96.3 to 100 %). The line "0 wrong payloads" failed on 6 of 6,200 marked units (3 under reverb, 2 under white noise at 20 dB, 1 through G.722): each read a payload of another issuer number, its tag failed under all six live keys, the grade was D and the public tier showed presence only. The line "0 cross-issuer labels" failed on the same 6. Since the unverified-issuer rule of 2026-10-28 (Grading, "Keyed image payloads"; Payload keys, "Response tiers") a payload whose tag failed is shown on no tier, so none of the 6 would be shown. The report-only line "tag accepts on unmarked units at most 0.15 % (Wilson upper)" cannot be met at n = 1,000 per row (it needs 2,557); the measured rate was 0 of 1,000 on each of the four rows.
Length and presence
Barge-in units are the fed length minus 0.5 s, because the hold-back is dropped at close.
| fed | delivered | public presence 16 / 24 kHz | payload read 16 / 24 kHz |
|---|---|---|---|
| 2, 3, 4, 6, 8 s | 1.5 to 7.5 s | 100 / 100 % | 0 / 0 % (too_short) |
| 10, 15, 30 s | 9.5, 14.5, 29.5 s | 100 / 100 % | 100 / 100 % |
A keyless re-read (stage 1 and the choice, no tag) of the 100 + 100 units of 30 s, cut from their start: 100 % correct
at 8.0, 8.25, 8.5, 8.75 and 9.0 s delivered at both rates, 0 wrong; 7.5 s is too_short. The payload floor is 8.0 s
delivered on the mark's 20 ms grid (one P70 cycle); through the stream marker that is 10 s fed (9.5 s delivered), or at
least 8.5 s fed for a barge-in. Presence reads from 1.5 s delivered. The docs keep 10 s as the claim. The keyless re-read agreed with the
cloud tag accept on 711 of 711 units from 25 cells.
Alignment
The floor holds only on the grid. Stage 1 reads at phase 0 on 320-sample blocks counted from the first sample of the file, and every cloud unit was cut on that grid.
| condition | payload read |
|---|---|
| 9.5 s excerpt starting on the 20 ms grid, 16 / 24 kHz | 100 / 100 of 100 |
| 9.5 s excerpt starting 1 to 319 samples off the grid, 16 / 24 kHz | 6 / 4 of 100 (presence on 81 / 77 %) |
| clean 10 s unit delayed by 1 sample | 49 of 50 |
| delayed by 2, 3, 8, 11, 22, 80, 160 or 480 samples | 0 of 50 each |
| delayed by 5 samples | 9 of 50 (1 wrong) |
| delayed by 320 or 640 samples (whole blocks) | 50 of 50 each |
Off-grid audio keeps presence and loses the payload unless the phase search is switched on (payload.phase_search, off by default; v2 matrix). A keyless stage-2
probe (CPU, 26 reads of clean units after an 11-sample delay, G.722, AMR-WB, AMR-NB and white noise at 20 dB) read 26 of
26 with 0 wrong, against 1 of 26 for stage 1; it costs 77 to 200 s per read on a loaded CPU, and its false-accept side on
unmarked audio is not measured.
Codecs
Payload read (tag accept) at 10 s fed, out of 100. Every 4 s cell reads no payload.
| codec | payload read | public / full presence | median confidence | why |
|---|---|---|---|---|
| G.711 mu-law, A-law | 100, 100 | 100 / 100 | 0.99 | waveform kept, no delay |
| G.726 at 32 kbit/s | 100 | 100 / 100 | 0.98 | waveform kept, no delay |
| MP3 (the signed twin) | 100 | 100 / 100 | 0.99 | |
| Opus 32, 24, 16 kbit/s | 100, 100, 100 | 100 / 100 | 0.99, 0.99, 0.95 | |
| Opus 12 kbit/s | 98 | 100 / 100 | 0.85 | |
| Opus 8 kbit/s | 0 | 26 / 66 | 0.40 | destroys the mark: 4 of 50 even with the delay removed |
| G.722 | 0 | 95 / 100 | 0.70 | 22-sample delay: 50 of 50 once removed |
| AMR-WB at 23.85 kbit/s | 0 | 12 / 100 | 0.34 | 95-sample delay: 48 of 50 once removed (96 % aligned) |
| AMR-NB at 12.2 kbit/s | 0 | 14 / 99 | 0.29 | 80 to 83-sample delay (v2 measured) plus damage: 28 of 50 once removed (56 % aligned) |
| Speex | 0 | 99 / 99 | 0.85 | CELP does not keep the waveform: 0 of 50 |
G.729 and EVS are not measured: ffmpeg 7.0.2 has no encoder. Bandwidth is not the reason: the mark's residual has 99.999 % of its energy under 4 kHz, 23.5 dB under the speech (median, range 17.8 to 30.3 dB); G.711 and a plain 4 kHz low-pass keep it (50 of 50 each), a 4 kHz high-pass removes it (0 of 50). Where a codec fails only through delay, the failure is the alignment above, not the codec.
Packet loss, noise and reverb
| condition | payload read | public presence |
|---|---|---|
| packet loss 1, 3, 5 and 10 %, repeat-last or zero fill | 100 of 100 in every cell | 100 % |
| white noise at 30 / 25 / 20 dB SNR | 99 / 83 / 30 | 100 / 83 / 35 % |
| babble noise at 30 / 25 / 20 dB SNR | 99 / 100 / 94 | 100 / 100 / 95 % |
| reverb, RT60 0.3 s, direct-to-reverb ratio 0 dB | 8 | 94 % (full tier 99 %) |
CPU sweeps of 30 units each locate the limits. White noise, payload read by SNR: 35 dB 100 %, 30 dB 100 %, 27.5 dB 97 %, 25 dB 77 %, 22.5 dB 57 %, 20 dB 33 %, 17.5 dB 17 %, 15 dB 7 %; half the payloads are lost at about 22 dB SNR. Babble is milder (94 % at 20 dB). Reverb at 0 dB direct-to-reverb ratio: payload read 13, 7, 13 and 3 % at RT60 0.1, 0.2, 0.3 and 0.5 s, with presence at 83 to 93 %; the ratio matters more than the RT60: at RT60 0.3 s, +6 dB reads 67 % and +12 dB reads 100 %. Reverb followed by G.711 reads 3 %. Audio re-recorded in a room keeps presence and usually loses the payload.
Unmarked voice
Four rows of 1,000 unmarked units each (583 to 593 source clusters), read keyed and unkeyed:
| row | public presence | full-tier presence | payload read |
|---|---|---|---|
| G.711 mu-law | 0 of 1,000 | 7 | 0 |
| Opus 12 kbit/s | 1 of 1,000 | 5 | 0 |
| AMR-NB | 0 of 1,000 | 4 | 0 |
| 5 % loss, repeat fill | 1 of 1,000 | 7 | 0 |
Wilson upper bound 0.56 % on the rows with one hit. 0 tag accepts on 4,000 unmarked reads. Read without a key, the 16-bit word names a random issuer: 0 of the 6,103 marked units present on the unkeyed read named issuer 1.
With the phase search on (v2 matrix, opt-in)
Two further matrices (the v2 matrix of 2026-10-30: a core matrix of 1,250 positives and 13,000 unmarked units, and a
broad matrix of 6,850 positives and 13,000 unmarked units; 100 per cell, preregistered lines) re-ran this section's conditions on the build
with the phase search added. Every figure in this subsection is with payload.phase_search: on. The search ships off.
The shipped defaults, and every figure in the sections above, are the first run's. The cells that keep the 20 ms grid
(G.711, G.726, Opus at 16 kbit/s and above, the MP3 twin, 10 s fed) do not use the search and read the same either way.
| cell (10 s fed unless named) | shipped default (search off) | with payload.phase_search: on (ships off) |
sealed line, where one applies |
|---|---|---|---|
| On-grid 16 and 24 kHz, G.711 mu-law, Opus 24 kbit/s | 100 / 100 each | 200 / 200 each (two runs pooled) | 97 / 97 / 95 / 90 of 100 |
| G.722 | 0 / 100 | 192 / 200 = 96.0 % (92.3 to 98.0 %); 94 / 100 in one run, 98 / 100 in the other | 95 of 100 |
| AMR-WB 23.85 kbit/s | 0 / 100 | 200 / 200 | - |
| AMR-NB 12.2 kbit/s | 0 / 100 | 91 / 100 | - |
| 9.5 s excerpt off the 20 ms grid, 16 kHz | 6 / 100 | 194 / 200 = 97.0 % (93.6 to 98.6 %) | 95 of 100 |
| 9.5 s excerpt off the 20 ms grid, 24 kHz | 4 / 100 | 96 / 100 | - |
| 10 s clip with a delayed start | 0 of 50 for most delays (table "Alignment") | 191 / 200 = 95.5 % (91.7 to 97.6 %) | 95 of 100 |
- Unmarked voice. 26,000 unmarked units in two matrices of five rows each: 0 tag accepts, and public presence on at most 6 of 2,600 per row (0.23 %, line 0.5 %). The search chose a keyless payload on 2 of the 26,000; both tags failed and both were shown as presence without a payload. The preregistered line allowed none, so the line is not met, which is why the search ships off.
- Wrong payloads. None shown on any of the 1,250 and 6,850 marked units. Counted strictly, as the preregistered line counts them, the search produced an internal keyless pick that the tag rejected on 15 of the 1,250 and 27 of the 6,850. The first run's 6 wrong payloads on 6,200 marked units are the figures in the opening paragraph.
- Detect time. The line is p95 within 0.1 x duration + 1.5 s. On an A10G every cell is within it (worst 0.92 of the budget). On an L4 five cells with the search (both AMR rows, the delayed clip, both off-grid excerpts) run 7 to 10 % over it, and G.722 as well when the L4 machines are taken alone (1.07). The latency line was bound to the A10G; the L4 excess is a recorded deviation. The search costs about +0.5 s per read at p50.
- Same decisions on a second GPU type and a desktop GPU. The first run: 400 reads compared between the A10G and the L4, 400 between the A10G and the RTX 5080, 0 mismatches. The v2 reads on the RTX 5080: 400 reads compared, 0 mismatches (largest confidence difference 1e-4).
Stream telemetry
Compute numbers on the local RTX 5080 with no real-time pacing, not on the cloud boxes.
| rate, chunk | units | first marked frame (audio) | held back at close | worst chunk per unit p50 / p99 | chunks over budget | real-time factor p50 |
|---|---|---|---|---|---|---|
| 16 kHz, 20 ms | 4,900 | 0.70 s | 0.50 s | 16.5 / 48 ms | 1,473 of 2.05 million | 0.024 |
| 24 kHz, 20 ms | 1,000 | 0.72 s | 0.50 s | 16.9 / 44 ms | 444 of 490,000 | 0.026 |
| 16 kHz, 10 ms | 100 | 0.70 s | 0.50 s | 17 / 22 ms | 1.6 % of chunks, every unit | 0.026 |
| 16 kHz, 40 ms | 100 | 0.72 s | 0.50 s | 18 / 29 ms | 0 | 0.025 |
| 16 kHz, 100 ms | 100 | 0.70 s | 0.50 s | 16 / 26 ms | 0 | 0.024 |
The 0.7 s is the buffered embedder's lookahead, not compute; flush takes about 12 ms, and a barge-in drops the 0.5 s held
back. The chunk that completes a 320-frame model block takes 15 to 18 ms, so 10 ms chunks miss their budget on every block
boundary: feed 20 ms or more. One 960 ms first chunk was a cold start. Sessions below 16 kHz are passed through unmarked
(not measured, by design).
Null campaign 2026-10: how these were run
The sections below ("Image null campaign", "Video null campaign", "Audio null campaign", "Text null campaign") and the
v2 table under "Voice calls" report one campaign, nulls-2026-10, run from 2026-10-25 to 2026-10-30. It measures two
things for each medium: how often unmarked media is flagged (false positives), and whether blind marks of a test
issuer are read back (sensitivity). About 420,000 reads in all.
- Pass lines were fixed first. For every medium the pass lines and the preregistration (units, reads, intervals, verdict rules) were sealed in version control before any unit was selected, marked or read. Each result below is stated against its sealed line, including the ones that fall short of it. A line is never reworded after the fact.
- Blind positives. The marked units were produced by the product's own marker with a test issuer, mixed into the reads under neutral ids, and counted against a manifest that was opened only after the reads were done.
- Intervals. Wilson 95 % is the headline interval throughout. In no medium did an interval resampled by source exceed 1.5 times the Wilson interval, and where a count is 0 the Wilson upper bound is the figure to read.
- Hardware. Image, video, audio and voice reads ran on cloud GPU machines (NVIDIA L4 and A10G; L40S for part of the video run). Text ran on a local CPU. "Same decisions on a second GPU type and on a desktop GPU" means that 200 to 400 units per medium were read again on another GPU type and on a desktop RTX 5080, and every decision field was compared; a count of mismatches is given in each section.
- Corpora and licences. Public corpora only: COCO train2017, Open Images, YouTube UGC, Wikimedia Commons, Netflix Open Content, Xiph derf, LibriSpeech, Multilingual LibriSpeech, the Free Music Archive, FSD50K, C4, mC4, PG-19, arXiv abstracts, MAGE. Sources are licensed CC0, CC BY, public domain, ODC-BY or Apache-2.0, with these stated exceptions: Wikipedia text (CC BY-SA) and Wikimedia Commons clips admitted under CC BY-SA, read for measurement only and not republished; the Xiph derf test sequences and the Sintel film (97 of the 4,854 video clips), whose licences were not verified one by one. Sources used in earlier tuning ("seen": 982 of the video UGC clips, the train-clean-100 LibriSpeech split, a C4 shard) are tagged, and results are reported by source so that they can be checked.
What the campaign does not establish
- No digest of the stored records was committed before the manifests were opened, in any medium. The lines and preregistrations were sealed in advance; the records were not.
- The registry bundles in this campaign held controls only, so grade C (a registry-confirmed hit by the deployment's own record) was not exercised. Image and video positives reached grade B by fingerprint match.
- The text grade-B gate (p < 1e-5 on the exact tail) and the audio streaming-card rule were chosen on the same data they are reported on. They are in-sample choices, not held-out results. The audio rule was applied by replaying the stored records, not by a new run of the product.
- The voice null rows share source audio (each source appears in several rows), so the rows are not independent.
- 40 DCI 4K video clips (4096x2160) were refused by
watermark.video.max_pixelsat every read and are left out of the video counts. - The audio strata of other sounds (FSD50K) and synthesised speech (Kokoro) were added after the preregistration. They are reported and bind no line.
Image null campaign
Corpus. 38,500 unmarked photos as distributed (JPEG): 19,175 COCO train2017 photos (Flickr licence CC BY 2.0 or public domain) and 19,325 Open Images validation and test photos (CC BY 2.0, at most 25 per author). 2,200 screenshots (phone 390x844 and desktop 1920x1080 page captures of other Open Images photos, PNG) are a report-only second stratum. 42,200 units, each read four ways (168,800 reads). Positives: 1,500 photos marked by the product with a test issuer, blind, 500 for each of PixelSeal (the default scheme), TrustMark and keyed PixelSeal.
What was measured. The share of unmarked photos that Marker.detect flags (PixelSeal at the shipped defaults; keyed
PixelSeal: a tag that verifies under the deployment's key), and the share of blind positives read back with the
issuer's exact payload (350 unedited, 75 after JPEG quality 75, 75 after a resize to 0.6).
| measure | result | Wilson 95 % upper | sealed line | met |
|---|---|---|---|---|
| PixelSeal default read, presence on unmarked photos | 0 / 38,500 | 0.0100 % | at most 19 of 38,500 (0.05 %) | yes |
| Keyed PixelSeal, tag accepted on unmarked photos | 0 / 38,500 | 0.0100 % | Wilson upper at most 0.15 % | yes |
| Screenshots of unmarked photos (report only) | 0 / 2,200 | 0.174 % | none | - |
| Sensitivity, unedited, PixelSeal | 350 / 350 (98.9 to 100 %) | at least 340 of 350 | yes | |
| Sensitivity, unedited, keyed PixelSeal | 350 / 350 (98.9 to 100 %) | at least 340 of 350 | yes | |
| Sensitivity, unedited, TrustMark (comparison) | 346 / 350 (97.1 to 99.6 %) | at least 340 of 350 | yes | |
| Wrong payloads, cross-issuer labels (1,500 positives) | 0, 0 | 0, 0 | yes |
- Sensitivity after edits (report only). PixelSeal and keyed PixelSeal read 75 of 75 after JPEG quality 75 and 75 of 75 after the resize; TrustMark 74 of 75 and 73 of 75. Every positive was read back at grade B by fingerprint match, never by exact bytes (the metadata strip changes the bytes).
- The 7 TrustMark misses were never watermarked. The marker declined to embed them because the result would have fallen below the 38 dB quality floor (1.4 % of photos); the fast decoder finds nothing on them.
- Keyed reads of unmarked photos. Before the unverified-issuer rule (Grading, "Keyed image payloads"), a keyed read carried a payload word whose tag had failed on 6.0 % of unmarked photos. Since the rule a word whose tag failed is shown on no tier. The 0 of 38,500 above is tag accepts, which that rule does not change.
- Detect time. p50 97 to 390 ms by image size up to 4 megapixels for the PixelSeal read (overall p50 139 ms, p95 185 ms); the read is CPU-bound (mean GPU utilisation 6 to 8 % per machine).
- Same decisions on a second GPU type and a desktop GPU. 800 reads compared between the L4 and the A10G and 800 between the L4 and the RTX 5080: 0 mismatches in any decision field, and a largest score difference of 0.
Video null campaign
Corpus. 4,854 decodable unmarked 5 s clips (H.264, cut to the clip format of the video suite above): 3,041 YouTube
UGC (CC BY 4.0; 982 of them were used in earlier tuning, 2,059 were not), 1,618 Wikimedia Commons, 93 Xiph derf, 46 local
1080p, 52 Netflix Open Content (Meridian) and 4 Sintel. 872 whole 20 s UGC videos are a report-only extra stratum.
40 DCI 4K clips (4096x2160) were refused by watermark.video.max_pixels at every read and are left out of the 4,854
(0.71 % of the stratum). Positives: 742 clips marked by the product with a test issuer, blind (VideoSeal 249, keyed
VideoSeal 247, keyframe TrustMark 246).
What was measured. The share of unmarked clips flagged by the unkeyed VideoSeal read (presence), by keyed VideoSeal (tag accepted) and by the keyframe TrustMark reader (tag accepted), and the share of blind positives read back with the issuer's exact payload.
| measure | result | Wilson 95 % upper | sealed line | met |
|---|---|---|---|---|
| VideoSeal, unkeyed: presence on unmarked clips | 0 / 4,854 | 0.079 % | Wilson upper at most 0.15 % | yes |
| VideoSeal, keyed: tag accepted on unmarked clips | 0 / 4,854 | 0.079 % | Wilson upper at most 0.15 % | yes |
| Keyframe TrustMark: tag accepted on unmarked clips | 0 / 4,854 | 0.079 % | Wilson upper at most 0.15 % | yes |
| Sensitivity, unedited, VideoSeal unkeyed | 169 / 174 = 97.1 % (93.5 to 98.8 %) | at least 97 % | rate met; the stated count of 170 of 174 was missed by one | |
| Sensitivity, unedited, VideoSeal keyed | 172 / 174 = 98.9 % (95.9 to 99.7 %) | at least 97 % | yes | |
| Sensitivity, unedited, keyframe TrustMark | 167 / 173 = 96.5 % (92.6 to 98.4 %) | at least 97 % | no: the stated count was 170 of 173 (168 would meet the 97 % rate) | |
| Wrong payloads, cross-issuer labels (742 positives) | 0, 0 | 0, 0 | yes |
- The keyframe sensitivity is below its line. Over the marked positives only (the clips that were actually watermarked) the keyframe reader reads 167 of 171. The misses sit at roughly a tenth of the bit rate of the hits. The keyframe reader ran on PyTorch in this campaign, not on the ONNX reader benchmarked under "Keyframe reader on ONNX Runtime" above.
- After H.264 crf 23 (report only). VideoSeal unkeyed 70 of 75, keyed 69 of 73, keyframe 70 of 73.
- Detect time. p50 7 to 12 s for a 5 s clip at 1080p or below and about 32 s at 2160p for VideoSeal (16 s for the keyframe reader); the slowest read took 74 s. The reads are CPU-bound (median GPU utilisation 0 %), so the L4, A10G and L40S machines gave the same throughput.
- Same decisions on a second GPU type and a desktop GPU. 556 reads compared between the L4 and the A10G and 556 between the L4 and the RTX 5080: 0 mismatches in any decision field; the largest score difference was 4.1e-7.
Audio null campaign
What the audio payload is built for. The keyed audio payload and its decoder are built for speech and conversations of 8 s and more. Music and clips shorter than 8 s are known limits outside that scope. They are stated here, with the numbers, so that customers can plan around them. Two product behaviours follow from the measurements below: a keyed deployment hides the streaming detector's unconfirmed presence, and an unkeyed deployment shows presence only and never names an issuer.
Corpus. Four strata of 5,000 unmarked units each: single utterances (3,177 LibriSpeech, 1,823 Multilingual LibriSpeech in seven languages; CC BY 4.0), 30 s joins and 120 s joins of consecutive LibriSpeech utterances of one chapter, and 30 s music clips (4,979 decodable, Free Music Archive tracks licensed CC0, CC BY or public domain, at most 19 per artist). Add-on strata, added after the preregistration and reported only: 5,000 FSD50K sound clips (CC0 or CC BY) and 2,000 Kokoro synthesised utterances. Positives: 1,200 clips marked with a test issuer, blind (600 unkeyed; 600 keyed, 300 through the buffered marker and 300 through the stream marker).
What was measured. Two readers per unit. The keyed read is the shipped keyed audioseal-p70-r1 read: public
presence, and a payload accepted only when its tag verifies. The unkeyed read is the 16-bit presence read at the
hardened 0.5 setting. Sensitivity is the share of blind positives read back with the issuer's exact payload.
Keyed deployments
Public presence is shown here after the streaming-card rule (Grading, "The streaming card in a keyed P70 deployment"), replayed over the stored records.
| measure | result | Wilson 95 % upper | sealed line | met |
|---|---|---|---|---|
| Tag accepted on unmarked audio, each stratum | 0 / 5,000 (music 0 / 4,979) | 0.077 % | Wilson upper at most 0.15 % | yes |
| Tag accepted, all unmarked keyed reads | 0 / 26,979 | report only | - | |
| Public presence, single utterances / 30 s / 120 s | 0 / 5,000 each | 0.08 % | at most 0.5 % / 0.5 % / 1 % | yes |
| Public presence, music | 2 / 4,979 | 0.15 % | at most 0.5 % | yes |
| Public presence, sounds (add-on, report only) | 15 / 5,000 | 0.49 % | none (0.5 % shown for reference) | - |
| Positives that lose public presence under the rule | 0 of 1,400 | - | ||
| Sensitivity, stream marker | 197 / 210 = 94 % (89.7 to 96.3 %) | at least 204 of 210 (97 %) | no | |
| Sensitivity, buffered marker | 178 / 210 = 85 % (79.3 to 89.0 %) | at least 204 of 210 (97 %) | no | |
| Wrong payloads shown, cross-issuer labels shown | 0, 0 (1 of 600 counted strictly, see below) | 0, 0 | no (strictly 1 of 600); 0 shown, the 1 internal pick was rejected by the tag |
- Why the rule exists. Before it, keyed music showed public presence on 361 of 4,979 clips (7.25 %, Wilson upper 8.00 %), 360 of them from the streaming detector, which does not read the keyed marks; the same card gave all 25 public false presences on read speech and 177 of the 192 on sounds. In a keyed deployment that card can only confirm someone else's mark, so its detection alone is no longer public presence. Replayed over the saved reads: music 2 of 4,979, read speech 0, sounds 15 of 5,000, and none of the 1,400 positives lost its public presence. This is an in-sample rule applied to stored records (see above), not a new run.
- Sensitivity is below its line, and where the misses are. All 13 stream misses are clips shorter than 8 s (1.95 to 7.04 s). The keyed payload needs 8 whole one-second segments, 8.0 s of audio; no payload was read from any clip shorter than 8 s (0 of 167 speech positives), while all 34 positives of 8 to 10 s and all 469 of 10 s or more were read (stream positives of 9 s or more: 196 of 196). The buffered misses are the same short clips in speech (speech 133 of 140 = 95 %, 132 of 132 at 9 s or more) and music, where 45 of 70 unedited keyed clips (64 %) were read: 33 of the 100 keyed music positives were present but the default read chose no payload; 2 were under 8 s; 1 chose a keyless wrong payload. Keyed music payloads are a known limit.
- Wrong payloads. None shown. Counted strictly, as the sealed line counts them, there is one: on a music positive (1 of 600) the keyless step picked a payload naming another issuer. Its tag failed under all six live keys. At the time of the run (Round M, before the verified-tag rule) the full tier showed it as an unverified "claimed issuer"; since that rule it is shown on no tier, and the public tier showed presence only throughout.
- Deep scanning was measured and rejected.
payload.hardened_read: auto(an optional second stage for keyed music) recovered 0 of the 33 music positives it was added for, chose 26 payloads on 4,979 unmarked music clips where the line allowed none (none verified a tag), and chose a wrong issuer on 3 positives. It stays off. - Detect time. p50 0.94 s (single utterances) to 2.28 s (120 s joins) per unit on the L4; the read is GPU-bound (mean GPU utilisation 92 %).
Unkeyed deployments
Unkeyed audio carries a 16-bit word with no error detection, so one flipped bit names another issuer. In the shipped product unkeyed audio is shown as presence only, with no issuer named. The rows below are the public presence of the unkeyed read, which the streaming-card rule does not change.
| stratum | result | Wilson 95 % upper | sealed line | met |
|---|---|---|---|---|
| Single utterances | 9 / 5,000 (0.18 %) | 0.34 % | at most 0.5 % | yes |
| 30 s joins | 4 / 5,000 (0.08 %) | 0.21 % | at most 0.5 % | yes |
| 120 s joins | 12 / 5,000 (0.24 %) | 0.42 % | at most 1 % | yes |
| Music | 361 / 4,979 (7.25 %) | 8.00 % | at most 0.5 % | no |
| Sounds (add-on, report only) | 191 / 5,000 (3.82 %) | none | - | |
| Synthesised speech (add-on, report only) | 2 / 2,000 (0.10 %) | none | - | |
| Sensitivity, issuer named correctly | 393 / 420 = 93.6 % (90.8 to 95.5 %) | at least 408 of 420 | no | |
| Wrong issuer named | 42 of 600 | 0 | no |
- Unkeyed music is a known limit. 360 of the 361 hits fire on the streaming detector. An unkeyed deployment still shows that detection because the unkeyed stream mark is the one that card reads; the rule above fixes keyed deployments only. Plan for unkeyed music to show presence on about 7 % of unmarked clips. A keyed deployment does not have this limit (2 of 4,979 after the rule).
- Unkeyed identity. All 600 unkeyed positives were detected as present. The 42 reads that named another issuer are 16-bit words with one flipped bit, 40 of them on the 100 music positives. Presence only is shown for unkeyed audio now, so no issuer is named.
- Fallback paths. On music the fallback read paths add 293 of 4,979 clips to the full tier's presence beyond the public tier's; the full tier reports them with a caveat that says so.
- Detect time. p50 0.88 s (single utterances) to 1.78 s (120 s joins) per unit on the L4.
Equality and below-line results
- Same decisions on a second GPU type and a desktop GPU. 400 reads compared between the L4 and the A10G: 0 mismatches. Between the L4 and the RTX 5080 every decision field is identical; a diagnostic field (a note that a fine read was removed) differs on 13 of the units, which is reported as a deviation and decides nothing.
- Results below their sealed line, in one place. Keyed sensitivity, stream 94 % and buffered 85 % against 97 % (shortfall: clips under 8 s, and music at 64 %); no payload under 8 s; unkeyed music public presence 7.25 % against 0.5 %; unkeyed sensitivity 93.6 % and 42 of 600 wrong issuers, now not shown; keyed wrong payloads and cross-issuer labels, strictly 1 of 600 against a line of 0; keyed pooled identity 375 of 420. Keyed music public presence, which was also 7.25 % before the rule, is 2 of 4,979 (0.04 %) after it, with no positive lost; unkeyed music is unchanged.
Text null campaign
Corpus. Three strata of 5,000 human documents each, by length in tokens: 200 to 512, exactly 1,024 and exactly 8,192. They are drawn from C4 (ODC-BY), Wikipedia (CC BY-SA, read for measurement only), PG-19 (Apache-2.0 dataset of pre-1919 public-domain books), arXiv abstracts (CC0), and mC4 in German, Spanish, French, Japanese and Chinese (ODC-BY). A fourth stratum of 5,000 machine-written texts (MAGE 4,215, UltraChat 785) is report only. 20,800 units, each read by the SynthID reader and the KGW reader (41,600 reads, none unreadable). Positives: 800 texts marked by the product with a test issuer, blind, 400 per scheme (280 unedited, 120 after deleting 10 % of the words).
What was measured, and which gate. The numbers below are the re-score at the gate that ships: grade B at
p < 1e-5, presence (grade D) at p < 1e-3, and KGW scored on the exact binomial tail (Grading, "Per-signal grades"; Configuration,
text.grade_b_p_value). The gate was set after an earlier run at the previous gate (B at p < 1e-3, normal
tail) had failed several of its lines; only the shipped gate is reported here. The same 20,800 units were re-read.
| scheme | length | presence, k of 5,000 (Wilson upper) | grade B, k of 5,000 (Wilson upper) | sealed lines | met |
|---|---|---|---|---|---|
| KGW | 200 to 512 tokens | 8 (0.32 %) | 0 (0.08 %) | presence at most 0.5 %, B at most 0.2 % | yes |
| KGW | 1,024 tokens | 13 (0.44 %) | 0 (0.08 %) | same | yes |
| KGW | 8,192 tokens | 6 (0.26 %) | 0 (0.08 %) | same | yes |
| SynthID | 200 to 512 tokens | 4 (0.21 %) | 0 (0.08 %) | same | yes |
| SynthID | 1,024 tokens | 3 (0.18 %) | 0 (0.08 %) | same | yes |
| SynthID | 8,192 tokens | 7 (0.29 %) | 0 (0.08 %) | same | yes |
| KGW and SynthID | machine-written text (report only) | 5 and 5 (0.23 %) | 0 and 0 | none | - |
- Pooled. Grade B on 0 of 20,000 unmarked documents per scheme (40,000 reads; Wilson upper 0.02 % per scheme). Presence: KGW 32 of 20,000 (0.16 %), SynthID 19 of 20,000 (0.10 %); the 5,000 machine-written documents are included.
- Sensitivity. SynthID: unedited 280 of 280 present and grade B; after deleting 10 % of the words 119 of 120 present (the miss is a 19-token text), 112 at grade B. KGW: unedited 280 of 280 present, 260 at grade B (the other 20 are grade D because too few distinct n-grams were scored); 10 % deletion 120 of 120 present, 100 at grade B. All meet the 97 % line (at least 272 of 280). Text carries no payload, so there are no wrong payloads or cross-issuer labels.
- Detect time. p50 for SynthID 8.7 ms (200 tokens) to 266 ms (8,192 tokens); for KGW 125 ms to 1,086 ms, on a CPU. Text ran on a CPU only, so there is no second-GPU comparison.
Caveats that travel with these numbers.
- The gate was chosen on these same documents. This is an in-sample result, not a held-out one.
- KGW's false-positive rate depends on the register of the text: arXiv abstracts score about 2.6 times the rate of the other sources. The margin on the 1,024-token KGW cell (13 of 5,000, upper 0.44 % against a 0.5 % line) is thin.
- Text grade B is statistical: it is a p-value below 1e-5, not a registry-confirmed match, and it must not be read as equivalent to the grade B of an image or video, which is confirmed against a registered asset by fingerprint.
- The gate costs length. B at p < 1e-5 needs about 1.9 times the tokens of B at p < 1e-3 for the same per-token signal, so short or heavily edited marked text becomes grade D sooner. That is not measured on paraphrase or translation.
Keyed audio payload
The keyed audioseal-p70-r1 payload (Payload keys, "Audio") was measured on the product path, end to end. Each clip is
marked with Marker.mark_audio in keyed mode and written by the product writer. It then goes through the transform
and is read by Marker.detect. All runs use alpha 1.41, the c10 cell layout, 10.0 s excerpts unless named, 4 live
audio keys and 2 own issuers.
Verified-exact means that detect chose a payload, its tag verified under a live key, and it equals the embedded payload. Intervals are Wilson 95 %. Every line was pre-registered before any data.
F1 is 200 LibriSpeech clips of 13 s or more, not used by any earlier run: 35 test-clean, 15 test-other and 150 dev-clean. Of these, 89 are fully fresh; the other 111 had been used only as unmarked nulls.
The CPU standard: the default read on CPU (A1)
The default read (hardened_read: off, CPU) reads the default envelope: an excerpt that starts on a 20 ms boundary
of the marked file, and the whole file. Every row read 200 of 200 (100 %, 98.1 to 100 %) in both envelopes,
except Opus at 12 kbit/s.
The rows that read 200 of 200:
- Opus 24 and 48;
- MP3 64 and 128;
- AAC 64 and 128 (MP4);
- G.711 mu-law and A-law;
- raw AAC (ADTS) as Laidline writes it, at 16, 44.1 and 48 kHz;
- Laidline's own mp3, m4a, opus, flac and wav output, decoded by the product;
- identity.
A1-rate is post-stratified with fixed weights: test-clean 1/2, test-other 1/4, dev-clean 1/4. Its interval combines the per-stratum Wilson bounds, which is conservative. The line is PASS at 95.0 % or better.
| row | envelope | rate | interval | test-clean | test-other | dev-clean | line |
|---|---|---|---|---|---|---|---|
| Opus 12 | default, 10 s | 97.3 % | 85.5 to 99.2 % | 35/35 | 14/15 | 144/150 | PASS |
| Opus 12 | whole file | 97.0 % | 84.7 to 99.2 % | 33/35 | 15/15 | 149/150 | PASS |
| Opus 12 | default, 12 s | 99.2 % | 88.1 to 99.6 % | 35/35 | 15/15 | 145/150 | (reported) |
| every other row | both | 100 % | 89.3 to 100 % | 35/35 | 15/15 | 150/150 | PASS |
The 89 fresh clips (reported, not the measure). A1-rate is measured on the full 200, a decision taken after the data. On the 89 fresh clips alone:
| row | rate | interval | strata | result |
|---|---|---|---|---|
| Opus 12, default | 98.6 % | 74.7 to 99.5 % | 12/12, 6/6, 67/71 | PASS |
| Opus 12, whole file | 91.3 % | 66.0 to 97.6 % | 10/12, 6/6, 70/71 | UNPROVEN |
| every other row | 100 % | 76.8 to 100 % | PASS |
At this size (12 test-clean clips carrying half the weight) one clip moves the post-stratified rate by about 4 points. The interval is wide, and the check has little power.
A1-eq (product equals bench; binding). The bench reader read every product file that Marker.detect read.
- The line: the net loss is at most 4 per row and no payload is wrong.
- Result: net loss 0 on 37 of the 38 rows, and -1 on Opus 12 default (the product read 193, the bench 192).
- PASS.
W0. 0 wrong payloads in every product and bench read of the build, including A1's 7,600 product reads.
P5-prod. No call lost to the arbiter (the truth decoded, the call not verified): 0 of 11,200 spans. PASS.
Edited audio under the fast read and the hardened decode (A2-presence, report only)
These are 10.0 s excerpts that start at any sample. They are read with hardened_read: off, under
decode: fast (what auto resolves to on a CPU-only host) and under decode: hardened. Each cell gives presence on the full tier and on the public tier, out of
200.
| row | decode: fast, full = public |
decode: hardened, full |
decode: hardened, public |
|---|---|---|---|
| Opus 12 | 31 (11.1 to 21.2 %) | 182 (86.2 to 94.2 %) | 34 (12.4 to 22.8 %) |
| Opus 24 | 124 | 199 | 125 |
| Opus 48 | 144 | 200 | 144 |
| MP3 64 | 165 | 200 | 165 |
| MP3 128 | 166 | 200 | 167 |
| AAC 64 | 163 | 200 | 164 |
| AAC 128 | 164 | 200 | 165 |
| G.711 mu-law | 161 | 200 | 163 |
| G.711 A-law | 164 | 200 | 165 |
| identity | 164 (76.1 to 86.7 %) | 200 | 165 |
decode: hardened ran its statistics on the GPU. A 20-clip CPU subsample (200 verdicts) differed in 1: Opus 12 on
clip 3570-5694-0003.
The GPU standard: deep scanning on a GPU (B1)
B1 reads with hardened_read: on on a GPU. Its excerpts start at any sample; the trims and offsets apply to the whole
file. The line: PASS at 190 of 200 or more, KILLED at 183 or fewer.
| rows | verified-exact | interval | line |
|---|---|---|---|
| Opus 24 and 48; MP3 64 and 128; AAC 64 and 128; G.711 mu-law and A-law | 200/200 each | 98.1 to 100 % | PASS |
| real containers: raw AAC as Laidline writes it, raw AAC at 44.1 and 48 kHz, MP3 64 untagged | 200/200 each | 98.1 to 100 % | PASS |
| identity | 200/200 | 98.1 to 100 % | PASS |
| trims (10 ms, 1 s) and offsets (0.5 s of silence, 3.0 s of unmarked speech prepended) | 200/200 each | 98.1 to 100 % | PASS |
| Opus 12 (best effort, no line) | 188/200 = 94.0 % | 89.8 to 96.5 % | reported |
Composed rows. A1's default-envelope excerpts were read with deep scanning on. The line: no more than 2 per 100 below A1. The result was no loss:
| row | deep scanning on | A1 (deep scanning off) |
|---|---|---|
| identity | 200 | 200 |
| Opus 12 | 196 | 193 |
| Opus 24 | 200 | 200 |
| MP3 64 | 200 | 200 |
| AAC 64 | 200 | 200 |
Nulls, foreign marks and the public body
N1, own-accepts on unmarked audio: 0 everywhere. PASS.
- Forced reads of the earlier null sets (1,591 clips; stage 1 and stage 2 on both detectors, whatever the trigger): 177,520 words, 307 BCH-valid, 0 eligible, 0 tag checks. The product path as configured showed 3 of those clips as present, and stage 2 ran on 113 of them.
- Decoder-level nulls on the product's Chase-II decoder and P70 rule:
- block null: 100,000 draws, 167 BCH-valid;
- constant messages: 65,536, 9,312 BCH-valid;
- Gaussian words: 1,000,000, 8,450 BCH-valid. None of them gave an eligible top or a tag check.
- Foreign marks (40 clips, keyed under 4 foreign keys):
- with the own issuer: 40 tag failures, 0 accepts;
- with another issuer: never checked, and labelled third-party on 40 of 40 (91.2 to 100 %).
TP-null: PASS. 0 third-party labels on N1's 1,591 null clips and on G1-null's 88 (102 single-word foreign tops).
TP-own: FAIL under the measured rule at 31 calls, and FAIL under the three-grid rule (chosen after the data) when measured at 3,000 calls. The rule now shipped adds a score floor (chosen after the 3,000-call data) and passed a fresh 3,000 with 0 labels.
- Under the two-grid rule that ran, one own mark was labelled as another deployment's: B1, Opus 12, clip 5536-43359-0007, under deep scanning. That was 1 of the 31 own-mark calls in which no own payload was chosen (27 on the A1 and B1 rows, 4 on B1's composed rows).
- Under the three-grid rule, the measured word lists give 0 of 31, and the 40 foreign marks stay labelled.
- The line allows at most 1 per 1,000 such calls and no row with more than 1 clip, so the line had no power at 31 calls.
- The powered measurement (rules written before the data): 3,000 of Laidline's own marks (train-clean-100 utterances of 13 to 17 s) keyed under foreign keys, every word of both stages kept, each read scored as if no own payload had been chosen. The three-grid rule labelled 13 of 3,000 (4.3 per 1,000; exact 95 % interval 2.3 to 7.4). The line stated before the data (7 or more labels fail, 0 passes) failed. The two-grid rule labelled 80 and R6' (two grids and two word positions) 21. The own payload was chosen on all 3,000 calls, so the product showed none of the 13 labels; the failure applies to own-mark calls in which no own payload is chosen. No line moved.
- The hold-out of the score floor (rules and a pre-registration record committed before any unit was read): see "Third-party rule S97 and its hold-out" below. The floor rule labelled 0 of 3,000 fresh own marks (rate shown below 1 per 1,000).
The cause and the candidate rules are below ("Third-party label on an own mark").
P-pub: PASS. All 17,871 public audio watermark bodies (A1, A2, B1 with its composed rows, N1 and the foreign marks) were one of the allowed bodies, with no forbidden field.
D1: the 16-bit claimed issuer.
- (a) 0 by construction: keyed mode never shows the 16-bit issuer (0 issuers shown in 4,000 calls).
- (b) The cost of (c): every issuer-exact 16-bit read loses its claimed issuer, 100 per 100 (the saved
tables of the earlier 16-bit runs, shipped decode):
- M-acc: 3,192 of 3,192 issuer-exact reads (3,597 detections of 3,700);
- HZb-30: 27 of 27;
- HZb-120: 24 of 24.
Streams (S-1)
The stream path is the keyed stream session (arm L2000-R200-H500, the tail flushed). It was compared with the file
path, mark_audio, on the same 100 T1 clips. The rows are the default-envelope excerpts through each codec, plus the
streamed MP3 (with the pad) and the streamed Opus. The line: verified-exact within 2 per 100 of the file path.
| row | stream | file | line |
|---|---|---|---|
| Opus 12 | 97 | 94 | FAILED by the letter; accepted after the data |
| Opus 24 | 99 | 100 | PASS |
| every other codec row, identity | 100 | 100 | PASS |
| streamed mp3 / file mp3, excerpt and whole | 100 | 100 | PASS |
| streamed opus / file opus, excerpt and whole | 100 | 100 | PASS |
The Opus 12 row fails the line's letter: the gap is 3 against the allowed 2, with the stream path reading more, not fewer. The two paths carry independent marks of the same clip (each has its own random cycle offset and asset payload), so the row compares two rates on 100 clips. A gap of 3 is within sampling variation at this n: the intervals overlap (91.5 to 99.0 % against 87.5 to 97.2 %). The row was accepted after the data because the stream path read more.
Timings
T1: stage 1 on CPU (2 threads on 2 reserved cores; the line is 0.1 x duration + 1.5 s). There were two runs:
- Run 2 (the quiet re-time). The reserved cores were 3 % busy before, at load 1.1 to 2.1. These are the measured figures.
- Run 1 (kept in the record). It ran under load 2.5 to 5.9, with the cores 6 % busy before.
| length | 3-grid (default) run 2 | 5-grid run 2 | 3-grid run 1 | 5-grid run 1 | line |
|---|---|---|---|---|---|
| 12 s | 2.20 s, MET | 3.18 s, MISSED | 1.98 s, MET | 3.61 s, MISSED | 2.7 s |
| 30 s | 2.71 s, MET | 3.61 s, MET | 5.12 s, MISSED | 6.25 s, MISSED | 4.5 s |
| 60 s | 4.08 s, MET | 4.98 s, MET | 6.86 s, MET | 8.74 s, MISSED | 7.5 s |
| 120 s | 6.94 s, MET | 7.84 s, MET | 10.82 s, MET | 8.35 s, MET | 13.5 s |
In the quiet run the default 3-grid reader met every line; run 1's 30 s miss was load. The 5-grid reader is not the default; it missed at 12 s and met the other lengths (report only).
T2: deep scanning on the GPU.
- Stage 2 per call at 12 s: p50 1.04 s, p95 1.14 s.
- The trigger (the hardened statistics) on unmarked clips: p50 0.44 s at 12 s, 0.63 s at 30 s and 1.70 s at 120 s.
- Stage 2 started on 113 of 1,591 unmarked calls (N1, the product path).
T3: hit against miss for the audio layout (report only). The measured timing channel stays within the bound stated in Payload keys residual 10 on every backend measured; one file-registry cell is unexplained and indicative. The mitigations are the equal-work construction (Payload keys, "Equal work and its residuals") and the registry recommendation: the file registry is for development; production uses Postgres or S3.
Third-party label on an own mark (TP-own analysis)
The call. B1, Opus 12 kbit/s, clip 5536-43359-0007, under deep scanning. Re-read on CPU, it reproduced the label.
- The detection. The 16-bit read did not detect (fast-read probability 0.18). Presence came from the hardened detection's phase path, which started stage 2.
- The own payload. It decoded on no word: there was no own-issuer top, no eligible word, and the truth was not among the tops.
- The foreign head. The only two BCH-valid words decoded the same version-2 payload, of a foreign issuer
(
269a0ee4f99e95e8a2, issuer 27040):- word positions: detector 0, phase 314, rotation 5, word 9;
- grids: 20 and 30;
- scores: 0.918 and 0.912;
- each word had a single Chase candidate (margin 1.0).
- Why two grids were not two readings. Grids 20 and 30 are 10 frames (200 ms) apart. Their 1.0 s segments overlap by 80 %, so the two "different grids" read largely the same audio. One wrong decode reappeared on the neighbouring grid and met the two-grid rule.
The same mechanism on two more own marks. N1's foreign-key marks that carry the own issuer (clips 32-21625-0006 and 328-129766-0096) also showed a third-party head in the forced read. The product did not label them, because their own head was chosen. Re-read on CPU with every word kept, both reproduce the head:
- 328-129766-0096: one word (stage 2, detector 0, phase 15, rotation 6, word 11) read on grids 10 and 20.
- 32-21625-0006: one word (detector 0, phase 0, rotation 7, word 11) read by stage 1 on grids 0 and 17, and by stage 2's phase-0 read on grid 0. That is the same word of the same audio, counted from two stages.
Candidates. Every candidate narrows R0, so the null sets give 0 under all of them: N1's 1,591 clips, G1-null's 88 and the decoder-level nulls. The own marks and the 40 other-issuer foreign marks were re-read on CPU with every word kept.
| candidate | B1 call | N1f 328-129766-0096 | N1f 32-21625-0006 | foreign marks still labelled (of 40) |
|---|---|---|---|---|
| R0, shipped: a foreign v2 head tops words on 2 different grids | labelled | head | head | 40 |
| R1 (Chase margin), R2a/R2b (own presence, own top), R3 (no eligible own top) | labelled | not scored | not scored | 40 |
| R4: the head on 3 or more grids | not labelled | no head | no head | 40 |
| R5: 2 grids at least 15 frames apart | not labelled | no head | head | 40 |
| R6 as scored: 2 grids and 2 word positions, the stage counted in the position | not labelled | no head | head | 40 |
| R6': 2 words differing in grid and in word position, the stage not counted | not labelled | no head | no head | 37 |
| R7: 2 words from different reads (detector or phase) | not labelled | no head | no head | 0 |
R6 as proposed fails on 32-21625-0006: stage 1 and stage 2 read the same phase-0 word. The candidate demanding evidence from different audio reads (R7) removes every genuine foreign label too, because a genuine foreign mark's words come from one read: detector 0 at phase 0, on several grids. R4 is the only candidate here that removes all three own-mark heads and keeps all 40 genuine labels. Three grids still overlap in audio, so its independence is measured, not shown. The line had no power at 31 calls: a rate of 1 per 1,000 cannot be shown at that size.
Decision after the data: R4 ships. A third-party head needs three or more grids. R6 is withdrawn, refuted on 32-21625-0006, and R7 is unusable. Regression tests pin the rule on the three own-mark word lists (no head) and the 40 foreign marks (a head). Under R4 the lines read: TP-own 0 of 31 (no power), TP-null 0, and the foreign other-issuer label 40 of 40. At 3,000 calls R4 failed the TP-own line: 13 labels ("TP-own" above).
Third-party rule S97 and its hold-out
Why a floor. Each of the 13 labels of the 3,000-call run was one wrong word of an own mark (a wrong-rotation decode) read again on overlapping grids, and every such word scored at most 0.9445 (the score is 1 minus the weighted share of bits the Chase decoder flipped). Every genuine head of the fit data had a third grid at 0.9788 or more: the 3,000 own payloads scored as if foreign and the 40 foreign marks. S97 (the third-party rule's score floor) counts a grid only for words scoring 0.97 or more. On the 3,000 of that run it labels 0 and keeps 3,000 of 3,000 own heads and 40 of 40 foreign marks. The floor was chosen after that data (S96 and S95 fit equally; S98 already loses one own head), so it was tested once on a fresh set with the lines, the ladder (S97, then S96, then S95, only on a kept-line FAIL) and the predictions written down first.
The hold-out. 3,000 fresh own marks (train-clean-100, 13 to 17 s, the earlier 3,000 and the P40 units excluded, seed
[20261014, 40]) with the earlier run's construction (own issuer, foreign key, every word of both stages kept, each read scored
as if no own payload were chosen), and the next 150 clips marked by another issuer under a foreign key (4 of the 9
other bench issuers by the runner's index). run in 29 GPU slices, resumed from the
per-unit records after a machine crash; 0 errors, 0 own-accepts, 0 wrong chosen payloads;
the shipped third_party agreed with the round's replay from the recorded words (summarise_holdout) on every unit,
and a separate replay agrees. Precision: every unit was embedded and
read in full float32 on the GPU (TF32 off for matmuls and cuDNN, cuDNN deterministic: the bench's strict setting,
applied before the product's models load). Full float32 with deterministic
cuDNN is the shipped CUDA precision (watermark.cuda_precision: strict, the default), so these rates are at the shipped
precision; under torch's default TF32 near-threshold decisions can differ.
| line (stated before the data) | statistic | result | verdict |
|---|---|---|---|
| L-own | S97 labels on 3,000 own marks, as if no own payload chosen (PASS 0, FAIL 7 or more) | 0 of 3,000 (one-sided 95 % upper bound 1.0 per 1,000) | PASS |
| L-kept-own | own heads not kept under S97, scored as if foreign (PASS 0, FAIL 7 or more) | 1 of 3,000 (2,999 kept, at least 99.84 %) | INCONCLUSIVE |
| L-kept-foreign | clean foreign marks the product leaves unlabelled (PASS 0, FAIL 1 or more) | 0 of 150 (at least 98.0 %) | PASS |
Beside it on the same 3,000 reads: S96 0, S95 0, the three-grid rule without a floor 14 (its own line would fail again), the two-grid rule 76, R6' 18. Own heads kept under S96 and S95: 3,000 of 3,000; the one S97 miss had grid scores 0.9788, 0.9723, 0.9684 and 0.9665. The ladder descends only on a kept-line FAIL, so S97 is adopted; the public caveat stays on an L-own PASS. The reader's phantom-word rate, a property of any rotation-searched block code read on overlapping grids: 1,958 foreign version-2 words in 3,000 own-mark calls (0.65 per call; 1,985 in the 3,000-call run).
Opus 12 kbit/s, reported as measured (no line):
| row | measured | Wilson 95 % |
|---|---|---|
| 150 foreign marks, labelled by the product (S97) | 129 of 150 (86.0 %); presence shown on all 150, the other 21 presence only | 79.5 to 90.7 % |
| same, forced-read words: S97 / S96 / S95 / three grids / two grids | 131 / 133 / 133 / 134 / 147 of 150 | |
| 3,000 own marks: own payload chosen | 2,982 of 3,000 (99.4 %) | 99.1 to 99.6 % |
| same: labels among the 18 calls without it, every rule | 0 of 18 | 0 to 17.6 % |
| same, scored as if no own payload chosen: S97 / three grids / two grids | 0 / 1 / 10 of 3,000 | |
| same, own heads scored as if foreign and kept: S97 / S96 / S95 / three grids | 2,489 / 2,592 / 2,623 / 2,628 of 3,000 | S97 81.6 to 84.3 % |
Under Opus 12 the floor costs labels on genuine marks (a degraded word scores lower); a missed label is a weaker true statement: with the 16-bit detection fired, the full tier shows "a mark is present; its issuer could not be read", and the public tier shows presence only when that verdict came on a phase-0 path (on a fallback path, the absent body); without it, a head under the floor shows nothing.
Predictions written before the data, against the outcome: S97 about 0.02 labels expected, 0 seen; S96 0.18, 0; S95 0.5, 0; the three-grid rule about 13, 14; own heads missed 0 to 1, 1; clean foreign marks 150 of 150, 150 of 150. Regression tests pin the floor, the absent verdict below it, the 13 phantom heads of the 3,000-call run (no label) and the equal-work property.
Disclosure. Under decode: hardened the span read's words (the payload read inside long
clips) join stage 1's read and so reach this rule, S97's floor included. This hold-out ran without the span read (its
intact 13 to 17 s clips would not trigger it), so the figures above hold for that construction; the new exposure is a
call where the span read triggers. Measured there, not as a pre-registered line: 2,205 three-grid span reads on 800 own-
mark units of the two fresh long-clip rounds below gave 408 reads with a BCH-valid wrong top, the highest scoring 0.9638 and none
at or over 0.97; the read forced on 1,887 unmarked clips gave 0 third-party heads.
Keyed audio payload inside long clips
The whole-clip payload read needs 8.0 s delivered on the 20 ms grid and the docs claim 10 s ("Voice calls" above); the span read below is what reads a mark inside a longer recording.
The payload of keyed P70 marks (alpha 1.41) spliced into longer unmarked recordings, read under decode: hardened
with the span read (Grading, "The cell gate for keyed P70 audio"). Units: the earlier long-clip run's donors, marks, codecs and
hosts at fresh seeded offsets (a fresh phase per unit; the speakers repeat). Verified = the issuer's payload chosen and
its tag verified. The lines were stated before data and are the earlier long-clip run's, unchanged: 90 / 79 (PASS / KILL) for 12 s in
120 s, also through Opus 24 kbit/s, 95 / 84 for 24 s in 120 s. GPU (strict float32), 100 units a row.
Round 1: PR-A+ (the coarse pass's best phase, then the searched phase). Seed 2026101447. The predictions were restated before any fresh unit was read, from a GPU re-read of the design's 177 units (the CPU replay had predicted 93 for Opus 24).
| row | PR-A+ | verdict | prediction | PR-A | PR-C3 | PR-A2 (both peaks) | the earlier span rule, same units |
|---|---|---|---|---|---|---|---|
| 12 s in 120 s | 97 | PASS | 94 | 96 | 95 | 99 | 63 |
| Opus 24, 12 s in 120 s | 86 | UNPROVEN | 87 | 79 | 85 | 93 | 62 |
| 24 s in 120 s | 93 | UNPROVEN | 95 | 93 | 96 | 98 | 69 |
| 9 s in 30 s (reported) | 92 | report | 97 | 90 | 94 | 95 | 67 |
PR-A+ stays as measured, UNPROVEN. The read forced on 1,887 unmarked clips (300 30 s and 240 120 s test-corpus joins, 500 short utterances, 200 30 s and 100 120 s train-clean-100 joins, 547 clips of LJ Speech, VCTK and solo piano): 0 own-accepts, 0 eligible tops, 0 tag checks. The trigger fired on 1 of 840 unmarked joins, unit for unit as the earlier long-clip run measured. On 88 clips the hardened decode had flagged (G1-null), the decode result was identical with the read on, and the read gave 0 eligible tops.
Round 2: the span read (the two coarse peaks, then the searched phase, stopping at the first choice). Adopted after round 1. A second fresh draw (seed 2026101448; none of round 1's units), the span read adopted as the candidate before any of its units was read (the both-peaks form PR-A2 reported from the same reads). The predictions are round 1's measured PR-A2 counts.
| row | the span read | verdict | prediction | PR-A2 | PR-A+ | the earlier span rule, same units |
|---|---|---|---|---|---|---|
| 12 s in 120 s | 98 | PASS | 99 (96 to 100) | 98 | 96 | 67 |
| Opus 24, 12 s in 120 s | 93 | PASS | 93 (88 to 96) | 93 | 91 | 72 |
| 24 s in 120 s | 98 | PASS | 98 (94 to 99) | 98 | 96 | 64 |
| 9 s in 30 s (reported) | 98 | report | 95 (90 to 98) | 98 | 98 | 75 |
P0 (never below the earlier span rule on a unit): holds on every row. 0 wrong payloads, 0 own-accepts. The span read took one read on 94, 86, 94 and 96 units of the four rows (a hit at the first peak), three times on at most 5. On the unmarked join where the trigger fired it read three phases and gave 0 eligible tops.
CPU cost (2 threads on 2 reserved cores, 7 paired runs, p50; the keyed hardened detect with the span read):
| clip | added over the earlier span rule's read | the span read | whole detect | line |
|---|---|---|---|---|
| 120 s, a hit at the first peak | 0.02 s | 1.36 s | 67.5 s | at most 0.1 s (KILL over 1.0 s): PASS |
| 120 s unmarked join, the read forced (three reads) | 2.86 s | 4.24 s | 71.7 s | report |
| 30 s unmarked join, the read forced | 2.66 s | 4.14 s | 44.2 s | report |
On a hit the span read costs what the earlier span rule's one read cost (1.34 s); a miss costs about three such reads. The hardened decode itself is 55 to 56 s of the 120 s calls (GPU recommended); the trigger fires on 1 of 840 unmarked joins, so the miss path is a rare cost. Round 2: PASSED on every line.
Keyed audio fingerprint confirmation
Grade B by the registered asset's fingerprint record (Payload keys, "Audio"; Grading), measured on the product path:
Marker.mark_audio in keyed mode, the transform on a 10 s cut, Marker.detect reading every slot's 15,469-byte
record. Lines pre-registered, with the N-fp hold-out set. Intervals are Wilson 95 %.
| line | result |
|---|---|
| Decoy records never confirm | 1,000 synthetic 20 s queries and 10 matcher fixtures against the 5 decoy records: 0 of 5,050 confirmed; lowest statistic 0.458 (tau 0.33) |
| F-1: B by fingerprint on F1's 10 s cuts (200 clips per row) | 200/200 on opus_24, opus_48, G.711 mu-law and A-law, MP3 64 and 128, AAC 64 and 128, resample 8 kHz and identity; opus_12 194/200 (0.936 to 0.986), the 6 misses being payload reads (194/194 confirmed when the payload was chosen); pooled codec rows 1,994/2,000 (0.9935 to 0.9986); scope whole on every confirmed cut |
| N-fp (a): unmarked hold-outs, forced against every F1 record | 79 hold-outs sharing no source audio with F1: 15,800 comparisons, 12,000 scored on 60 hold-outs (18 short ones too short, 1 low content), 0 confirmed; lowest statistic 0.432 |
| N-fp, true-copy control (report only) | 9 hold-outs that are joins containing an F1 clip's utterance: confirmed on the 4 whose shared audio lies inside the searched 20 s (4.5 to 13.4 s of it; statistic 0.031 to 0.050), not on the 5 with 0 to 0.1 s inside |
| N-fp (b): 1,000 forgeries (a registered payload copied onto unrelated audio) | payload read and tag verified on all 1,000; grade C on all 1,000, 0 B; forced against the named record 0 confirmed, lowest 0.446 |
| T-fp: CPU of the confirmation per call (query fingerprint, 4 record reads, 4 comparisons) | p50 0.467 s at 10 s, 0.471 at 12 s, 0.514 at 30 s, 0.518 at 60 s, 0.505 at 120 s: 19, 17, 11, 7 and 4 % of the 0.1 d + 1.5 s detect budget against a 25 % line (2 threads, reserved cores; indicative: the machine was at load 15 to 29 during the run) |
| T3: hit against miss, record read included | within the bound stated in Payload keys residual 10 on every backend measured; no cell blocks (reported, indicative). Mitigations: the equal-work construction and the registry recommendation (the file registry is for development; production uses Postgres or S3) |
Video robustness (VideoSeal and keyframe TrustMark, Xiph derf)
What the video watermarks survive. Each clip is marked by the production pipeline (laidline.core.video, libx264
crf 18 preset medium, 32 frames per model call) with each scheme on its own, then goes through each transform and is
decoded the way /detect samples it (at most 256 frames: for VideoSeal at a stride that is a multiple of 4, for the
keyframe scheme the key frames plus the marking positions). An exact decode is the full payload recovered; a
mis-decode is a different, well-formed payload. What the failures mean and how much a decode-side fix could recover
is in Robustness baseline.
videoseal: VideoSeal 1.0 (vendored at870ca7f) on every frame, a 92-bit payload (version, issuer id, 48-bit asset sequence, CRC-16) under a BCH code that corrects 16 bit errors. Ran on the RTX 5080 (cuda:0).trustmark_keyframes: the stopgap scheme, TrustMark Q (61-bit image payload) on one forced IDR frame per second. Ran on the CPU (6 workers).
# with the cu130 torch wheels, or in the laidline-cuda container laidline bench video --corpus DIR --n 20 --device cuda --workers 6
Point --corpus at a directory of your own clips; the published run used 5 s cuts of the Xiph sequences below.
Corpus: 43 clips of 5 s cut from 24 Xiph.org derf test sequences (https://media.xiph.org/video/derf/y4m/; terms vary by
sequence, see Third-party licences; the clips are not redistributed): 35 at 352x288 and 8 at 1280x720, encoded libx264 crf 18.
The sequences total about 4.4 GiB of y4m. Measured: VideoSeal on the RTX 5080 (torch
2.14.0+cu130, TF32 off, cuDNN deterministic), the keyframe scheme on the CPU of the same machine;
machine.run_seconds 3029.9 including the sweeps on all 43 clips; 0 unreadable clips. The first published run had the rows up to social; the rows from rotate_90 on are new. The result ships as
laidline/compliance/data/video_robustness.json (with the sweeps and "Where it breaks"), and the marking-method text of
the evidence export quotes the configured scheme from it. What the same model costs on the GPU sidecar (720p job wall
time, GPU memory) is in "Video marking on the RTX 5080" and the image sizes in "Docker images", both below.
Transforms (transform_definitions in the JSON):
| transform | definition |
|---|---|
| identity | decode and re-encode, libx264 crf 18 preset medium (the pipeline's own settings) |
| h264_crf23 | libx264 crf 23, preset ultrafast |
| h264_crf28 | libx264 crf 28, preset ultrafast |
| h264_crf35 | libx264 crf 35, preset ultrafast |
| h265_crf28 | libx265 crf 28, preset ultrafast |
| vp9_crf33 | libvpx-vp9 crf 33 (constant quality, b:v 0, deadline good, cpu-used 4) in WebM |
| rescale_0.5 | rescale to 50 percent of each side (libswscale, even dimensions), libx264 crf 18 |
| rescale_0.75 | rescale to 75 percent of each side (libswscale, even dimensions), libx264 crf 18 |
| crop_80 | centre crop keeping 80 percent of each side (even dimensions), libx264 crf 18 |
| frame_drop_10 | every 10th frame dropped, same frame rate, libx264 crf 18 |
| fps_15 | frame rate reduced to 15 fps (libavfilter fps), libx264 crf 18 |
| remux_mkv | video packets copied into Matroska (no pixel change) |
| social | short side scaled to 720 or less, 30 fps, libx264 crf 26, faststart (a social-media upload) |
| rotate_90 | rotated a quarter turn clockwise (libavfilter transpose=clock, width and height swap), libx264 crf 18 |
| rotate_+5 | rotated 5 degrees clockwise about the centre on the same canvas, corners filled black (libavfilter rotate, bilinear), libx264 crf 18 |
| hflip | mirrored left to right (libavfilter hflip), libx264 crf 18 |
| crop_80_corner | top-left corner crop keeping 80 percent of each side (even dimensions), libx264 crf 18 |
| letterbox | centred black bars, always added: a clip narrower than 16:9 is pillarboxed into the smallest even 16:9 frame that holds it (side bars); a 16:9 or wider clip is letterboxed into the smallest even 4:3 frame (top and bottom bars); libx264 crf 18 |
| overlay_box | a caption bar drawn over the lower third: black at 60 percent opacity, x 5 to 95 percent of the width, y 70 to 85 percent of the height (libavfilter drawbox), libx264 crf 18 |
| brightness_eq | eq=brightness=0.08:contrast=1.1 on luma, chroma untouched (FFmpeg eq's integer map applied with libavfilter lutyuv, as eq is not in the PyAV build), libx264 crf 18 |
| grayscale | saturation set to zero (libavfilter hue=s=0, neutral chroma), libx264 crf 18 |
| trim_start_1.3s | the first 1.3 s cut (libavfilter trim; a cut off the 1 s key frame grid), libx264 crf 18 |
| middle_2s | a 2 s segment from the middle: round(2 x fps) frames from frame (frames - kept) // 2, libx264 crf 18 |
| splice_unmarked | the first half of the frames from the marked clip, the rest from the same clip's unmarked source (a partly marked edit), libx264 crf 18 |
| speed_1.25 | played 1.25 times faster: timestamps divided by 1.25 (setpts), then back to the source frame rate (libavfilter fps drops one frame in five), libx264 crf 18 |
| h264_1mbps | libx264 crf 18 under a VBV bitrate cap: maxrate 1000 kbit/s, buffer (bufsize) 2000 kbit, preset ultrafast; not a hard cap: with the buffer, the bitrate averaged over a 5 s clip can exceed 1 Mbit/s |
| screen_capture | a screen recording: scaled (aspect kept, even dimensions, libswscale) to fit 90 percent of a 1920x1080 canvas, centred on black, 30 fps, libx264 crf 23 |
| oracle_crop_80 | Oracle (not a product number): crop_80's centre crop padded back to the original size at the crop's true offset on mid-grey (0x808080), libx264 crf 18; bounds a detector that could undo the crop |
Video watermark robustness (VideoSeal videoseal_1.0@870ca7f, 92-bit payload under BCH t=16; keyframe TrustMark 0.9.2, 61-bit payload, one IDR frame per 1 s): n=43 clips of Xiph derf test sequences, 5 s clips, seed 0; unreadable 0.
videoseal (cuda:0)
n=43, skipped 0; embed PSNR mean 44.7 dB, p5 41.4 dB; embed 2.28 s per clip, detect 0.33 s per unmarked clip. The 43 unmarked clips are the earlier false-positive basis for video; the 2026-10 null campaign read 4,854 unmarked clips and flagged none (Video null campaign).
unmarked clips decoded to a payload: 0 of 43 (95% interval 0 to 8.2%); a uniformly random read lands within the BCH radius (t=16) of a codeword and passes the CRC-16 with probability 2^-64.4, below 2^-50.
| transform | exact decode (95% CI) | mis-decode | bits mean/p5 | detect s | exact, first 25% of frames |
|---|---|---|---|---|---|
| identity | 100.0% (91.8 to 100.0%) | 0% | 99.4% / 96.8% | 0.27 | 97.7% (87.9 to 99.6%) |
| h264_crf23 | 93.0% (81.4 to 97.6%) | 0% | 97.5% / 91.5% | 0.22 | 86.0% (72.7 to 93.4%) |
| h264_crf28 | 53.5% (38.9 to 67.5%) | 0% | 91.1% / 79.8% | 0.21 | 48.8% (34.6 to 63.2%) |
| h264_crf35 | 14.0% (6.6 to 27.3%) | 0% | 73.5% / 56.0% | 0.21 | 16.3% (8.1 to 30.0%) |
| h265_crf28 | 27.9% (16.7 to 42.7%) | 0% | 79.4% / 56.0% | 0.22 | 18.6% (9.7 to 32.6%) |
| vp9_crf33 | 83.7% (70.0 to 91.9%) | 0% | 96.6% / 88.7% | 0.29 | 72.1% (57.3 to 83.3%) |
| rescale_0.5 | 62.8% (47.9 to 75.6%) | 0% | 92.1% / 77.4% | 0.20 | 48.8% (34.6 to 63.2%) |
| rescale_0.75 | 93.0% (81.4 to 97.6%) | 0% | 98.6% / 91.5% | 0.20 | 90.7% (78.4 to 96.3%) |
| crop_80 | 62.8% (47.9 to 75.6%) | 0% | 92.4% / 77.0% | 0.21 | 34.9% (22.4 to 49.8%) |
| frame_drop_10 | 97.7% (87.9 to 99.6%) | 0% | 99.1% / 95.6% | 0.20 | 95.3% (84.5 to 98.7%) |
| fps_15 | 97.7% (87.9 to 99.6%) | 0% | 99.3% / 96.0% | 0.10 | 95.3% (84.5 to 98.7%) |
| remux_mkv | 100.0% (91.8 to 100.0%) | 0% | 99.8% / 98.8% | 0.24 | 100.0% (91.8 to 100.0%) |
| social | 62.8% (47.9 to 75.6%) | 0% | 93.8% / 82.7% | 0.19 | 58.1% (43.3 to 71.6%) |
| rotate_90 | 95.3% (84.5 to 98.7%) | 0% | 98.3% / 92.3% | 0.22 | 90.7% (78.4 to 96.3%) |
| rotate_+5 | 93.0% (81.4 to 97.6%) | 0% | 98.1% / 90.7% | 0.22 | 86.0% (72.7 to 93.4%) |
| hflip | 93.0% (81.4 to 97.6%) | 0% | 98.8% / 91.1% | 0.22 | 90.7% (78.4 to 96.3%) |
| crop_80_corner | 41.9% (28.4 to 56.7%) | 0% | 86.2% / 50.8% | 0.21 | 27.9% (16.7 to 42.7%) |
| letterbox | 34.9% (22.4 to 49.8%) | 0% | 85.9% / 69.3% | 0.23 | 34.9% (22.4 to 49.8%) |
| overlay_box | 95.3% (84.5 to 98.7%) | 0% | 98.1% / 93.2% | 0.22 | 90.7% (78.4 to 96.3%) |
| brightness_eq | 97.7% (87.9 to 99.6%) | 0% | 98.7% / 92.7% | 0.22 | 88.4% (75.5 to 94.9%) |
| grayscale | 100.0% (91.8 to 100.0%) | 0% | 99.4% / 95.6% | 0.21 | 95.3% (84.5 to 98.7%) |
| trim_start_1.3s | 90.7% (78.4 to 96.3%) | 0% | 96.5% / 77.0% | 0.16 | 72.1% (57.3 to 83.3%) |
| middle_2s | 90.7% (78.4 to 96.3%) | 0% | 96.5% / 82.7% | 0.09 | 72.1% (57.3 to 83.3%) |
| splice_unmarked | 97.7% (87.9 to 99.6%) | 0% | 98.7% / 93.5% | 0.23 | 95.3% (84.5 to 98.7%) |
| speed_1.25 | 95.3% (84.5 to 98.7%) | 0% | 98.8% / 94.3% | 0.18 | 95.3% (84.5 to 98.7%) |
| h264_1mbps | 90.7% (78.4 to 96.3%) | 0% | 97.4% / 88.7% | 0.21 | 90.7% (78.4 to 96.3%) |
| screen_capture | 69.8% (54.9 to 81.4%) | 0% | 93.4% / 73.8% | 0.30 | 51.2% (36.8 to 65.4%) |
| oracle_crop_80 | 93.0% (81.4 to 97.6%) | 0% | 96.4% / 91.9% | 0.22 | 74.4% (59.8 to 85.1%) |
trustmark_keyframes (cpu)
n=43, skipped 2; embed PSNR mean 42.3 dB, p5 38.8 dB; embed 4.98 s per clip, detect 1.50 s per unmarked clip.
unmarked clips decoded to a payload: 0 of 43 (95% interval 0 to 8.2%).
| transform | exact decode (95% CI) | mis-decode | bits mean/p5 | detect s | exact, first 25% of frames |
|---|---|---|---|---|---|
| identity | 95.1% (83.9 to 98.7%) | 0% | 93.3% / 80.6% | 0.96 | n/a |
| h264_crf23 | 87.8% (74.5 to 94.7%) | 0% | 86.0% / 66.4% | 1.26 | n/a |
| h264_crf28 | 78.0% (63.3 to 88.0%) | 0% | 68.5% / 58.6% | 1.30 | n/a |
| h264_crf35 | 12.2% (5.3 to 25.5%) | 2.4% | 59.0% / 52.2% | 1.39 | n/a |
| h265_crf28 | 26.8% (15.7 to 41.9%) | 0% | 66.0% / 57.0% | 1.39 | n/a |
| vp9_crf33 | 92.7% (80.6 to 97.5%) | 0% | 93.4% / 80.6% | 0.90 | n/a |
| rescale_0.5 | 90.2% (77.5 to 96.1%) | 0% | 90.2% / 70.6% | 1.03 | n/a |
| rescale_0.75 | 92.7% (80.6 to 97.5%) | 0% | 92.9% / 70.2% | 0.90 | n/a |
| crop_80 | 92.7% (80.6 to 97.5%) | 0% | 92.8% / 70.8% | 0.92 | n/a |
| frame_drop_10 | 92.7% (80.6 to 97.5%) | 0% | 59.9% / 55.8% | 1.28 | n/a |
| fps_15 | 78.0% (63.3 to 88.0%) | 2.4% | 88.6% / 49.6% | 0.83 | n/a |
| remux_mkv | 97.6% (87.4 to 99.6%) | 0% | 97.9% / 87.8% | 0.71 | n/a |
| social | 70.7% (55.5 to 82.4%) | 0% | 76.4% / 62.8% | 1.29 | n/a |
| rotate_90 | 0% (0 to 8.6%) | 0% | 50.2% / 46.4% | 1.42 | n/a |
| rotate_+5 | 0% (0 to 8.6%) | 0% | 55.7% / 49.0% | 1.43 | n/a |
| hflip | 95.1% (83.9 to 98.7%) | 0% | 95.2% / 76.6% | 0.81 | n/a |
| crop_80_corner | 39.0% (25.7 to 54.3%) | 0% | 79.8% / 55.8% | 1.32 | n/a |
| letterbox | 0% (0 to 8.6%) | 0% | 54.1% / 49.8% | 1.43 | n/a |
| overlay_box | 82.9% (68.7 to 91.5%) | 0% | 89.5% / 72.4% | 1.21 | n/a |
| brightness_eq | 85.4% (71.6 to 93.1%) | 0% | 93.4% / 74.4% | 0.85 | n/a |
| grayscale | 85.4% (71.6 to 93.1%) | 0% | 93.8% / 80.2% | 0.91 | n/a |
| trim_start_1.3s | 0% (0 to 8.6%) | 0% | 49.5% / 44.5% | 1.14 | n/a |
| middle_2s | 0% (0 to 8.6%) | 0% | 49.5% / 43.5% | 0.59 | n/a |
| splice_unmarked | 95.1% (83.9 to 98.7%) | 0% | 77.0% / 68.6% | 1.05 | n/a |
| speed_1.25 | 92.7% (80.6 to 97.5%) | 0% | 61.9% / 57.0% | 1.00 | n/a |
| h264_1mbps | 92.7% (80.6 to 97.5%) | 0% | 83.2% / 56.0% | 0.99 | n/a |
| screen_capture | 0% (0 to 8.6%) | 0% | 51.8% / 47.4% | 1.56 | n/a |
| oracle_crop_80 | 95.1% (83.9 to 98.7%) | 0% | 93.8% / 71.8% | 0.83 | n/a |
Columns: exact decode is the payload recovered exactly, with its Wilson 95 percent interval; mis-decode a
different payload that passed the code's checks; bits the agreement of the decoded bits with the embedded ones (for
VideoSeal the 248 hard codeword bits of the averaged logits, for the keyframe scheme the 100 raw TrustMark bits of each
voted frame; mean and 5th percentile); detect s the decode time per clip; exact, first 25% of frames (VideoSeal
only) the exact decode when only the first quarter of the sampled frames is averaged, a diagnostic for temporal
voting. oracle_crop_80 is not a product number: the crop padded back to the original size at its true offset.
Reading the tables:
- VideoSeal survives light re-encoding, geometry and tone edits, not typical upload compression. Exact decode is 100 percent with no change, after a Matroska remux and in greyscale; 97.7 percent after dropping every 10th frame, at 15 fps, after a brightness and contrast change or a half-unmarked splice; 95.3 percent after a 90-degree turn, a caption box or a x1.25 speed-up; 93.0 percent after a 5-degree rotation, a horizontal flip, rescaling to 75 percent and at H.264 crf 23; 90.7 percent after the start trim, the middle 2 s and a 1 Mbit/s cap. It falls to 83.7 percent at VP9 crf 33, 53.5 percent at H.264 crf 28, 27.9 percent at H.265 crf 28, 14.0 percent at crf 35, 62.8 percent after rescaling to 50 percent, an 80 percent centre crop or the social-media re-encode, 69.8 percent after a screen recording, 41.9 percent after a corner crop and 34.9 percent after letterboxing. The BCH code corrects 16 of 220 effective bits; heavier compression flips more.
- VideoSeal never returned a wrong payload. Mis-decode is 0 percent on every transform, and 0 of 43 unmarked clips decoded to a payload. The analytic bound for a uniformly random read is 2^-64.4 per decode.
- The keyframe scheme holds up better under compression and scale, fails every edit that moves its 1 s grid, and can
return a wrong payload. It recovers 78.0 percent at H.264 crf 28, 90.2 percent after rescaling to 50 percent and
92.7 percent after the centre crop, 95.1 percent with no change (over the 41 clips it marked; on 2 clips TrustMark
skipped every keyframe), but 0 percent after a rotation, letterboxing, the start trim, the middle 2 s and a screen
recording. It returned a different, well-formed payload for 2.4 percent of clips at crf 35 and at 15 fps. A decoded
payload alone therefore proves nothing:
detectcounts a registry hit only when the query's bytes or the fingerprint of its frame at 1 s match the evidence entry (Grading). 0 of 43 unmarked clips decoded to a payload. - More frames help VideoSeal. Averaging all sampled frames beats the first quarter on most rows (crop 80 percent 62.8 against 34.9 percent, screen recording 69.8 against 51.2 percent), which is why temporal weighting matters.
- Quality and speed. Embed PSNR is mean 44.7 dB (5th percentile 41.4 dB) for VideoSeal and 42.3 dB (38.8 dB) for the keyframes, measured as the evidence records it. VideoSeal on the RTX 5080 took 2.28 s per 5 s clip to embed and 0.33 s to check an unmarked clip; the keyframe scheme on one CPU worker 4.98 s and 1.50 s. These are wall-clock times with the sweeps running alongside, so they are comparable only between runs on an idle machine.
- Natural video only. VideoSeal's JND attenuation hides the mark in texture: uniform per-pixel noise frames decode at chance (0.50 bit accuracy in the vendoring test) and very smooth frames carry a faint mark. Test with real footage, not noise or flat colour.
Where it breaks
Sweeps on all 43 clips. Margins are bit errors against the codeword (VideoSeal: decoded at most 16 of 248, near at
most 32, mid at most 62, far above; keyframes: the averaged vote against the 100-bit packet, t=5, near at most 10, mid
at most 25). The hint is a heuristic, not a measurement. The per-clip margins of every main row are in the JSON
(where_it_breaks).
Where it breaks: videoseal
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| h264_crf | 23 (93.0%) | 28 (53.5%) | n/a | at 26: decoded 26, near 13, mid 4 | heuristic: soft voting or stronger ECC candidate (13 near, 4 mid, 0 far of 17 failing; oracle absent) |
| h265_crf | none (first below at 23) | 23 (55.8%) | n/a | at 23: decoded 21, near 8, mid 13, far 1 | heuristic: no single candidate (mixed margins) (8 near, 13 mid, 1 far of 22 failing; oracle absent) |
| vp9_crf | 25 (97.7%) | 40 (62.8%, whole sweep) | n/a | at 33: decoded 34, near 9 | heuristic: soft voting or stronger ECC candidate (9 near, 0 mid, 0 far of 9 failing; oracle absent) |
| bitrate | 1 (90.7%) | 0.5 (67.4%, whole sweep) | n/a | at 0.5: decoded 29, near 9, mid 4, far 1 | heuristic: soft voting or stronger ECC candidate (9 near, 4 mid, 1 far of 14 failing; oracle absent) |
| scale | 0.6 (90.7%) | 0.5 (62.8%) | n/a | at 0.5: decoded 25, near 10, mid 6, far 2 | heuristic: soft voting or stronger ECC candidate (10 near, 6 mid, 2 far of 18 failing; oracle absent) |
| crop | 90 (93.0%) | 80 (62.8%) | >= 90% up to 80; 88.4% at 85 | at 85: decoded 37, near 4, mid 1, far 1 | heuristic: soft voting or stronger ECC candidate (4 near, 1 mid, 1 far of 6 failing; oracle 88.4%) |
| rotate | 5 (93.0%) | 10 (81.4%, whole sweep) | n/a | at 10: decoded 34, near 8, mid 1 | heuristic: soft voting or stronger ECC candidate (8 near, 1 mid, 0 far of 9 failing; oracle absent) |
| trim_start | 1.5 (90.7%; first below at 1, after 0.6) | 2.5 (88.4%, whole sweep) | n/a | at 1: decoded 38, near 2, mid 1, far 2 | heuristic: no single candidate (mixed margins) (2 near, 1 mid, 2 far of 5 failing; oracle absent) |
| segment | 2 (90.7%; first below at 3, after 4) | 0.5 (72.1%, whole sweep) | n/a | at 3: decoded 36, near 4, mid 1, far 2 | heuristic: soft voting or stronger ECC candidate (4 near, 1 mid, 2 far of 7 failing; oracle absent) |
| splice | 25 (95.3%, whole sweep) | 25 (95.3%, whole sweep) | n/a | n/a | n/a |
| fps | 10 (90.7%, whole sweep) | 10 (90.7%, whole sweep) | n/a | n/a | n/a |
Where it breaks: trustmark_keyframes
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| h264_crf | 20 (95.1%) | 28 (78.0%) | n/a | at 23: decoded 38, near 1, mid 2 | heuristic: no single candidate (mixed margins) (1 near, 2 mid, 0 far of 3 failing; oracle absent) |
| h265_crf | none (first below at 23) | 23 (68.3%) | n/a | at 23: decoded 31, near 4, mid 6 | heuristic: no single candidate (mixed margins) (4 near, 6 mid, 0 far of 10 failing; oracle absent) |
| vp9_crf | 33 (92.7%) | 40 (82.9%, whole sweep) | n/a | at 40: decoded 36, near 4, mid 1 | heuristic: soft voting or stronger ECC candidate (4 near, 1 mid, 0 far of 5 failing; oracle absent) |
| bitrate | 0.5 (90.2%, whole sweep) | 0.5 (90.2%, whole sweep) | n/a | n/a | n/a |
| scale | 0.5 (90.2%) | 0.33 (68.3%) | n/a | at 0.33: decoded 32, near 7, mid 1, far 1 | heuristic: soft voting or stronger ECC candidate (7 near, 1 mid, 1 far of 9 failing; oracle absent) |
| crop | 80 (92.7%) | 80 (92.7%) | >= 90% up to 80; 80.5% at 70 | at 70: far 41 | heuristic: resync/search candidate (0 near, 0 mid, 41 far of 41 failing; oracle 80.5%) |
| rotate | none (first below at 1) | 2 (70.7%) | n/a | at 1: decoded 38, near 1, mid 2 | heuristic: no single candidate (mixed margins) (1 near, 2 mid, 0 far of 3 failing; oracle absent) |
| trim_start | 1 (95.1%; first below at 0.1) | 1 (95.1%; first below at 0.1) | n/a | at 0.1: far 41 | heuristic: embedding change candidate (tiling, redundancy, sync) (0 near, 0 mid, 41 far of 41 failing; oracle absent) |
| segment | 1 (92.7%; first below at 4) | 1 (92.7%; first below at 4) | n/a | at 4: decoded 1, far 40 | heuristic: embedding change candidate (tiling, redundancy, sync) (0 near, 0 mid, 40 far of 40 failing; oracle absent) |
| splice | 25 (92.7%, whole sweep) | 25 (92.7%, whole sweep) | n/a | n/a | n/a |
| fps | 30 (95.1%) | 15 (78.0%) | n/a | at 24: decoded 32, near 2, far 7 | heuristic: embedding change candidate (tiling, redundancy, sync) (2 near, 0 mid, 7 far of 9 failing; oracle absent) |
Hints are a heuristic from the margin mix and the oracle rate at the first failing point, not a measurement: mostly near (>= 50% of failing items) suggests soft voting or a stronger ECC; mostly mid or far with an oracle >= 80% suggests a resync search; mostly far with a low or no oracle suggests an embedding change.
The H.264 quality ladder and the centre crops, with the crop oracle (the other sweeps are in the JSON and in Robustness baseline):
h264_crf: H.264 quality ladder: libx264 at each crf, preset ultrafast
| crf | videoseal | trustmark_keyframes |
|---|---|---|
| 18 | 100.0% (91.8 to 100.0%) | 95.1% (83.9 to 98.7%) |
| 20 | 97.7% (87.9 to 99.6%) | 95.1% (83.9 to 98.7%) |
| 23 | 93.0% (81.4 to 97.6%) | 87.8% (74.5 to 94.7%) |
| 26 | 62.8% (47.9 to 75.6%) | 70.7% (55.5 to 82.4%) |
| 28 | 53.5% (38.9 to 67.5%) | 78.0% (63.3 to 88.0%) |
| 30 | 32.6% (20.5 to 47.5%) | 46.3% (32.1 to 61.3%) |
| 32 | 18.6% (9.7 to 32.6%) | 36.6% (23.6 to 51.9%) |
| 35 | 14.0% (6.6 to 27.3%) | 12.2% (5.3 to 25.5%) |
| 40 | 4.7% (1.3 to 15.5%) | 7.3% (2.5 to 19.4%) |
crop: centre crops: the percentage of each side kept (even dimensions), libx264 crf 18
| keep % | videoseal | oracle videoseal | trustmark_keyframes | oracle trustmark_keyframes |
|---|---|---|---|---|
| 95 | 95.3% (84.5 to 98.7%) | 95.3% (84.5 to 98.7%) | 95.1% (83.9 to 98.7%) | 95.1% (83.9 to 98.7%) |
| 90 | 93.0% (81.4 to 97.6%) | 93.0% (81.4 to 97.6%) | 95.1% (83.9 to 98.7%) | 97.6% (87.4 to 99.6%) |
| 85 | 86.0% (72.7 to 93.4%) | 88.4% (75.5 to 94.9%) | 95.1% (83.9 to 98.7%) | 95.1% (83.9 to 98.7%) |
| 80 | 62.8% (47.9 to 75.6%) | 93.0% (81.4 to 97.6%) | 92.7% (80.6 to 97.5%) | 95.1% (83.9 to 98.7%) |
| 70 | 7.0% (2.4 to 18.6%) | 76.7% (62.3 to 86.8%) | 0% (0 to 8.6%) | 80.5% (66.0 to 89.8%) |
| 60 | 0% (0 to 8.2%) | 23.3% (13.2 to 37.7%) | 0% (0 to 8.6%) | 7.3% (2.5 to 19.4%) |
| 50 | 0% (0 to 8.2%) | 0% (0 to 8.2%) | 0% (0 to 8.6%) | 0% (0 to 8.6%) |
Changes from the published run
The earlier VideoSeal numbers were embedded with cuDNN TF32 on (the torch default); this run has TF32 off, which changes the VideoSeal embed bytes. libx264 output is also occasionally not byte-reproducible (seen in 2 of 16 encodes under mixed load, 0 of 240 in isolation; cause unidentified), so per-clip bit accuracy reproduces to about +-0.006 between runs and an exact decode can move at the edge; that affects the keyframe scheme as well, which ran on the CPU both times. Rows whose exact decode or mis-decode moved, all inside the earlier run's Wilson interval:
| scheme | row | earlier run | this run |
|---|---|---|---|
| videoseal | h265_crf28 | 25.6% | 27.9% |
| videoseal | vp9_crf33 | 79.1% | 83.7% |
| videoseal | rescale_0.75 | 97.7% | 93.0% |
| videoseal | crop_80 | 53.5% | 62.8% |
| videoseal | fps_15 | 100.0% | 97.7% |
| videoseal | social | 67.4% | 62.8% |
| trustmark_keyframes | identity | 92.7% | 95.1% |
| trustmark_keyframes | h264_crf23 | 85.4% | 87.8% |
| trustmark_keyframes | h264_crf28 | 75.6% (mis-decode 4.9%) | 78.0% (mis-decode 0%) |
| trustmark_keyframes | h264_crf35 | 12.2% (mis-decode 4.9%) | 12.2% (mis-decode 2.4%) |
| trustmark_keyframes | h265_crf28 | 29.3% | 26.8% |
| trustmark_keyframes | rescale_0.5 | 87.8% | 90.2% |
| trustmark_keyframes | rescale_0.75 | 90.2% (mis-decode 2.4%) | 92.7% (mis-decode 0%) |
| trustmark_keyframes | fps_15 | 78.0% (mis-decode 0%) | 78.0% (mis-decode 2.4%) |
| trustmark_keyframes | social | 75.6% | 70.7% |
Keyframe reader on ONNX Runtime (TrustMark Q)
watermark.video.trustmark_runtime: onnx (the default) runs the keyframe reader's TrustMark Q decoder forward
through ONNX Runtime on Laidline's FP32 graph of the Q decoder weights (decoder_Q.fp32.onnx, sha256 1e641de3...).
Everything around the forward is the torch reader's. Measured on one host: AMD Ryzen 7 7800X3D (AVX-512, VNNI, no
AMX), CPU only. The gain on hosts without AVX-512 is unmeasured, and decision-equality belongs to a build (ORT version,
CPU kernels).
Decision-equality (onnxruntime 1.29.0, bench reader). 0 differing decisions against the torch FP32 Q-only reader on 8,661 saved units: 1,781 unmarked and 6,880 marked rows. There were 0 decodes on unmarked units. Max |delta normalised score| was 0.069, on reads far from the gates. One marked row (derf/bridge_far_0, crop 60) reads a wrong payload under both readers identically; it is a torch reader row, not an ONNX one.
The shipped onnxruntime 1.24.4. The Pipecat plugin requires onnxruntime~=1.24.3, so Laidline pins 1.24.4
rather than 1.29.0. 1.24.4 gives logits bitwise equal to 1.29.0's on 45 golden inputs (including the 16 reads nearest
the gates; the check runs on AVX-512 hosts) and on the bench reader's 512 calibration frames.
The product path against torch (onnxruntime 1.24.4, 2 threads each). The product's unkeyed
decode_keyframes(hardened) on the Q-only reader (hardened_reads) ran twice on the same file in one process, once
with trustmark_runtime: torch and once with onnx. It covered the bench reader's EQ-screen (364 marked rows and 120
unmarked clips) and EQ-U (every unmarked unit: 41 derf, 1,120 YouTube UGC, 620 fresh UGC; this is the earlier unmarked-clip basis, and the 2026-10 null campaign later read 4,854 unmarked clips, see Video null campaign). The comparison used the bench
decision-equality: the same payload and path, and read for read the same passed and accepted bits. A Critical
counts only where ONNX is wrong and torch is not. The rules were written before the data.
What this run did not pair: the keyed reader. The keyed reader (keyed_reads, the reader once payload keys are
configured) was not run on these sets here; the next paragraph covers it. It reads frames the unkeyed vote does not:
these sets' marked rows carry an unkeyed payload, which is not eligible under a key, so the keyed reader would go on
to the scan. Two things carry over to it:
- its per-read gate verdict is the same function of the logits as the unkeyed reader's (
best_candidateandsoftdecode.accept); - its outputs on the 45 golden inputs, 16 of them near the gates, are equal under both paths.
| set | units | differing (near-miss / hard) | ONNX Critical | decodes on unmarked (ONNX / torch) | verdict |
|---|---|---|---|---|---|
| EQ-screen | 484 (marked: 245 vote, 23 scan, 96 none under both) | 0 (0 / 0) | 0 | 0 / 0 of 120 | PASS |
| EQ-U | 1,781 (each scanned) | 0 (0 / 0) | 0 | 0 / 0 of 1,781 | PASS |
Over 269,819 aligned reads, 71,369 of them with a candidate on both sides: max |delta score| 0.0217, max |delta normalised| 0.069, the same maxima as the bench reader, both on reads rejected under both paths. None of the 2,145 units differs, so no read near a gate changed its verdict. Of the 364 marked rows (the EQ-screen sample of the 6,880; the bench reader ran all of them on 1.29.0), the torch reader makes no wrong row here.
The keyed reader, paired. The product's keyed keyframe branch (keyed_reads, own issuer,
4 bench video keys, a local registry) ran on the same file under torch and under ONNX (onnxruntime 1.24.4, 2 threads),
on EQ-screen (484), EQ-U (1,781) and 81 saved keyed-marked keyframe files. The comparison used the whole keyed signal
and the bench decision-equality, read for read. Line, written before the data: 0 differing. Result: 0 differing on
484, 1,781 and 81 units, 0 ONNX Critical, 0 payloads chosen on unmarked units on either side; on the keyed-marked set
79 exact by the vote and 2 nothing chosen, the same on both runtimes.
Whole-clip time of the product reader (onnxruntime 1.24.4, quiet window).
The product's decode_keyframes(hardened) on the Q-only reader, fingerprint frame included. Each figure is the median
of 3 repetitions after a warm-up, with the two readers alternated in one process.
- 2 threads: two reserved CPUs, idle before start (1-minute load 0.83), torch 2 threads, ORT intra-op 2.
- Default: the whole machine (8 threads, all CPUs idle before start, load 2.5), torch's default 8 threads, ORT intra-op 0. The 1-minute load reached 4.7 to 7.6 during the repetitions; that is this process's own threads, with no other queue.
- The line was the ONNX median at most 1.2x the bench reader's R1o figure.
| clip | threads | torch | ONNX | ONNX gain | bench R1o | ratio to R1o | line |
|---|---|---|---|---|---|---|---|
| akiyo CIF | 2 | 9.859 s | 5.491 s | 1.80x | 5.436 s | 1.01 | PASS |
| old_town_cross 720p50 | 2 | 19.054 s | 12.308 s | 1.55x | 11.082 s | 1.11 | PASS |
| akiyo CIF | default (8) | 4.970 s | 2.837 s | 1.75x | 2.497 s | 1.14 | PASS |
| old_town_cross 720p50 | default (8) | 9.236 s | 5.614 s | 1.65x | 5.008 s | 1.12 | PASS |
Neither clip decodes (both unmarked) under either reader.
Whole-clip time, unmarked clip, decode: hardened, Q-only reader (bench reader, onnxruntime 1.29.0, quiet window, median of 3). "8 threads" is the whole machine: torch's default and ORT intra-op 0.
| clip | torch, 2 threads | ONNX, 2 threads | torch, 8 threads | ONNX, 8 threads |
|---|---|---|---|---|
| akiyo CIF (141 frames scanned) | 9.96 s | 5.44 s (1.83x) | 4.80 s | 2.50 s |
| old_town_cross 720p50 (231 frames) | 17.77 s | 11.08 s (1.60x) | 8.65 s | 5.01 s |
| marked as written (vote only) | 0.28 / 0.69 s | 0.22 / 0.66 s |
Text robustness (SynthID and KGW, Qwen2.5-0.5B on C4)
What the text watermark survives. For 200 prompts from C4 (the first 50 tokens of a news document), each arm
generates a marked completion of up to 300 new tokens with Qwen/Qwen2.5-0.5B-Instruct and the bench key, then every
completion goes through each transform and is scored by Marker.detect with the arm's scheme, as /detect scores
text. Unmarked generations of the same prompts and 500 human C4 continuations give the false-positive rate. What the
failures mean is in Robustness baseline.
synthid_t1: SynthID tournament (ngram 5, depth 30), sampling at temperature 1.0 (top-k 0, top-p 1.0, no repetition penalty).kgw_t1: KGW (green-list ratio 0.25, bias 2.0, context width 1), sampling at temperature 1.0.kgw_t0: KGW, greedy decoding (the case KGW is configured for).
# with the cu130 torch wheels; the paraphrase and translation transforms also need sentencepiece laidline bench text --corpus FILE --n 200 --workers 2 --gen-batch 8 --device cuda
Point --corpus at a C4 shard or a JSON-lines file of your own prompts; --model names the generating model.
Corpus: C4 realnewslike validation, shard 00000-of-00001 (allenai/c4, ODC-BY 1.0); prompts, human controls and the
embed_25 filler are disjoint documents drawn with seed 0. Model Qwen/Qwen2.5-0.5B-Instruct (Apache-2.0) at revision
7ae557604adf67be50417f59c2c2f167def9a775; translation by Helsinki-NLP opus-mt-en-de and opus-mt-de-en. Measured
on the RTX 5080 (torch 2.14.0+cu130, transformers 5.17.0, TF32 off, cuDNN deterministic, 2
workers, generation batch 8): machine.run_seconds 3581.2. The result ships as
laidline/compliance/data/text_robustness.json; the marking-method text does not quote it. The false-positive guard is a
binomial bound (below).
Transforms (transform_definitions in the JSON):
| transform | definition |
|---|---|
| identity | none: the completion as decoded, re-tokenised by detect (a decode/re-tokenise round trip) |
| truncate_100 | keep the first 100 tokens (the generating model's tokenizer), decoded |
| truncate_50 | keep the first 50 tokens (the generating model's tokenizer), decoded |
| delete_10 | delete 10 percent of the words (whitespace-separated, seeded positions); line breaks kept |
| delete_30 | delete 30 percent of the words (whitespace-separated, seeded positions); line breaks kept |
| insert_10 | insert words drawn from the human C4 text (running text, so by frequency; seeded) at random positions, 10 percent of the word count |
| swap_10 | replace 10 percent of the words with words drawn uniformly from the distinct alphanumeric words of the human C4 text (seeded) |
| swap_30 | replace 30 percent of the words with words drawn uniformly from the distinct alphanumeric words of the human C4 text (seeded) |
| typo_5 | each non-space character, with probability 0.05, gets one edit: substitute a random lowercase letter, insert one after it, delete it, or swap it with the next character (no change at the end of a word) (seeded) |
| normalise | Unicode NFKC, curly quotes to straight quotes, every whitespace run collapsed to one space, trimmed |
| embed_25 | the completion placed at a seeded word boundary of human C4 text three times its token length (the mark is 25 percent of the tokens), joined by blank lines; KGW scores only the first 1024 tokens, so under KGW the mark is about 12 to 29 percent of the scored window, and part of it may fall outside |
| paraphrase | rewritten by the generating model (chat prompt, temperature 0.7, no watermark, at most twice the tokens) |
| translate_rt | English to German and back (Helsinki-NLP opus-mt-en-de and opus-mt-de-en, sentence by sentence, greedy) |
Text watermark robustness (Qwen/Qwen2.5-0.5B-Instruct, bench key fingerprints synthid 3bc1c45d, kgw 3bc1c45d): n=200 C4 realnewslike validation (allenai/c4, shard 00000-of-00001) prompts (50-token prefixes, up to 300 new tokens), seed 0, measured.
Detected: p < 0.001 from Marker.detect (one z-test over the first max_detect_tokens tokens: SynthID 8192, KGW 1024); grade B: detected over at least 200 tokens (KGW: tokens covered by distinct n-grams, a repeated n-gram counting once).
Decoding: sampled arms do_sample=True, temperature=1.0, top_k=0, top_p=1.0, repetition_penalty=1.0 (temperature: the arm's); greedy arm do_sample=False, temperature=unset, top_k=unset, top_p=unset, repetition_penalty=1.0; the raw C4 prefix as the prompt; the completion ends at its first EOS.
synthid_t1 (SynthID, temperature 1.0)
n=200 generations, tokens mean 296.5, p5 300; stopped before the limit 9.
| transform | detected (95% CI) | grade B | z mean / p5 | tokens mean | scored mean |
|---|---|---|---|---|---|
| identity | 100.0% (98.1 to 100.0%) | 98.5% | 24.06 / 17.14 | 296 | 289 |
| truncate_100 | 100.0% (98.1 to 100.0%) | 0% | 13.51 / 10.02 | 100 | 96 |
| truncate_50 | 99.5% (97.2 to 99.9%) | 0% | 9.39 / 6.77 | 50 | 46 |
| delete_10 | 100.0% (98.1 to 100.0%) | 98.0% | 16.28 / 11.50 | 267 | 261 |
| delete_30 | 97.0% (93.6 to 98.6%) | 92.5% | 6.66 / 4.00 | 209 | 204 |
| insert_10 | 100.0% (98.1 to 100.0%) | 98.5% | 16.39 / 11.52 | 327 | 320 |
| swap_10 | 100.0% (98.1 to 100.0%) | 98.5% | 15.28 / 10.37 | 300 | 294 |
| swap_30 | 94.0% (89.8 to 96.5%) | 92.5% | 5.45 / 2.98 | 306 | 301 |
| typo_5 | 100.0% (98.1 to 100.0%) | 98.5% | 8.75 / 5.65 | 355 | 349 |
| normalise | 100.0% (98.1 to 100.0%) | 98.5% | 20.20 / 13.82 | 296 | 288 |
| embed_25 | 100.0% (98.1 to 100.0%) | 100.0% | 12.01 / 7.80 | 1184 | 1158 |
| paraphrase | 49.5% (42.6 to 56.4%) | 35.5% | 4.38 / -0.57 | 209 | 195 |
| translate_rt | 89.5% (84.5 to 93.0%) | 89.5% | 5.94 / 2.17 | 333 | 275 |
kgw_t1 (KGW, temperature 1.0)
n=200 generations, tokens mean 296.4, p5 300; stopped before the limit 6.
| transform | detected (95% CI) | grade B | z mean / p5 | tokens mean | scored mean |
|---|---|---|---|---|---|
| identity | 100.0% (98.1 to 100.0%) | 97.0% | 13.52 / 10.74 | 296 | 270 |
| truncate_100 | 100.0% (98.1 to 100.0%) | 0% | 7.78 / 5.48 | 100 | 95 |
| truncate_50 | 96.5% (93.0 to 98.3%) | 0% | 5.43 / 3.36 | 50 | 48 |
| delete_10 | 99.5% (97.2 to 99.9%) | 95.0% | 11.76 / 9.31 | 267 | 247 |
| delete_30 | 99.5% (97.2 to 99.9%) | 66.0% | 8.57 / 6.20 | 209 | 198 |
| insert_10 | 100.0% (98.1 to 100.0%) | 97.5% | 11.75 / 9.15 | 326 | 303 |
| swap_10 | 100.0% (98.1 to 100.0%) | 97.5% | 11.12 / 8.41 | 300 | 280 |
| swap_30 | 99.5% (97.2 to 99.9%) | 97.0% | 6.96 / 4.71 | 306 | 295 |
| typo_5 | 99.0% (96.4 to 99.7%) | 98.5% | 8.43 / 6.18 | 356 | 339 |
| normalise | 99.5% (97.2 to 99.9%) | 96.5% | 12.28 / 9.73 | 296 | 269 |
| embed_25 | 98.0% (95.0 to 99.2%) | 98.0% | 6.87 / 4.02 | 1014 | 901 |
| paraphrase | 57.0% (50.1 to 63.7%) | 29.0% | 4.19 / 0.00 | 192 | 166 |
| translate_rt | 97.5% (94.3 to 98.9%) | 92.5% | 6.54 / 3.88 | 337 | 256 |
kgw_t0 (KGW, greedy)
n=200 generations, tokens mean 300.0, p5 300; stopped before the limit 0.
| transform | detected (95% CI) | grade B | z mean / p5 | tokens mean | scored mean |
|---|---|---|---|---|---|
| identity | 95.0% (91.0 to 97.3%) | 1.0% | 6.43 / 3.29 | 300 | 63 |
| truncate_100 | 95.0% (91.0 to 97.3%) | 0% | 6.28 / 3.17 | 100 | 56 |
| truncate_50 | 93.0% (88.6 to 95.8%) | 0% | 5.41 / 2.89 | 50 | 39 |
| delete_10 | 89.0% (83.9 to 92.6%) | 0.5% | 5.24 / 2.43 | 271 | 75 |
| delete_30 | 77.0% (70.7 to 82.3%) | 0% | 4.19 / 1.54 | 214 | 84 |
| insert_10 | 74.5% (68.0 to 80.0%) | 3.0% | 4.38 / 1.79 | 333 | 116 |
| swap_10 | 74.0% (67.5 to 79.6%) | 2.5% | 4.30 / 1.03 | 306 | 114 |
| swap_30 | 39.5% (33.0 to 46.4%) | 32.0% | 2.77 / 0.56 | 316 | 203 |
| typo_5 | 56.0% (49.1 to 62.7%) | 27.5% | 3.34 / 0.74 | 354 | 181 |
| normalise | 90.5% (85.6 to 93.8%) | 1.0% | 5.60 / 2.45 | 299 | 62 |
| embed_25 | 17.0% (12.4 to 22.8%) | 17.0% | 1.65 / -0.30 | 1024 | 717 |
| paraphrase | 30.5% (24.5 to 37.2%) | 0.5% | 2.29 / -0.40 | 121 | 62 |
| translate_rt | 63.5% (56.6 to 69.9%) | 0.5% | 3.51 / 0.44 | 308 | 66 |
False-positive controls
These 500 human continuations and 600 unmarked generations are the earlier false-positive basis for text. The 2026-10 null campaign read 20,000 unmarked documents per scheme at the shipped grade-B gate (Text null campaign).
| set | scheme | n | flagged | FPR (95% CI) | z mean / max |
|---|---|---|---|---|---|
| human C4 continuations | synthid | 500 | 1 | 0.2% (0.035 to 1.1%) | -0.06 / 3.31 |
| human C4 continuations | kgw | 500 | 0 | 0% (0 to 0.76%) | 0.03 / 3.07 |
| unmarked generations, synthid_t1 | synthid | 200 | 1 | 0.5% (0.088 to 2.8%) | -0.06 / 3.54 |
| unmarked generations, kgw_t1 | kgw | 200 | 0 | 0% (0 to 1.9%) | 0.14 / 2.86 |
| unmarked generations, kgw_t0 | kgw | 200 | 0 | 0% (0 to 1.9%) | 0.11 / 2.53 |
FPR guard (limit 0.1%, binomial tail 0.05): PASS: human/synthid 1 of 500 (0.2%, 95% CI 0.035 to 1.1%; expected 0.5 at the limit, P(X>=1)=0.39); unmarked:synthid_t1/synthid 1 of 200 (0.5%, 95% CI 0.088 to 2.8%; expected 0.2 at the limit, P(X>=1)=0.18); 0 flagged in the other 3 control sets
Columns: detected is p < 0.001 from Marker.detect, with its Wilson 95 percent interval; grade B is detected over
at least 200 tokens (for KGW, tokens covered by distinct n-grams, a repeated n-gram counting once); z is the test's
z score (mean and 5th percentile); tokens the text's tokens as detect tokenises it; scored the tokens (SynthID) or
distinct n-grams (KGW) the test used.
Reading the tables:
- Sampled text survives light editing. SynthID at temperature 1.0 is detected in 100 percent of completions with
no change (98.5 percent at grade B), after deleting, inserting or swapping 10 percent of the words, 5 percent typos,
Unicode and whitespace normalisation, and inside human text three times its length (
embed_25). It holds 97.0 percent after deleting 30 percent of the words and 94.0 percent after swapping 30 percent. KGW at temperature 1.0 is at least 96.5 percent on every row but paraphrase. - Paraphrase removes about half the marks; translation fewer. A paraphrase by the same model leaves 49.5 percent (SynthID) and 57.0 percent (KGW); an English-German-English round trip 89.5 and 97.5 percent.
- Short text detects but does not grade. The first 100 tokens are detected in 100 percent of completions for both sampled arms but never reach grade B, which needs 200 tokens.
- Greedy KGW on a small model detects but grades D. Greedy decoding with a 0.5B model repeats itself, and KGW
scores each distinct n-gram once, so a 300-token completion has 63 scored bigrams on average: 95.0 percent detected,
1.0 percent at grade B. It also breaks early under edits (74.0 percent after swapping 10 percent of the words) and
inside human text (17.0 percent for
embed_25). - False positives. SynthID flagged 1 of 500 human continuations (z 3.31) and 1 of 200 unmarked generations (z 3.54); KGW flagged none. The guard is a binomial bound on a 0.1 percent limit: at that limit 500 texts expect 0.5 flags, so a single flag is not evidence of excess, and the run passes. With no flag in 500 the Wilson upper bound is still 0.76 percent, so this run cannot show a rate below 0.1 percent either.
- Known gap.
detectscores only the configuredwatermark.text.schemewith the default key, with one z-test over the firstmax_detect_tokenstokens (8192; KGW 1024) and no windowed scoring.embed_25measures the cost of the dilution.
Where it breaks
The marked texts of the first 100 prompts, scored again after each sweep point (scoring only, no extra generation).
Margins are z classes (decoded: p < 0.001; near: at least half that z; mid: a quarter; far: below); the hint is a
heuristic, not a measurement. The sweep tables are in the JSON (sweeps) and summarised in
Robustness baseline.
Where it breaks: synthid_t1 (SynthID, temperature 1.0)
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| length (tokens kept) | 25 (98.0%, whole sweep) | 25 (98.0%, whole sweep) | n/a | n/a | n/a |
| delete (% of words) | 30 (97.0%) | 40 (73.0%) | n/a | at 40: decoded 73, near 21, mid 4, far 2 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (21 near, 4 mid, 2 far of 27 failing) |
| insert (% of words) | 30 (100.0%) | 40 (84.0%) | n/a | at 40: decoded 84, near 15, mid 1 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (15 near, 1 mid, 0 far of 16 failing) |
| swap (% of words) | 30 (95.0%) | 30 (95.0%) | n/a | at 40: decoded 38, near 49, mid 9, far 4 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (49 near, 9 mid, 4 far of 62 failing) |
| typo (% of characters) | 5 (100.0%) | 10 (53.0%, whole sweep) | n/a | at 10: decoded 53, near 36, mid 9, far 2 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (36 near, 9 mid, 2 far of 47 failing) |
| embed (mark's % of tokens) | 10 (100.0%, whole sweep) | 10 (100.0%, whole sweep) | n/a | n/a | n/a |
Tokens needed, length sweep (re-tokenised truncations): detected in >= 90% of items from 25 tokens, >= 50% from 25 tokens; grade B in >= 90% from 200 tokens, >= 50% from 200 tokens.
Tokens needed, per item (the first k at which the first k tokens of the marked text, as detect tokenises it, reach p < 0.001 or grade B, from its per-position scores): p < 0.001 reached by 200 of 200, median 9 tokens, p90 15, max 69; grade B reached by 197 of 200, median 200 tokens, p90 200, max 200.
Embed: SynthID scores the first 8192 tokens, which held the whole mark in every embed row (500 of 500), so an embed miss is dilution by the human text. Known product gap, not fixed: detect scores only the configured watermark.text.scheme and the default key_id, with one z-test over the whole text (its first max_detect_tokens tokens: 8192 for SynthID, 1024 for KGW) and no windowed scoring, so marked text inside longer human text is diluted; embed_25 measures that cost. token_ids hold the whole text (up to that cap) and mark_span locates the mark, so windowed scoring can be done offline.
Where it breaks: kgw_t1 (KGW, temperature 1.0)
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| length (tokens kept) | 50 (94.0%) | 25 (72.0%, whole sweep) | n/a | at 25: decoded 72, near 24, mid 1, far 3 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (24 near, 1 mid, 3 far of 28 failing) |
| delete (% of words) | 50 (98.0%, whole sweep) | 50 (98.0%, whole sweep) | n/a | n/a | n/a |
| insert (% of words) | 50 (100.0%, whole sweep) | 50 (100.0%, whole sweep) | n/a | n/a | n/a |
| swap (% of words) | 40 (99.0%) | 50 (79.0%, whole sweep) | n/a | at 50: decoded 79, near 21 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (21 near, 0 mid, 0 far of 21 failing) |
| typo (% of characters) | 10 (97.0%, whole sweep) | 10 (97.0%, whole sweep) | n/a | n/a | n/a |
| embed (mark's % of tokens) | 25 (99.0%) | 25 (99.0%) | n/a | at 10: decoded 32, near 6, mid 9, far 53 | heuristic: windowed scoring candidate (the mark is intact but diluted by unmarked text) (6 near, 9 mid, 53 far of 68 failing) |
Tokens needed, length sweep (re-tokenised truncations): detected in >= 90% of items from 50 tokens, >= 50% from 25 tokens; grade B in >= 90% from 300 tokens, >= 50% from 300 tokens.
Tokens needed, per item (the first k at which the first k tokens of the marked text, as detect tokenises it, reach p < 0.001 or grade B, from its per-position scores): p < 0.001 reached by 200 of 200, median 13 tokens, p90 36, max 92; grade B reached by 194 of 200, median 212 tokens, p90 228, max 292.
Embed: KGW scores only the first 1024 tokens (at 25%: 0 of 4 failing items had none of the mark in that window, 1 only part of it; at 10%: 65 of 68 failing items had none of the mark in that window, 3 only part of it). For a miss with none of the mark in the window the 1024-token cap of the product, not dilution, explains it; with part of it, both; else dilution. Known product gap, not fixed: detect scores only the configured watermark.text.scheme and the default key_id, with one z-test over the whole text (its first max_detect_tokens tokens: 8192 for SynthID, 1024 for KGW) and no windowed scoring, so marked text inside longer human text is diluted; embed_25 measures that cost. Every KGW embed row also keeps the whole text's arrays (full_token_ids, full_kgw_green, full_kgw_pos) and its mark_span, so windowed scoring can be done offline.
Where it breaks: kgw_t0 (KGW, greedy)
| sweep | holds >= 90% up to | holds >= 50% up to | oracle ceiling | margins at first failing point | hint |
|---|---|---|---|---|---|
| length (tokens kept) | 50 (91.0%) | 25 (73.0%, whole sweep) | n/a | at 25: decoded 73, near 26, far 1 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (26 near, 0 mid, 1 far of 27 failing) |
| delete (% of words) | none (first below at 5) | 50 (51.0%, whole sweep) | n/a | at 5: decoded 89, near 8, mid 3 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (8 near, 3 mid, 0 far of 11 failing) |
| insert (% of words) | none (first below at 5) | 20 (50.0%) | n/a | at 5: decoded 82, near 15, mid 2, far 1 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (15 near, 2 mid, 1 far of 18 failing) |
| swap (% of words) | none (first below at 5) | 20 (52.0%) | n/a | at 5: decoded 81, near 13, mid 4, far 2 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (13 near, 4 mid, 2 far of 19 failing) |
| typo (% of characters) | none (first below at 1) | 5 (53.0%) | n/a | at 1: decoded 83, near 14, mid 2, far 1 | heuristic: more evidence candidate (the signal survives but is weak: more tokens, a stronger bias) (14 near, 2 mid, 1 far of 17 failing) |
| embed (mark's % of tokens) | none (first below at 75) | 75 (64.0%) | n/a | at 75: decoded 64, near 24, mid 10, far 2 | heuristic: windowed scoring candidate (the mark is intact but diluted by unmarked text) (24 near, 10 mid, 2 far of 36 failing) |
Tokens needed, length sweep (re-tokenised truncations): detected in >= 90% of items from 50 tokens, >= 50% from 25 tokens; grade B in >= 90% from never within the sweep, >= 50% from never within the sweep.
Tokens needed, per item (the first k at which the first k tokens of the marked text, as detect tokenises it, reach p < 0.001 or grade B, from its per-position scores): p < 0.001 reached by 196 of 200, median 11 tokens, p90 27, max 53; grade B reached by 2 of 200, median 277.5 tokens, p90 296, max 296.
Identity misses: 10 (distinct bigram ratio mean 0.09, repetition rate mean 0.91; detected items 0.22 and 0.74).
Embed: KGW scores only the first 1024 tokens (at 75%: 0 of 36 failing items had none of the mark in that window, 0 only part of it; at 50%: 0 of 71 failing items had none of the mark in that window, 0 only part of it; at 25%: 0 of 166 failing items had none of the mark in that window, 36 only part of it; at 10%: 67 of 94 failing items had none of the mark in that window, 7 only part of it). For a miss with none of the mark in the window the 1024-token cap of the product, not dilution, explains it; with part of it, both; else dilution. Known product gap, not fixed: detect scores only the configured watermark.text.scheme and the default key_id, with one z-test over the whole text (its first max_detect_tokens tokens: 8192 for SynthID, 1024 for KGW) and no windowed scoring, so marked text inside longer human text is diluted; embed_25 measures that cost. Every KGW embed row also keeps the whole text's arrays (full_token_ids, full_kgw_green, full_kgw_pos) and its mark_span, so windowed scoring can be done offline.
Hints are a heuristic from the z margins at the first failing point, not a measurement: a length failure is too few tokens and an embed failure diluted evidence, by construction; otherwise mostly near (at least 50% of the failing items within half the z needed) suggests more evidence would do, and mostly mid or far that the edits break the n-grams the key hashes (a scheme change). Text has no oracle rows.
Validators
Laidline signs and reads with c2pa-python 0.37.10 (c2pa-rs 0.90.19, recorded in every evidence line as
c2pa.c2pa_rs). The validator matrix checks the same signed
files against other validator versions, because a verdict can change between versions:
| Validator | Versions | Pinned |
|---|---|---|
c2patool (c2pa-rs CLI, x86_64 Linux release) |
0.27.22, 0.26.72, 0.25.0 (the current minor and the two before it) | SHA-256 of each release tarball, checked on every run |
c2pa-python |
0.37.10 (the locked version, in process), 0.37.12 | the exact version, read out of process |
The corpus is the three golden inputs (PNG, JPEG, WebP) signed by a Marker with the fixture chain, in five cases:
anchored (expected Trusted), unanchored (Valid), tampered (one bit flipped in the image data, Invalid), edit (a
rendition with the signed asset as its ingredient) and, with --tsa, timestamped (a DigiCert RFC 3161 token). Two
validators disagree when their states or their failure-code sets differ.
Run: 15 cases (5 cases by 3 formats, --tsa included) by 5 validators. Every validator agreed
on every case but one: c2patool 0.25.0 reports the valid but untrusted (unanchored) manifest as Invalid in all
three formats, where c2patool 0.26.72 and 0.27.22 and both c2pa-python versions report Valid; the failure codes agree
(signingCredential.untrusted), so only 0.25.0's state mapping differs. The finding is recorded as a known disagreement (validator, case, state, note, date), and the matrix then reports 3
known disagreements and 0 new ones. A disagreement that does not match a known entry (another validator, case, state or
code set) is new and fails the run.
Laidline reruns the matrix every week and fails only on a new disagreement, an error verdict, or an enforced case with
an unexpected state. The
detect-time counterpart is the optional secondary validator (detect.secondary_validator, Grading): a real
c2patool 0.27.22 agreed with c2pa-rs on Laidline's signed files. Adobe's Verify site is not in the matrix.
Docker images
The CPU Dockerfile has a shared base stage (the extras image,audio,video,text,server,evidence,kms,ingest, gcs and the TrustMark Q, B and C weights) and two targets: cpu (the sidecar and ingest worker, the default) and
lambda (awslambdaric, AWS Lambda). The CUDA Dockerfile is the GPU sidecar: the same
layout as the cpu target with torch 2.14.0 and torchvision 0.29.0 from the PyTorch cu130 index (the wheels bundle
the CUDA 13 runtime; the host needs only the NVIDIA driver and the NVIDIA container toolkit). No image carries the
VideoSeal or AudioSeal weights: they download on first use into $LAIDLINE_HOME/models (SHA-256 pinned). Both
images install the TrustMark Q decoder's ONNX graph (decoder_Q.fp32.onnx), a pinned weight of the Laidline mirror: stage it
(sh deploy/docker/stage-models.sh, from ~/.cache/laidline/models/trustmark/ or the mirror) into the build's models/ directory before building. The build checks its size and
SHA-256 and fails when it is missing or different; --build-arg ALLOW_TORCH_Q=1 builds without it, and that image's
keyframe reader runs Q on torch. The sizes below predate the graph (94.8 MB). Measured
with Docker 29.1.3 / Compose 2.40.3, linux/amd64 (docker image ls DISK USAGE and CONTENT SIZE; docker image inspect .Size is the compressed figure):
| image | Dockerfile, target | on disk (uncompressed) | compressed layers | build |
|---|---|---|---|---|
compose-laidline:latest |
Dockerfile.cpu, cpu |
2.84 GB | 724.3 MB | with the audio and video extras |
laidline-lambda:local |
Dockerfile.cpu, lambda |
2.69 GB | 688.2 MB | before the audio and video extras |
laidline-cuda:local |
Dockerfile.cuda |
10.4 GB | 3.55 GB | with the audio and video extras |
Before the extras the cpu image was 2.69 GB on disk and 687.7 MB compressed; the audio and video extras (AudioSeal,
PyAV, einops, omegaconf) add about 150 MB on disk and 37 MB compressed. The cpu and lambda images share the base
layers. Pushed to ECR for the Lambda live run, the deployment image (the lambda target plus the config and chain)
was 687.9 MB compressed; the Lambda container image limit is 10 GB uncompressed. torch comes from the PyTorch CPU index
(about 200 MB) in Dockerfile.cpu; in Dockerfile.cuda torch, the bundled CUDA 13 libraries (cuBLAS, cuDNN, NCCL and
the rest) and triton make up most of the 10.4 GB. The size drives the Lambda cold start: the first mark after a deploy
took 61 to 67 s while Lambda read the layers (above).
The GPU image was built and run with NVIDIA Container Toolkit 1.20.1 (nvidia-ctk --version;
Docker's default runtime left at runc, the compose service reserves the GPU with deploy.resources.reservations) on
an NVIDIA GeForce RTX 5080 (16,303 MiB, driver 616.92). Inside the container torch reports True NVIDIA GeForce RTX 5080 13.0 (CUDA available, device, CUDA runtime). Cold start of laidline-cuda (compose service, profile
gpu), from docker compose up -d returning to the first /readyz 200: 10.2 s (11.7 s including up -d itself, which
created the two volumes). /readyz does not wait for the video weights: readiness is the TrustMark self-test mark
(TrustMark runs on CPU in this image too), and the VideoSeal (228 MB (218 MiB)) and AudioSeal (57 MB) weights download
into the laidline-models volume on the first video job (below). To keep that download off the first job, pre-fill the
volume with docker compose --profile gpu run --rm laidline-cuda models fetch (laidline models fetch outside
Compose); Video has the whole GPU setup.
Video marking on the RTX 5080
A live check sends a 5 s 1280x720 clip (120 frames at 24 fps, H.264
with an AAC tone track, 3.0 MB; the marked mp4 is 1.5 MB) through
the async job path of the laidline-cuda container (POST /v1/mark/video, poll GET /v1/jobs/{id} once a second,
fetch the result's asset), with watermark.device: cuda,
watermark.video.preset: fast, crf 18 and 32 frames per model call. On the host a CPU Marker with the same
signing chain then validates the C2PA manifest (Trusted) and decodes the VideoSeal payload (issuer 4021) from
all 120 frames. Measured, three runs on one container:
| run | evidence timings_ms (probe / watermark / mux / sign / total) |
client wall time (upload to download) |
|---|---|---|
| first job (downloads the weights, loads VideoSeal and AudioSeal on cuda, first CUDA calls) | 7 / 21,187 / 1,857 / 6 / 23,063 | 23.8 s |
| second job (warm) | 3 / 2,153 / 103 / 8 / 2,270 | 3.2 s |
| third job (warm) | 4 / 2,219 / 88 / 6 / 2,320 | 3.2 s |
watermark is the VideoSeal embed on the GPU interleaved with the libx264 encode (preset fast) of the marked frames,
so a warm job marks the 120 frames of 720p in about 2.2 s (about 55 frames per second, faster than real time at
24 fps); the recorded PSNR was 51.1 dB. The client wall time adds the upload, the one-second polling interval and the
download. nvidia-smi (the whole GPU, sampled every 0.5 s) showed the memory in use go from 1,338 MiB before the
first job (the host's own use) to 3,347 MiB once VideoSeal was loaded and 4,419 to 4,472 MiB once AudioSeal had loaded
and the first embed had run, where it stayed while the container ran (the PyTorch caching allocator keeps its blocks):
about 3.1 GiB for the sidecar. What the mark survives is in "Video robustness" above; how to run the job path is in
Video.
Reference
- Robustness: why each mark fails, the root causes and the headroom a decode-side fix could recover.
- Grading: how a decode becomes a grade, and the per-signal figures quoted from this page.
- Keys: the keyed payloads measured in the keyed audio sections.
- Configuration: the
watermark.*,detect.*andevidence.*settings these runs used. - Licences: third-party model and corpus terms for the benchmark data.