PAPH v3 — an integer-only perceptual hash for detecting plagiarised pixel art.
Reference implementation of PAPH-SPEC-003, shipped as two independent engines: JavaScript, and Rust compiled to WebAssembly.
They produce byte-identical wires and identical verdicts.
$ npm run parity
tier 1 and tier 2 wires, byte for byte
PASS 96x96 3952 + 2712 B, 67 keypoints
PASS 160x120 3952 + 10232 B, 255 keypoints
PASS 220x170 3952 + 10272 B, 256 keypoints
PASS 128x128 with alpha 3952 + 7952 B, 198 keypoints
PASS 301x97 with alpha 3952 + 10272 B, 256 keypoints
PASS 64x64 3952 + 272 B, 6 keypoints
PASS 400x300 3952 + 10272 B, 256 keypoints
25 passed, 0 failed
That is the point of the whole design, not a nice extra. An index, a consensus rule, and a moderator's appeal all rest on two parties being able to recompute the same answer and get the same bytes. The Rust is a from-scratch rewrite against the specification rather than a transliteration of the JavaScript, which is what makes the agreement mean something.
npm install @pixagram/paph3No dependencies. Node 18+, or any browser with WebAssembly.
import { load } from '@pixagram/paph3';
const { Paph, backend } = await load(); // 'wasm' where available, 'js' otherwise
const engine = new Paph(); // shipping defaults
const a = engine.hash(imageDataA); // { t1: 3952 B, t2: 32 + 40n B, ... }
const b = engine.hash(imageDataB);
const v = engine.compare(a, b);
v.verdict; // 'Copy'
v.class; // 'certified — structure and geometry agree'
v.structural; // 9017 (0..10000)
v.geometric; // 10000
v.abstained; // ['silhouette'] channels that could not evaluatehash() accepts an ImageData, { px, w, h }, { pixels, width, height }, or (bytes, width, height).
Pin a backend if you would rather not branch on load():
import { Config, Paph } from '@pixagram/paph3/js'; // synchronous, always available
import { init, Paph } from '@pixagram/paph3/wasm'; // await init() once firstOpen demo/paph3-playground.html by double-clicking it. It is one self-contained file — no server, no network, no upload. Drop two works, or drop one and derive the second with any of eleven attacks, and watch every channel argue its case.
| attack | caught by | notes |
|---|---|---|
| recolour, tone curve | structure | absolute RGB is discarded at ingest |
| palette rebuilt | structure | quantile endpoints survive it; rank endpoints do not, and that asymmetry is itself evidence |
| nearest-neighbour upscale | front end | detected exactly and divided out before hashing |
| resample / rescale | geometry | recovered scale is reported |
| mirror | both | free — a bit permutation of the stored descriptor, no second wire |
| rotate 90/180/270, transpose | both | all eight D4 symmetries are bit operations on the stored code |
| crop | both | region selection is purely local, so a crop keeps its selections |
| figure lifted into another scene | geometry | the case the keypoint half exists for |
| luminance inversion | structure only | see Known gaps |
Two works from the same generator — same tileset, same palette family, same dither — are the hard negative. They share run-length and palette statistics and score around 6200 structurally, while geometry reads 0. The lattice caps them at Suspected and refuses to certify. That single row is the argument for two axes instead of one weighted sum.
Structure and geometry measure different things and fail differently. Averaging them lets a strong structural reading drag a silent geometric one over the line, which is exactly the failure a same-tileset impostor produces.
structural →
weak moderate strong
geo strong geometry crop / CERTIFIED
alone collage
geo weak unrelated suspected structure alone
geo abstains unrelated suspected structure alone
An outer band can certify on its own, but only past a raised bar — 15 geometric inliers, or 1.5× the structural threshold. The corners are not symmetric because the evidence is not.
Each channel reports four things:
| field | meaning |
|---|---|
raw |
the measurement |
control |
what that same measurement scores against a deliberately wrong answer |
value |
what survives the control, rescaled to 0..10000 |
measurable |
false means the channel abstained and contributed nothing |
Abstention is load-bearing. A channel that cannot evaluate returns nothing rather than a number that happens to read as favourable — a silhouette channel has no opinion about a sprite that was composited onto a host, and saying so is more useful than saying zero.
The nulls are not decorative. The local channel's control re-matches against bit-rotated copies of the same codes: same statistics, cannot be the same regions. The geometric channel's control keeps the correspondence set exactly as matched and permutes only which keypoint's geometry each one pairs with — asking precisely "would these matches have agreed on one placement by chance?" rather than the already-answered question of whether they matched at all.
| channel | weight | what it reads |
|---|---|---|
local |
35 | 128 dihedrally-canonical 64-bit region fingerprints — the collage channel |
shape |
25 | radial signatures of up to 8 connected regions over quantile bands |
topology |
15 | sparse region-adjacency graph, both endpoint encodings stored |
runs |
10 | run-length geometry along H, V and the diagonal — stroke texture, no colour in it |
dct |
10 | hierarchical DCT at three scales, best of sixteen symmetries |
palette |
5 | identity palette, absolute RGB discarded |
silhouette |
10 | outline shape — very strong when present, silent when not |
geometric |
— | its own axis: FAST-9 + steered BRIEF-256, Hough vote, fixed-point similarity refit |
| size | contents | |
|---|---|---|
| Tier 1 | exactly 3952 B | fixed layout, constant offsets, explicit section table, CRC-32 |
| Tier 2 | 32 + 40n B, n ≤ 256 | keypoint records, content-addressed to Tier 1 by CRC |
offset len section
0 64 header — magic, version, flags, dimensions, 11×4 section table, CRC-32
64 256 dct hierarchical DCT, sign + Gray-coded magnitude
320 8 brightness tonal record, kept out of the DCT index bucket
328 96 palette identity palette, 24 × 4 B
424 288 rag sparse adjacency, 48 × 6 B, quantile AND rank endpoints
712 328 shapes 8 × 41 B radial signatures
1040 48 runs run-length histograms, 3 axes × 16 bins
1088 1024 local 128 × 64-bit region fingerprints
2112 512 anchors 128 × 4 B aspect-true positions
2624 96 silhouette outline signature, its own channel
2720 80 colour absolute RGB — REPORTING ONLY, never scored
2800 1152 sketch 32 × 36 B keypoints, so Tier 1 alone can still do geometry
────────────
3952
Three details worth knowing:
- The section table is on the wire, not compiled into constants. This family has already paid once for a stale offset after a section was inserted.
- CRC-32, not XOR. An XOR cannot detect a transposition of two bytes, which is exactly the corruption a byte-range index introduces.
- The
coloursection must not move a verdict. Recolour invariance is load-bearing and dies the moment absolute RGB enters the scoring path. The test suite asserts that zeroing those 80 bytes changes nothing. It exists so a moderation report can say identical palette rather than rebuilt palette, which is a materially different case to argue.
A Config is frozen on construction. Derive rather than mutate:
const strict = engine.with({ scoring: 'gate', hammingT: 4 });
const cfg = new Config({ geoEps: 900 }); // throws on unknown keys and bad ranges
engine.config; // frozen, safe to pass aroundfoldMatte, matteTol, divideUpscale, peakRadius, foldInvert, localWindows, localCount, kpCount, sketchCount.
This is the entire argument for v3's larger byte budget. In v2 every one of these was baked in at hash time, so getting one wrong meant re-hashing the corpus to find out. Here they are pure compare-time choices, and the demo's knob panel re-decides instantly without touching a single stored byte.
| knob | default | what it decides |
|---|---|---|
scoring |
weighted |
gate takes the weakest of evidence and corroboration, and refuses to certify on one channel |
evidence |
lift |
lift is purity × confidence; proportion is share of achievable match. Both are always computed and both reported |
ragEndpoint |
rank |
quantile survives a rebuilt palette, rank does not — so rank agreement is the stronger finding when it occurs |
hammingT |
8 |
fingerprint collision radius, 0–64 |
confidenceAt |
16 |
collisions needed for full local confidence |
geoConfAt |
16 |
inliers needed for full geometric confidence |
geoEps |
1600 |
inlier tolerance, in units of 1/65535 of the frame |
geoMinCorr |
8 |
correspondences below which geometry abstains |
mirrorHypothesis |
true |
free — a bit permutation of the stored descriptor |
A caution about the defaults. They come from an operating point set by hand on the demo bench, and they make every assertion in the harness pass — but two of those cases did not become correct, they became reachable.
hammingT: 8roughly doubles the collision radius andweightedremoves the structural refusal thatgateenforces. The same two changes moved an unrelated-sprite pair from 833 to 3130, which is over theSuspectedline. Reverting costs one line and no re-hash. Calibrate on real works before automating anything on these numbers.
There is no float anywhere in the wire path. npm run tables enforces it, and fails the build if a Math.cos, Math.round, Math.log2 or Math.pow appears in the engine source. Every trig, ray and DCT-basis table is a frozen integer literal, because V8's Math.cos and Rust's f64::cos may disagree in the last ulp, and after round(x · 32768) that is a table entry off by one — in an artefact two independent nodes are supposed to agree on byte for byte.
Porting to Rust exposed four hazards that were invisible from inside JavaScript. All four are fixed in both engines.
The BRIEF pattern's LCG was not exact. s * 1103515245 reaches 2.4 × 10¹⁸, far past the 2⁵³ a double holds exactly, so the low bits of every pattern coordinate were rounding noise. IEEE-754 is exact per operation, so this was perfectly deterministic inside V8 — and therefore untestable from JavaScript alone — while being unreproducible in integer arithmetic anywhere else. Now Math.imul on both sides.
(3 * M) >> 1 depends on int32 coercion. JavaScript coerces before shifting, so 6442450941 wraps to 2147483645 first and the result is 1073741822, not 3221225470. The Rust reproduces the wrap explicitly rather than the arithmetic.
(sq * rx) >> 16 in the Hough vote was silently wrapping. That product reaches ~10¹², and r00 * ax passes 2³¹ once the recovered scale exceeds about 4×. This one changed results: it was the last divergence between the two engines, and fixing it moved an inverted pair from 7 inliers to 8. Both now floor-divide exactly.
The sparse-RAG key is a signed int32, because (qlo << 24) overflows into the sign bit. The Rust uses i32 keys so the tie-break sort matches.
One more, caught by the parity harness on its first run: the frozen DCT basis was generated at Q = 12 when the engine uses Q = 14. A single wrong scale factor in a table, invisible to every test that only compared the engine to itself. That is the argument for having a second implementation, in one sentence.
The mirror trick in §8.6 gives mirror invariance for zero stored bytes, and it rests on sector(mirror) === (32 − sector) & 63 holding exactly. It fails in precisely one case: an argmax tie, where the two tied sectors are adjacent and "lowest index" is not preserved by k → 32 − k. Rather than invent an asymmetric tie-break that quietly breaks mirroring, such a keypoint is refused. Measured rate on real content: 0.9%. Verified 2949 / 2949 exact on the rest.
Resolve the fastest available backend. opts.prefer is 'wasm' (default) or 'js'; opts.wasm optionally supplies the module bytes. The JavaScript fallback is not a degraded mode — same bytes, same verdicts, only slower.
Frozen. new Config(overrides?), .with(overrides), .toJSON(), Config.defaults(), Config.validate(obj). Unknown keys and out-of-range values throw.
new Paph(config?) // Config instance or a plain overrides object
engine.hash(image) // → Fingerprint { t1, t2, kpCount, crc, width, height }
engine.compare(a, b) // → Verdict
engine.with(overrides) // → a new engine; nothing is mutated
engine.backend // 'js' | 'wasm'
engine.config // frozencompare(x, y) is guaranteed equal to compare(y, x). The two wires are sorted into a canonical order once inside compare, and the directional outputs are swapped back — rather than fixing each asymmetric site separately, which is a promise that has to be re-kept every time a channel is added. v2 broke it in nine verdicts.
hash(image, opts?), compare(a, b, opts?), parseT1(bytes), parseT2(bytes), plus SECTIONS, SECTION_OFFSETS, THRESHOLDS, DEFAULT_CONFIG, T1_BYTES.
Full types in index.d.ts.
src/paph3.cjs the JavaScript engine (UMD — also drops into a <script> tag)
src/paph3.js ESM view of the same module
wasm/paph3.wasm 318 kB, built from rust/
wasm/paph3-wasm.js hand-written glue over a five-function C ABI
rust/ the Rust crate — zero dependencies, on purpose
src/tables.rs frozen integer tables shared with the JS
src/front.rs matte fold, upscale division, palette indexing
src/sections.rs all eleven Tier-1 section builders
src/keypoints.rs FAST-9, orientation, steered BRIEF, spatial quota
src/wire.rs serialisation, CRC, hash entry point
src/compare.rs channels, Hough vote, nulls, verdict lattice
src/lib.rs the C ABI surface
src/bin/paphcli.rs stdin/stdout harness used by the parity tests
test/test.cjs 39 property assertions
test/parity.mjs 25 cross-implementation assertions
tools/tables.cjs regenerate the frozen tables; assert no float reaches the wire
demo/ the self-contained evidence bench
docs/ SPEC-002, SPEC-003, and the implementation notes
The WebAssembly is deliberately not built with wasm-bindgen or wasm-pack. The export surface is five C functions and the JavaScript glue is written by hand next to it, because a consensus artefact should not have a code generator sitting between its source and its binary — and the binary is the thing two nodes have to agree about. The whole boundary contract fits on one screen.
The Rust crate has zero dependencies for the same reason: every dependency is another way for two parties to disagree.
npm test |
39 property assertions — reflection closure, determinism, wire integrity, argument-order symmetry, the abstention contract, a transform battery, timing budgets |
npm run parity |
JavaScript vs WebAssembly — byte parity on both tiers, verdict parity, channel-by-channel agreement, config-surface agreement |
npm run tables |
regenerate the frozen tables, check the reflection identities, assert no transcendental reaches the wire |
npm run build:wasm |
rebuild wasm/paph3.wasm (needs rustup target add wasm32-unknown-unknown) |
The build is reproducible: rebuilding from the shipped rust/ source produces a
binary with the same checksum as the one in wasm/, and npm run parity passes
against it unchanged. The crate compiles clean — no warnings.
Single thread, node 22, 512 × 384:
| hash | compare | |
|---|---|---|
| JavaScript | ~400 ms | ~5.8 ms |
| WebAssembly | ~139 ms | ~2 ms |
Budgets in SPEC-003 §13 are 1600 ms and 25 ms.
The transform battery, on a synthetic sprite:
| case | verdict | structural | geometric | inliers |
|---|---|---|---|---|
| identical | Identical | 10000 | 10000 | 256 |
| 4× nearest upscale | Copy | 9974 | 10000 | 256 |
| palette rebuilt | Copy | 9804 | 10000 | 41 |
| mirrored | Copy | 8798 | 10000 | 166 |
| cropped 70% | Copy | 7946 | 10000 | 37 |
| rotated 90° | Copy | 7849 | 10000 | 157 |
| recoloured | Copy | 6770 | 10000 | 185 |
| pasted, then cropped | Copy | 4879 | 10000 | 64 |
| same-generator scenes | Suspected | 6215 | 0 | 0 |
| inverted | Suspected | 5616 | 0 | 0 |
| pasted into a scene | Suspected | 4202 | 0 | 0 |
| unrelated sprites | Suspected | 3130 | 0 | 0 |
| sprite vs scene | Unrelated | 1390 | 0 | 0 |
Read the bottom half as carefully as the top. unrelated sprites at 3130 is over the Suspected line, and that is a consequence of the current defaults rather than of the engine finding something. These are synthetic fixtures; the numbers that matter come from real works.
Both are honest limits, stated here because a detector that hides them is worse than one that does not have them.
Geometric inversion invariance is not free, and is not implemented. Inverting a work complements every BRIEF bit and rotates the orientation by 180°, so the steered pattern samples the other side of the keypoint. Measured mean Hamming distance between a descriptor and the complement of its inverted twin, over 255 co-located keypoints: 129.9 of 256 — chance. A working version needs a centrally symmetric pattern: 64 independent pairs spanning four quadrants instead of 128 spanning two, trading descriptor distinctiveness for the invariance. That is a measurable choice, not a free one, and it has not been made. Inversion is carried by the DCT sign flip, the palette quantile reflection and the local complement fold — all of which do hold exactly.
The thresholds come from 16 works, not from moderation reports. The structural side was derived from real platform art rather than fixtures, which is more than v2 could say. The geometric side is a proposal. Neither should be automated on until they have been re-derived against real appeals — and the ground truth those 16 works carry is still inferred, with one label already proved wrong by a measurement.
| document | what it is |
|---|---|
docs/SPEC-003-paph-v3.md |
the v3 specification this implements |
docs/IMPL-003-implementation-notes.md |
errata against SPEC-003, measured results, and the determinism defects the Rust port exposed |
docs/SPEC-002-paph-v2.md |
descriptive specification of the two v2 algorithms this merges |
Where the implementation and SPEC-003 disagree, the implementation notes say so and explain why. Four corrections are material: the reflection-closed pattern is y-negated rather than x-mirrored; the Hough scale must come from level dimensions rather than level indices; KP_MARGIN is 24; and single-channel certification needs a corroboration floor.
MIT. Copyright © 2026 Pixagram SA, Zug, Switzerland.