npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pixagram/paph3

v3.0.1

Published

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

Readme

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