Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@pixagram/paph3

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.


Install

npm install @pixagram/paph3

No dependencies. Node 18+, or any browser with WebAssembly.

Quick start

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 evaluate

hash() 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 first

Try it without writing any code

Open 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.


What a copy has to survive

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.


How a verdict is reached

Two axes, never averaged

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.

Every channel is scored against its own null

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.

The channels

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

The wire

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 colour section 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.

Configuration

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 around

Hash-time — these change the wire

foldMatte, matteTol, divideUpscale, peakRadius, foldInvert, localWindows, localCount, kpCount, sketchCount.

Compare-time — these do not

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: 8 roughly doubles the collision radius and weighted removes the structural refusal that gate enforces. The same two changes moved an unrelated-sprite pair from 833 to 3130, which is over the Suspected line. Reverting costs one line and no re-hash. Calibrate on real works before automating anything on these numbers.


Determinism

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.

Orientation ties are refused, not broken

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.


API

load(opts?) → Promise<{ backend, Config, Paph }>

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.

class Config

Frozen. new Config(overrides?), .with(overrides), .toJSON(), Config.defaults(), Config.validate(obj). Unknown keys and out-of-range values throw.

class Paph

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              // frozen

compare(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.

Functional API

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.


Repository layout

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.


Scripts

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.

Measured

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.


Known gaps

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.


Provenance

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.

Licence

MIT. Copyright © 2026 Pixagram SA, Zug, Switzerland.

About

PAPH v3 — integer-only perceptual hash for pixel-art plagiarism detection. Two backends, byte-identical wires.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages