pixa-image-hash-wasm
v3.4.1
Published
The reference pixa-image-hash Rust implementation compiled to WebAssembly (PXH1 v5). Byte-identical output, 4-5x faster than the pure-JS engine, same report shape. Ships the built .wasm, a dependency-free loader, and the source to rebuild it.
Maintainers
Readme
pixa-image-hash
A structured perceptual hash and forensic differ for palette-based pixel art.

Most perceptual hashes answer one question — how far apart are these two images? — with one number. This library answers a better one: what happened between them? Feed it two rasters and it returns a verdict — Recolored, Rescaled, LocallyEdited, BackgroundSwapped, EmbeddedElement, Unrelated, seventeen classes in all — with a confidence, per-channel similarity scores, and the structured evidence behind the call. The hash itself is a compact, parameterizable wire format — the default profile puts a 128×96 sprite at 1.8 KB and a 506×324 painting at 13.4 KB, with compact/micro profiles down to a third of that — and any two profiles remain comparable. Every comparison reads only the hashes, never the pixels.
Written in no_std-friendly, dependency-light Rust; ships as a native crate and a ~192 KB WebAssembly module with a plain C ABI. The same bytes come out of the native build, the wasm build, and the JavaScript mirrors — byte-identical, by construction and by test.
Why it exists
Pixagram is a social network and marketplace for pixel art. On such a platform, the expensive question is not "have we seen this exact file?" — trivial — but "is this upload a re-export, a recolor, a rescale, a background swap, or a copymint of something already published?" Deduplication and provenance need an instrument that recognizes edits, names them, and does so cheaply enough to run on every upload.
Classical perceptual hashes (pHash, dHash, aHash and kin) were built for photographs — DCT coefficients and gradient statistics over smooth natural images. Pixel art breaks their assumptions from the first premise: it is flat color fields and hard edges, where a palette of twelve colors is a first-class object, a one-pixel outline is intentional, and a nearest-neighbour 2× export is the same artwork. A photographic hash sees noise where a pixel artist sees decisions.
pixa-image-hash starts from the medium's own grammar — palettes, indices, tiles — and builds three tiers of answer on top of one hash:
- Content key — a 32-byte SHA-256 identity that survives integer-scale re-exports. Exact-match lookup, O(1).
- Index code — a 256-bit code under Hamming distance, cheap enough for ANN indexes. Candidate retrieval at radius R = 48.
- Diagnose — the full forensic report: what changed, where, how much, and what to call it.
One hash per image, three price points of question.
How it works
hash_rgba8 decomposes an image into independent, individually comparable layers — the format is PXH1 v7:
| Layer | Contents | Size | |---|---|---| | Header | dims, source dims, detected integer scale (provenance), structure params u16 | 29 B | | Palette | ≤256 RGBA colors + populations, luminance-ranked | 5 B / color | | Tiles (T×T px, T ∈ 8/16/32/64) | packed bit-stream: exact FNV fold (0/16/32 b) + 16 average-rank cells at the palette's bit width + 12 b edge densities + optional 6 b entropy; a uniform tile is 9 bits (flag + palette index — everything else is reconstructed exactly at decode) | ~6–13 B / tile (T16), 9 b uniform | | Supertiles (2T×2T px) | average color, Gray-coded per channel | 4 B each | | Global grids (16×16 cells) | resolution-independent rank / color / edge fields + row & column profiles | 1,152 B fixed | | Contour (v5) | 32×32 bitmap from an all-integer Canny, 4 % edge budget | ~140 B | | Contour detail (v6) | the same Canny at a fixed source rate — finest ladder step (1, 2, 3, 4, 6, 8, … 256) whose grid fits 4 096 cells; aspect-true, self-describing | ≤ 514 B (typ. 200–510) |
The header's params word (bits 0–1: log₂(tile/8) · bits 2–3: exact-width code · bit 4: entropy · 5–15 reserved) selects a structure profile; everything below the tile block is parameter-free, so any two hashes stay comparable no matter how they were taken.
This build emits micro and nothing else (EMITTED_PARAMS). Every other profile still decodes and compares — archives and third-party wires keep working — but hash_rgba8 produces T64 / no exact digest / no entropy, and asking for another profile returns ParamsDisabled (wasm error 7). The reasoning is the whole arc of this library: what actually decides verdicts on real artwork — palette, global planes, profiles, contour, the fixed-rate detail map, the containment search and its plane corroboration — is parameter-free and runs at full strength here.
| Profile | Params | scene 480×240 | real art 506×324 | Status |
|---|---|---|---|---|
| micro | T64 · no exact · no entropy | 1,951 B | 2,654 B | emitted |
| compact | T32 · exact 16 | 2,607 B | 4,851 B | decode / compare only |
| default | T16 · exact 16 · entropy | 4,864 B | 13,416 B | decode / compare only |
| rich | T16 · exact 32 · entropy | 5,454 B | 14,728 B | decode / compare only |
(The same images under the old v6 wire: 12,567 B and 18,110 B — so the emitted profile is 85 % smaller on real art.)
What micro costs, stated plainly
The structure layer is a 64-px tile grid, and the honest consequences are pinned as tests (micro_granularity_is_the_documented_floor):
- A single-pixel edit is invisible to the structure layer. One feature cell averages 16×16 px; one pixel cannot move it. The edit is still caught by
content_key(the exact-duplicate tier), so dedup is unaffected — butLocallyEditedwill not fire on it. - Edit localization resolves to 64 px. A block edit is located to its tile; the reported bbox is in 64-px units.
- A canvas ≤ 64 px is a single tile — no structure grid at all. 64×64 sprites are carried entirely by palette, global planes, contour and detail.
Identicalmoves onto the content key. Without per-tile digests, tile equality is only feature-level — too weak for an identity claim. The verdict now accepts content-key equality instead, which is strictly stronger (SHA-256 over every stored layer, provenance spliced out). Without that route a micro hash compared with itself readRerendered.- Migration is a re-hash, not a mixed comparison. A v6 or
defaultarchive hash compared against a fresh micro hash of the same image pools instead of folding: overall > 0.95, but notIdentical. IfIdenticalmatters to your pipeline, re-hash the archive rather than mixing profiles.
Re-enabling the full range is one line: pub const EMITTED_PARAMS: Params = Params::DEFAULT;. Cross-width exact matching works through a 16-bit XOR fold — a rich/v6 digest folded to 16 bits still exact-matches a default digest, so a v6 archive and fresh v7 hashes diagnose Identical without re-hashing. Cross-tile-size pairs compare through pixel-area-weighted pooling of the finer grid onto the coarser one; the result is flagged structure_pooled (pooling cannot see fine-tile rounding or the seams between fine tiles), the exact channel is unavailable, and confidence takes a small haircut — but changed-tile localization, all global layers, contour, detail, and containment run at full strength.
Two properties do most of the work at profiles with a fine tile grid. Locality: edit one corner and only that corner's tiles change — the rest of the hash is untouched, so the differ can draw a bounding box around the edit. Scale normalization: integer nearest-neighbour upscales are detected and normalized away before hashing, with the provenance recorded separately — which is precisely what lets the content key splice provenance out and give a 1× original and its 2× export the same identity.
Comparison never touches pixels. Same-size, same-tile-size pairs compare tile against tile; same-size pairs with different tile params pool the finer grid onto the coarser (flagged); cross-size pairs fall back to the resolution-independent global layers. Since 3.0.0, the structure and color channels score un-Gray-coded L1 — Gray-coded Hamming guarantees that adjacent values differ by one bit, but not the converse (gray(0) and gray(255) also differ by one bit), so large jumps were systematically under-counted — the exact direction an adversary would push. On the old metric, a half-canvas near-black↔white flip scored 0.91 color similarity; on the new one, 0.65. Storage stays Gray-coded; only scoring changed, so existing hashes remain valid bytes.
Containment
The detail layer exists to answer a question the whole-image verdict cannot:
is the smaller artwork geometrically inside the larger one? Because the
map is stored at a known px-per-cell rate, an element pasted 1:1 into a
larger canvas keeps its cell geometry — when both images fall in the same
rate bracket, the paste rung of the search is a pure translation slide;
outside it, a dense σ certification band (23/32 … 37/32 of the
source-true ratio, integer rungs) brackets the scale — the true basin is
only ±1 rung wide, which the old geometric ladder could straddle — with
geometric descent/ascent beyond and a multi-start hill climb over the
top basins. diagnose reports it as contourContainment, independent of
the verdict (a pasted element inside unrelated clutter is supposed to
read Unrelated at whole-image level — the containment field is where it
shows).
Since 3.3.0 the score is null-subtracted: score = raw − 1.5 × ctl,
where raw is the density-chance-corrected precision of the winning
window and ctl is an empirical null — the same guest torus-rolled
(twice, at incommensurate offsets) and x-mirrored, each control granted
the same ±3-cell registration freedom the real guest enjoyed, and the
minimum of the two families taken. The control experiments that forced
this design are worth stating: no window-level density statistic separates
a true paste from a dense periodic impostor (a plaid patch in clutter
reaches precision 0.911 where the true paste sits at 0.909) — only
geometry destruction does. A real paste survives its nulls; a window that
merely explains everything scores just as well against a rolled or
mirrored guest, and the subtraction erases it.
Measured battery (13 cases, Rust ≡ JS to 4 decimals): 1:1 same-bracket
paste 0.78, cross-bracket paste 0.66, re-rendered element
0.72, literal same-raster crop 0.58 (honest straight-stroke
ridges survive a roll, so even clean guests pay a small control tax), a
1.10× nearest-neighbour paste 0.75 with the scale recovered to ±0.05,
σ-sweep 0.65/0.80/0.95 → 0.54/0.52/0.72; the plaid-element host is
rejected at 0.14, pure clutter at 0.05, and corruption of the pasted
element degrades monotonically. Certification takes either of two routes (3.3.2): edge score
≥ 0.40, or edge score ≥ 0.20 corroborated by the global rank
field at ≥ 0.55 — see below; either way a certified pair whose whole
images are not versions of each other lands the EmbeddedElement
verdict. Between the routes, 0.15–0.40 uncorroborated stays the review
band. The report carries the full instrument panel — raw, ctl,
loc (peak-vs-sidelobe, diagnostic only — honest long strokes break
it), dilated, planeAgreement, precision over chance, recovered
scale, and the window in host detail cells.
The second channel: what the global planes are for
The edge score has a hard ceiling that no amount of tuning removes. Measured on the fixture corpus: an element re-rendered inside a host scores 0.26, and an unrelated element occupying the same coarse layout scores 0.265. Identical evidence, opposite truths. Anything that certifies the first certifies the second — the edge maps cannot tell them apart, because at stroke level a re-execution genuinely disagrees with the original.
So the report asks a second, independent question at the window the
search found: do the global planes agree there? Three fields are
sampled on the resolution-independent 16×16 grid — per-cell luminance
rank, edge density, and average colour — each |Pearson| over
8×8 matched samples, and the reported plane_agreement is their
median. Two of three must agree.
Two design points, both forced by measurement:
Magnitude, not sign. Every plane is relative to its own image, so a colour-inverted guest — or a transparent guest pasted onto an opaque host — anti-correlates at full strength (measured −0.787 on the 1:1 paste). Scoring the magnitude makes the channel tone-robust by construction: for a two-tone element, inversion is bit-exact invariant; for a multi-colour element on transparency it shifts slightly as the palette re-ranks against its transparent entry, and in the measured case the evidence got stronger (rank 0.51 → 0.76), never weaker. A non-monotone re-tone — the palette rotated so the luminance order genuinely changes — still corroborates at 0.60.
Median, not minimum. The three fields fail differently, and that is exactly what makes them useful together. A genuine re-render disturbs the edge field (measured 0.20) while rank and colour hold at 0.71/0.77; a re-tone weakens rank and colour while the tone-blind edge field holds. Requiring all three would reject true re-renders; trusting one lets a re-toned element slip through the single channel its re-toning weakened. The median tolerates one dissenter and still demands a real consensus — an impostor would have to fool two independent fields at once, which none of the controls manages.
| Case | edge score | rank | edges | colour | median | verdict |
|---|---|---|---|---|---|---|
| Production pair, re-rendered at 0.96× | 0.262 | 0.739 | 0.595 | 0.761 | 0.739 | EmbeddedElement ✔ |
| Same scene re-toned (blue/white re-render) | 0.232 | 0.531 | 0.541 | 0.531 | 0.531 | EmbeddedElement ✔ |
| 1:1 paste | 0.497 | 0.787 | 0.564 | 0.837 | 0.787 | EmbeddedElement ✔ |
| Same paste, guest colour-inverted | 0.514 | 0.787 | 0.564 | 0.837 | 0.787 | EmbeddedElement ✔ |
| Re-rendered element (edge field dissents) | 0.369 | 0.709 | 0.200 | 0.766 | 0.709 | EmbeddedElement ✔ |
| Unrelated element, same coarse layout | 0.265 | 0.039 | 0.311 | 0.107 | 0.107 | Unrelated ✔ |
| Solid silhouette mimicking the outline | 0.376 | 0.000 | 0.000 | 0.000 | 0.000 | Unrelated ✔ |
| Dense plaid impostor | 0.140 | 0.077 | 0.070 | 0.076 | 0.076 | Unrelated ✔ |
| Pure clutter, no element | 0.045 | 0.052 | 0.147 | 0.000 | 0.052 | Unrelated ✔ |
Neither channel certifies alone; the conjunction does. The weakest true case sits at 0.531 and the strongest impostor at 0.107, so the 0.45 threshold falls in a wide gap — roughly 5× above the worst false positive. A flat sampled field has no opinion and reports 0.0, which is what refuses the solid silhouette despite its 0.376 on edge geometry alone.
This is the answer to "do the global planes earn their 1,024 bytes?"
They are 43–47 % of a micro hash of real artwork, and they buy four
things: the rank plane is the 256-bit index code (tier-2 candidate
retrieval), the planes are the only layers that score differently-sized
pairs at all, the colour plane drives background-swap detection — and
all three together are the corroboration channel that turns review-band
containment into a certified identification, tone changes included. The
pairs that motivated this work are unresolvable without them.
The Canny front-end that feeds both contour layers neutralizes background pixels to a fixed reference luma so a background swap cannot move the maps. 3.3.1 makes that reference adaptive: content whose own luma crowds the standard reference (measured in production — rgba(127,216,255) line art, integer luma 200, against the reference at 210 left a Δ of 10; the Gaussian pulled every gradient under the hysteresis floor and both maps came out empty) now switches to the dark counterpart, chosen from content-only statistics so both sides of a swap pair still agree, and byte-preserving for every image outside the blind band. An edge map that is empty anyway is now encoded as rate 0 with no detail block instead of shipping dead zero bytes.
The verdicts
diagnose walks a most-specific-first decision tree over the layer evidence:
| Verdict | Meaning |
|---|---|
| Identical | Same normalized raster, bit for bit |
| Rescaled | Same artwork resampled to another resolution |
| Recolored | Index layer untouched; the palette moved (brightness, contrast, tint, reorder) |
| PaletteReduced / PaletteExtended | Colors merged away, or new colors introduced |
| LocallyEdited | Same palette, a bounded region of tiles changed — bbox reported |
| Simplified / Elaborated | Detail density fell or rose while structure held |
| Inverted | Luminance ordering flipped |
| Transformed | Mirror or 180° rotation, detected on the tile lattice |
| Reframed | Crop, pad, or translation — found by profile-shift search |
| BackgroundSwapped | Same artwork; flat background exchanged with transparency (survives resizes) |
| Rerendered | Same composition re-executed as a different raster — the v5 contour layer's call |
| Restyled | Same arrangement, wholly different palette |
| EmbeddedElement | Not versions of each other, but the smaller content sits inside the larger at a recoverable scale — the containment search certified (score ≥ 0.40): a pasted element, a crop reposted as its own image, an extracted sticker |
| Derivative | Related, but nothing more specific fires |
| Unrelated | No layer ties the two together |
Each verdict carries a confidence derived from its margin over the deciding threshold, plus the raw evidence — palette diff, changed-tile list, background-detector table, contour F1, and the containment block — so a moderation pipeline can apply its own judgment rather than trust a scalar.
Quick start
Rust
use pixa_image_hash::{hash_rgba8, hash_rgba8_params, compare, diagnose, index_distance, EMITTED_PARAMS, Params, PixaHash};
let a = hash_rgba8(w, h, &rgba_a)?; // v7 MICRO profile; TooManyColors above 256
let b = hash_rgba8(w, h, &rgba_b)?;
// This build emits MICRO only; other profiles are refused at the encoder
assert_eq!(EMITTED_PARAMS, Params::MICRO);
assert!(hash_rgba8_params(w, h, &rgba_a, Params::COMPACT).is_err()); // ParamsDisabled
// …but a wire that arrives at another profile still decodes and compares
let foreign = PixaHash::from_bytes(&archive_bytes)?;
let s = compare(&a, &foreign); // s.structure_pooled flags the pooling
// …and the old wire is still writable for pre-v7 consumers:
let legacy = pixa_image_hash::hash_rgba8_v6(w, h, &rgba_a)?; // byte-frozen v6 layout
// Tier 1 — identity that survives integer-scale re-exports
if a.content_key() == b.content_key() { /* same artwork, exact tier */ }
// Tier 2 — cheap candidate retrieval
if index_distance(&a.index_code(), &b.index_code()) <= 48 { /* near-duplicate candidate */ }
// Tier 3 — the arbiter
let s = compare(&a, &b); // palette / structure / color / shape / overall
let report = diagnose(&a, &b); // verdict + confidence + evidence
println!("{:?} at {:.2}", report.verdict, report.confidence);
// Element containment, independent of the verdict
let cc = &report.contour_containment;
if cc.present && cc.score >= 0.40 {
println!("contains the smaller artwork at {:.2}x, score {:.2}{}",
cc.scale, cc.score, if cc.detail { "" } else { " (v5 fallback)" });
}
// Storage round-trip
let wire: Vec<u8> = a.to_bytes();
let back = PixaHash::from_bytes(&wire)?; // hardened: forged headers are rejected cheaplyDefault channel weights are palette 0.18 · structure 0.40 · color 0.30 · shape 0.12; compare_weighted accepts your own.
Browser / WebAssembly
The wasm module exports a plain C ABI — fourteen functions, no bindgen, no imports — and ships with a UMD loader (pxh_wasm.js) that wraps them:
const { instance } = await WebAssembly.instantiate(wasmBytes, {});
const ex = instance.exports;
const mem = () => new Uint8Array(ex.memory.buffer);
function hash(w, h, rgba) {
const p = ex.pxh_alloc(rgba.length);
mem().set(rgba, p);
const lenPtr = ex.pxh_alloc(4);
const out = ex.pxh_hash(p, rgba.length, w, h, lenPtr);
const len = new DataView(ex.memory.buffer).getUint32(lenPtr, true);
const bytes = mem().slice(out, out + len);
ex.pxh_free(out, len); ex.pxh_free(lenPtr, 4); ex.pxh_free(p, rgba.length);
return bytes; // the PXH1 wire format
}pxh_hash_params takes the header u16 for explicit profiles; pxh_compare fills six floats; pxh_diagnose returns the full report as JSON — including both hashes' params, similarity.structurePooled, and the containment diagnostics (raw / ctl / loc / dilated); pxh_content_key, pxh_index_code, and pxh_index_distance expose the dedup tiers; pxh_contour_detail returns the fixed-rate map (rate, grid, bbox, packed bits) for overlays. Through the loader these are hashBytes(w, h, rgba, params?) — the optional argument takes a profile name ("default" / "rich" / "compact" / "micro"), a { tile, exactWidth, entropy } object, or a raw u16, with PARAMS / encodeParams / decodeParams helpers exported alongside — plus compare, diagnose, contentKey, indexCode, indexDistance, and contourDetail. The workbench's source is the canonical, worked example of every call.
The analysis workbench
pixa-image-hash-workbench-3_4_1.html is the whole instrument in one file, running the package's own loader and wasm (pxh_wasm.js + dist/pixa_image_hash.wasm). Serve the package folder (python3 -m http.server — browsers block wasm fetches from file:// pages; the workbench also accepts the .wasm dropped onto it) and it opens the full panel: per-image wire anatomy (every section's bytes, the packed tile stream walked bit-by-bit, uniform-tile counts), palette swatches with populations, global rank/edge heat-maps and row/column profile sparklines, the emitted-profile badge, the verdict with per-channel bars and the pooled/aligned/exact-fold chips, changed-tile overlays, the dedup-tier line, and the containment panel with the complete score decomposition (raw − 1.5 × ctl), the recovered scale, and the found window drawn on the host image and both detail maps. Fourteen preset pairs cover the canonical cases — including the
side-by-side pair that makes the two-channel argument concrete (a
re-rendered element certified at review-band edge score, and a decoy at
the same edge score refused by the plane channel) — including the 1.10× upscaled paste the σ band recovers and the plaid-impostor control the empirical null rejects. A headless self-test (?selftest=1) replays the measured anchors. It supersedes the 3.2.0 lab and experiments pages. The raw report JSON is one <details> away.
A dedup pipeline in three tiers
The intended production shape, in order of cost:
upload → hash_rgba8 → wire bytes stored once
├─ content_key ── exact-match lookup ──────────── same artwork? stop.
├─ index_code ── Hamming ≤ 48 in an ANN index ── candidates? shortlist.
└─ diagnose ── per candidate ───────────────── verdict + confidence → policyThe index tier is deliberately narrow: integer upscales, monotone recolors, and same-palette edits land at distance 0; background swaps, inversions, and rank-permuting recolors land at 100–244 — outside the radius, on purpose. Those transformations are cheap to attempt and expensive to index against, so they are caught where they are cheap to catch: in diagnose, on the shortlist. Calibration on the synthetic corpus placed R = 48 at zero unrelated false admissions; R = 64 admits 3.6 %.
Determinism & guarantees
The hash path is all-integer — no libm, no floating point, entropy and quantile boundaries frozen as constant tables — so native Rust, wasm, and the JavaScript engine mirrors produce byte-identical hashes. This is enforced, not hoped for: the release gate hashes an 87-image corpus through old and new builds side by side and asserts byte equality.
Decoding untrusted hashes is hardened: geometry is validated against the header, allocation is capped by the bytes actually owed, and a forged header claiming absurd reserves is rejected before a single large allocation. MAX_DIM is 16,384; MAX_PALETTE is 256. PXH1 v4 hashes still decode — the contour layer is simply absent and every v5-only feature degrades gracefully.
Measured on the development machine: hashing a 64×64 sprite ≈ 0.35 ms native; a 256×256 ≈ 1.7 ms through wasm in Node. Comparison and diagnosis read only the hashes; the containment search adds up to ~10 ms to diagnose on paste-scale pairs and is skipped when either side lacks the detail layer.
Limitations
Honesty is cheaper than surprise; here is where the edges are.
- It detects raster edits, not concepts. A from-scratch redraw of the same character — new composition, new resolution, new hand — is out of scope for the tile and palette layers. The v5 contour layer covers one important slice of this (
Rerendered: the same composition re-executed, F1* ≥ 0.45), and the containment layer covers another (an element pasted, cropped, or re-placed at a recoverable scale). Two independent renditions of the same character remain out of reach by design: they land in the containment review band, not above the gate, because their fine structure genuinely disagrees. Concept-level similarity is embedding-model territory, and pretending otherwise would only miscalibrate both tools. - Scale normalization is integer-exact. Nearest-neighbour k× re-exports normalize away perfectly; non-integer or resampled rescales are handled by the resolution-independent layers and the
Rescaledgate instead, and heavy antialiasing or blur erodes that signal. - It is a pixel-art instrument. Images with more than 256 unique colors are refused (
HashError::TooManyColors) rather than silently quantized — a photograph should go to a photographic hash. - The index tier is blind by design to background swaps, inversions, and rank-permuting recolors; they are
diagnose's job. Sizing an ANN index on the assumption the code catches everything will disappoint. - Not a cryptographic authenticator. The content key inherits SHA-256's collision resistance over the normalized perceptual encoding, but an adversary who controls the pixels can always craft images that land outside any perceptual threshold. The 3.0.0 scoring change closed the cheapest such gap (the Gray-code under-count); the design goal is raising the price of evasion, not proving provenance.
- 3.0.0 thresholds are synthetic-calibrated. The verdict tree's operating points were placed on a procedural transform corpus and are shipping with that caveat attached. Run your real material through the lab before leaning on the margins; the release notes carry the old→new mapping table so a nudge is a one-liner.
- One frame at a time. Animations are hashed per frame; sequence-level reasoning is the caller's.
Versioning
Current release 3.4.1, format PXH1 v7 (v4–v6 decode; v6 still
writable via hash_rgba8_v6 / compute_legacy_v6, byte-frozen and
golden-locked, for pre-v7 consumers). v7 inserts the params u16 after the
version byte and repacks the tile block as a bit-stream; everything else
keeps the v6 layout. Interop is by construction: the 16-bit exact width
is the XOR fold of the 32-bit digest, so v6 archives exact-match fresh
default hashes (Identical, no re-hash); cross-tile-size pairs pool and
flag. Uniform tiles are stored as one palette index — their exact digest
(FNV seeded with the tile dims, then the index once per pixel), features,
edges, and entropy are reconstructed exactly at decode, so the wire
stays Class-A lossless: encode∘decode is the identity on both the struct
and the bytes, property-tested across every profile. The tile-entropy
quantizer extends past 16×16 tiles with an exact integer log₂ (48-bit
squaring), audited against 60-digit arithmetic for every count ≤ 4096 —
zero disagreements, and the frozen 257-entry v6 table is reproduced
bit-for-bit, so large-tile entropies share the v6 quantization exactly.
The 3.3.0 ABI carries thirteen exports (pxh_hash_params joins), the
diagnose JSON adds params, structurePooled, and the containment
instrument panel, and the release gate asserts wasm↔native SHA-256 byte
identity on the four v7 golden wires through the shipped loader, the
profile sizes, the pooled cross-params report, the containment anchors,
and the frozen real-pair detail maps — 18 checks, plus the workbench's
in-browser self-test.
3.4.1 (compare-side only, no wire change) deepens the corroboration
channel: all three global planes are now sampled instead of the rank
field alone, the consensus is their median, the threshold moves to 0.45,
and the report carries plane_rank / plane_edges / plane_color
alongside plane_agreement so the claim is auditable. This is what lets
a re-toned or colour-inverted embedding certify — the case where an
element appears inside a host in a different palette entirely.
3.4.0 pins the encoder to the micro profile (EMITTED_PARAMS), adds
pxh_emitted_params to the ABI (fourteen exports), routes Identical
through the content key when no exact channel exists, and documents the
64-px granularity floor as a test rather than a footnote. No format
change: the params word, every profile's decoding, and the pooled
cross-parameter comparison are untouched — only production is pinned.
3.3.2 (format-compatible, no wire change) adds the plane-corroboration
channel described above: contour_containment.plane_agreement in the
report, a second certification route for review-band windows, and the
EmbeddedElement verdict now firing on re-rendered and recolored
embeddings that the edge score alone must leave open. The measured
decoy — an unrelated element scoring 0.265 against the production
pair's 0.262 — is pinned as a regression test in both the suite and the
workbench self-test.
3.3.1 (format-compatible) un-blinds the contour front-end for bright
line art via the adaptive neutralization reference, encodes empty edge
maps as rate 0 (decoder accepts), and wires the containment gate into
the verdict tree as EmbeddedElement — certified pastes, crops, and
extracted stickers no longer read Unrelated. The workbench displays
the global-plane heat-maps at the source aspect (the stored grid is
aspect-normalized by design; the display now re-imposes the image's
geometry), fixes the v5-fallback window overlay, and adds the bright
linework preset with two more self-test anchors.
3.3.0 also seals the containment hardening: the score is now
null-subtracted (raw − 1.5 × ctl against torus-roll and mirror
controls with matched registration freedom), the σ certification band
brackets non-1:1 scales densely, and the 13-case battery above is pinned
in both the Rust suite and the JS engine at 4-decimal agreement.
Earlier: 3.2.0 added the fixed-rate detail layer + containment search;
3.1.0 the compare-side containment; 3.0.0 hardened untrusted decoding,
removed floating point from the hash path, added the content-key and
index-code dedup tiers, a byte-identical performance pass, and the
un-Gray-coded L1 scoring recalibration. content_key remains
version-bound, as it was across v4→v5→v6→v7.
License
MIT © 2026 Pixagram SA
