§3 CPNP-1 as this profile executes it
Every selection below is made by services/verd/src/verd/cpnp.py. They are listed because a digest is only reproducible by someone who knows which choices produced it.
3.1 Decode
Pillow's plugin registry decides what is an image. There is no format allowlist at the CPNP layer: whatever Pillow opens and load()s is decoded, and img.format.lower() is recorded as image_format. Anything Pillow refuses — empty input, a truncated header, a text file — raises CpnpError and no Record is produced for it. Undecodable inputs are rejected, per §4 step 1.
3.2 Frame selection, and what "the first frame" means
Multi-frame inputs are decoded at logical index 0 (seek(0)) — never averaged, never the "best" frame, never advanced past. The animated flag is set only when the format is one of gif, webp, png, apng, avif and n_frames > 1; a multi-page TIFF is therefore paged, not animated, and still decodes to page 0.
Errata E7 defines "the first frame" for containers where a frame is not the whole picture, and this profile executes all three of its rules:
- Fully coalesced. Frame 0 is composed onto the container's logical screen at the container's full logical dimensions. **The Canonical Buffer's dimensions are the logical screen's, never the frame rectangle's.**
- The canvas beneath frame 0 is transparent. Every logical-screen pixel frame 0's rectangle does not cover, and every pixel frame 0 marks transparent, is
RGBA = (0, 0, 0, 0)entering §3.5 and is therefore **opaque white** in the Canonical Buffer. A container-declared background colour is not consumed: GIF89a's Logical Screen Descriptor Background Color Index is ignored, as is the Background Color field of a WebPANIMchunk. - Disposal never applies. Disposal describes how a frame is cleared before the next frame is drawn; CPNP-1 never advances past index 0, so no disposal method of any frame is consumed.
Corpus entries:
corpus/gif-animated-2frame.gif— two full-screen frames that differ in every pixel, so a decoder that took frame 1 cannot reproduce the digest.corpus/gif-frame0-subrectangle.gif— a two-frame GIF whose frame 0 is a 40×30 rectangle at offset (12, 9) over a 64×48 logical screen, carrying disposal method 2 (restore-to-background) and a Logical Screen Descriptor background index pointing at a colour table entry that holds magenta(255, 0, 255)and that no pixel of either frame references. Its expected Canonical Buffer is 64×48, its uncovered region is opaque white, and magenta appears nowhere in it. A decoder that hashed the frame rectangle fails on the dimensions; one that painted the container background, or that pre-applied frame 0's disposal, fails on the pixels.
One E7 case this profile does not reach, stated because it is a divergence and not a gap in coverage. Where a GIF's frame 0 is a sub-rectangle and the file declares no transparency index, this toolchain renders the uncovered region as palette index 0 rather than as E7's transparent canvas, so the Canonical Buffer carries that palette colour where E7 requires opaque white. No corpus entry pins that case: a reference corpus must not publish an expected digest for behaviour a normative errata item forbids. It is recorded in §6 and needs a ruling — an implementation fix, or an errata amendment — before release 2 can carry a digest for it either way.
3.3 Orientation — an explicit map, not ImageOps.exif_transpose
EXIF tag 274 is read from img.getexif() and applied by a map that lives in cpnp.py. ImageOps.exif_transpose would do the same thing today and possibly something else after an upstream refactor; a hash that depends on a library default is not a standard.
| Tag 274 | Operation applied, in order | Canonical Buffer dimensions |
|---|---|---|
| 1 | (none — the no-op) | W × H |
| 2 | FLIP_LEFT_RIGHT | W × H |
| 3 | ROTATE_180 | W × H |
| 4 | FLIP_TOP_BOTTOM | W × H |
| 5 | FLIP_LEFT_RIGHT, then ROTATE_90 | H × W |
| 6 | ROTATE_270 | H × W |
| 7 | FLIP_LEFT_RIGHT, then ROTATE_270 | H × W |
| 8 | ROTATE_90 | H × W |
Image.Transpose.ROTATE_90 is counter-clockwise, which is why orientation 6 (the common "camera rotated 90° clockwise") maps to ROTATE_270. Values 5 and 7 are written as mirror-then-rotate rather than TRANSPOSE/TRANSVERSE; they are numerically identical, and the pair is spelled out so the intent is readable.
Out of range is upright. Any value that is not 1–8 — 0, 99, a negative, a string, a corrupt IFD that raises on read — is logged and treated as 1. It is not an error, and it does not reject the asset.
Corpus entries: corpus/jpeg-orientation-1.jpg … -8.jpg (one scene, one encoder setting, differing in tag 274 and nothing else) and corpus/jpeg-orientation-out-of-range.jpg (tag 274 = 99), whose expected digest equals orientation 1's.
Orientation is render-affecting metadata. Removing it moves pixel_hash, and the corpus proves it: all eight orientation entries carry distinct digests. This is errata item E1 / finding THESIS-01, and it is the reason the thesis is stated as "pixel_hash survives stripping of descriptive metadata" rather than of metadata in general. corpus/jpeg-no-exif.jpg is the other half: the same picture with the entire EXIF block never written, sharing orientation 1's digest exactly.
3.4 Colour — ICC → sRGB, fail-closed
An asset carrying an embedded ICC profile is converted to sRGB through littleCMS:
- Rendering intent: relative colorimetric (
1). - Black point compensation: ON. The
BLACKPOINTCOMPENSATIONflag (0x2000) is passed, and no other flag is. This is stated flatly because it is easy to get backwards, and a profile document that named the wrong flag would send every implementer to a digest they cannot reproduce. - Destination profile: littleCMS's built-in sRGB (
ImageCms.createProfile("sRGB")), not a shipped sRGB binary. Note that lcms stamps a synthesised profile with the wall-clock creation date, so the destination profile's own bytes are not constant between runs — only its colorimetry is, and only its colorimetry is used. - Input mode: modes littleCMS consumes directly (
RGB,L,CMYK,LAB) are handed over as-is; anything else is promoted toRGBfirst. - An absent profile is assumed sRGB and converted by nobody. Absent is not broken.
- A present-but-unusable profile fails closed. If the profile cannot be parsed, or no transform to sRGB can be built from it,
CpnpErroris raised and no Canonical Buffer, and therefore no Record, exists for that asset. Errata E8: the untagged-sRGB fallback applies to assets that declare no colour space, never to assets that declare one that cannot be read. Treating an unusable profile as untagged sRGB reinterprets every colour in an Adobe RGB or CMYK-derived image and yields apixel_hashno other implementation reproduces — a false identity, not a weaker one. There is deliberately no field in which a Record could confess this happened.
Corpus entry: corpus/png-srgb-icc.png. Under this toolchain the sRGB → sRGB transform is exactly identity on 8-bit input — measured, not assumed — so that entry shares its digest with the untagged PNG, the WebP and the two TIFFs of the same picture. A CMM that rounded that transform differently would fail this corpus, which is the point of pinning one.
3.5 Alpha — set aside, then composited over white
Alpha is detected as either a real alpha channel ("A" in img.getbands()) or palette/colour-key transparency ("transparency" in img.info). Both count.
The channel is extracted before colour management, because littleCMS does not carry alpha through a transform unless asked and a silently zeroed alpha would composite the whole image to white. After the sRGB conversion the alpha is put back and the image is composited over opaque white (255, 255, 255, 255) in the gamma domain — not in linear light. That is not photometrically correct; it is what every other implementation does, and agreement beats correctness when the output is an identity.
Corpus entries: corpus/png-rgba-alpha.png (a diagonal alpha ramp: one corner fully transparent and therefore pure white in the buffer, the opposite corner fully opaque and therefore untouched) and corpus/gif-palette-transparency.gif (a palette colour key, not an alpha channel, honoured all the same).
3.6 Quantisation — 8 bits per channel, always
The buffer is RGB, 8 bits per channel. Sources deeper than 8 bits (I, I;16, I;16B, I;16L, I;16N, F) are quantised down with a warning, because quantisation changes what the pixel hash means. The final buffer is always [H, W, 3] uint8, C-contiguous; anything else raises.
3.7 No resize. No crop. Ever
The Canonical Buffer is the decoded image at its own dimensions. Resizing is the model processor's business; if it happened here, pixel_hash would encode the input resolution of whichever model version was fashionable that quarter. Orientation may transpose the dimensions (§3.3); nothing else changes them.
3.8 HDR gain maps are flagged, never tone-mapped
The raw bytes are scanned for urn:iso:std:iso:ts:21496:-1, hdrgm:Version and GainMapImage. A match sets hdr_present. Pillow hands back the SDR base image and CPNP-1 hashes that; no tone mapping happens. (No corpus entry — see §6.)
3.9 The digest itself
pixel_hash is SHA-256 over this exact preimage, rendered as 64 lowercase hex characters:
Per errata E10: width and height are decimal ASCII with no leading zeros, no sign, no separators and no whitespace; the trailing bytes are exactly width · height · 3 octets, interleaved R, G, B, top-to-bottom, row-major, with no row padding and no alpha channel — alpha has already been consumed by §3.5. The literal VER1: denotes CPNP-1, and a Record whose pixel_hash was computed with it MUST carry cpnp_version: "1.0"; changing the tag is CPNP-2, which is VER 2.0 (VERSIONING.md §4). content_sha256 in the manifest is the far simpler SHA-256 over the committed file's bytes, taken before anything touches them.
The perceptual digests (pdq/1.0, phash-dct/64) are not part of this profile. They are computed from the Canonical Buffer, so this corpus is a prerequisite for pinning them, but their own parameters and test vectors are errata E22's deferred work for the 1.1 perceptual-algorithm registry.
