Layered provenance and forensics for image, video, audio and text. Local-first.
Four independent evidence layers, four separate verdicts, no merged score:
| Layer | Crate | Question |
|---|---|---|
| Manifest | halftone-c2pa |
Is there a valid signed C2PA manifest, and what source type does it declare? |
| Container | halftone-container |
Does the file structure match its claimed origin? |
| Mark | halftone-mark |
Does it carry a watermark we hold a key/decoder for? |
| Blind | halftone-blind |
Does a calibrated classifier flag it — at what FPR? |
cargo install halftone-cli
Installs the ht binary. Not related to the unrelated halftone crate
on crates.io.
ht inspect photo.jpg
ht inspect --json --only manifest,container *.jpg
ht inspect --batch shots/*.jpg # file × source matrix
ht inspect --batch --json shots/*.jpg > run.json # one document, per-file summary rows
ht inspect --report clip.mp4
ht inspect --fingerprints my-writers.json --trust-anchors anchors.pem photo.jpg
ht fingerprint --writer "Canon EOS R5 fw 1.8.1" --class camera --into my-writers.json shots/*.jpg
ht fingerprint --writer "macOS 15 screenshot" --class screenshot --into my-writers.json ~/Desktop/Screenshot*.png
ht sign photo.jpg --key mykey.pem
ht bench corpus.json
ht packs update
| Source | Format | What it reports | Present means |
|---|---|---|---|
jpeg_quant |
JPEG | Annex-K quality (luma + chroma), chroma subsampling, Huffman class (default/optimised), marker inventory, writer fingerprint → DB lookup | libjpeg-family or Adobe re-encode, or a DB match. Encoder path, not authorship. |
jpeg_double |
JPEG (baseline) | Double-quantization comb in luma DCT histograms (own baseline Huffman decoder) | Re-encoded after an earlier JPEG encoding. Blind spots stated in the rationale. |
png_writer |
PNG | Chunk inventory → built-in writer rules (png_rules.rs, each marked verified/documented) + exact-hash DB, iCCP name, pHYs, caBX, generation-parameters text |
Embedded metadata names a generator or carries pipeline parameters. Writer matches are Inconclusive unless the writer is a generator. |
webp_writer |
WebP | Chunk inventory, lossy/lossless, EXIF/XMP | XMP names a generator. |
exif_consistency |
JPEG/PNG/WebP/HEIF | Self-identification, camera-metadata contradictions, EXIF-vs-frame dimensions | Metadata explicitly names a generator. Contradictions are Inconclusive. |
marking_metadata |
JPEG/PNG/WebP | IPTC DigitalSourceType in XMP (JPEG APP1 + ExtendedXMP, PNG iTXt, WebP), value and syntactic form as written; IPTC IIM block from APP13 (originating program, version); whether a C2PA container is present |
XMP self-declares a generative source type (trainedAlgorithmicMedia / compositeWithTrainedAlgorithmicMedia). Unsigned; verifies nothing. Non-generative terms are Absent with the value in details; unknown or conflicting terms are Inconclusive. |
data_mining_reservation |
JPEG/PNG/WebP/HEIF | IPTC / PLUS Data Mining property in XMP (plus:DataMining), value as written and judged against the PLUS vocabulary; the plus:OtherConstraints text |
XMP states a prohibition (DMI-PROHIBITED…): the file's metadata reserves rights against data mining or AI training. Unsigned; verifies nothing and decides no legal question. Allowed / Unspecified are Absent with the value in details; unknown or conflicting terms are Inconclusive. Absence is not permission. |
vendor_ai_flag |
JPEG/PNG/WebP/HEIF | A vendor's own statement that the content is AI-generated, outside the IPTC vocabulary: the yes/no field ContainsAiGeneratedContent (namespace https://canva.com/export) and the credit line Made with Google AI, with prefix, namespace and value |
The writing application says yes. Not the IPTC vocabulary, so marking_metadata does not report it. Unsigned. No is Absent with the value in details; any other value of the field is Inconclusive. Only statements measured on real files are looked for; any other credit line is just a credit line. |
rights_statements |
JPEG/PNG/WebP/HEIF | Copyright notice (EXIF, IIM, dc:rights, PNG text), rights holder and licensor, usage terms and instructions, web statement, Creative Commons licence, credit line and source, PLUS releases, rights expressions: each value quoted with its field |
At least one rights or credit statement is there. Quoted, never interpreted: nothing about who owns anything or whether a notice is valid. Copies of the notice or the credit line that disagree are Inconclusive (a copy cut at IIM's field limit is not a disagreement). Absence is not a waiver. |
embedded_identifiers |
JPEG/PNG/WebP/HEIF | The privacy listing: person names (Artist, By-line, dc:creator, pdf:Author), creator contact details, account and tracking records (Attrib:Ads, the FBMD marker), device serial numbers and owner name, xmpMM document identifiers, GPS position |
The file discloses at least one of these to anyone who receives it. Quoted as written; says nothing about accuracy. Absent is not "identifies nobody": MakerNotes, manifests, thumbnails and pixels are not read. |
None of these carries a calibration yet; the details object exposes every raw
discriminant so ht bench can measure per-signal false-positive rates against a
labelled corpus. The jpeg_double threshold is provisional and says so.
--batch wraps a run in one document (schemas/batch.schema.json, schema 1.4.0):
the full inspections plus a summary row per file with every source's status and the
named columns a marking scan is about — manifest validation_state, issuer, claim
generator, declared digitalSourceType, ingredient count and the actions that carry
a source type; XMP DigitalSourceType, packet count and whether a C2PA container is
present. Rows are projections of the evidence, never a score; the report and the
differential runner read rows, not details. Without --json the same run prints a
numbered file × source matrix.
--metadata adds a listing under each file of the fields those last four sources
found (rights statements, identifiers, vendor AI flags, the data-mining value), each
with its field name and value as written; with --json the same values are in the
sources' details.
Values that can name a person or an account are masked by default, in every output
including JSON: identifier values, and the free text of rights statements (notices,
credits, owners, terms, links). A masked entry keeps its field name, its kind and the
value's length, so a report shows what a file discloses without disclosing it again.
--reveal includes the values. Fixed terms (Marked, release status, a Creative
Commons licence URL), the vendor AI flag and the data-mining term are never masked.
--table prints those named columns, one line per file, aligned for reading;
--tsv prints the same with tabs and a header line:
ht inspect --table --only manifest,container corpus/differential/04-generators/*
ht inspect --tsv a.jpg b.png | cut -f1,2,7
Columns: file · manifest (Trusted / Valid / Invalid / remote / -) · signer ·
declared source type (with its distance when it sits behind the active manifest) ·
ingredients · actions · xmp (the terms, present, none, or n/a when the marking
source did not look at the file).
ht survive answers one question per file and transform: does the marking still
read after this happens to the file?
ht survive corpus/differential/04-generators/google-flow__*.jpeg
ht survive --captured corpus/messaging --keep /tmp/derived --long a.jpg b.png
ht survive --suite none,jpeg_q80,webp --json a.jpg > survive.json
The default suite is identity, JPEG q95/q80/q70, 50% resize, 10% centre crop, PNG
and lossless-WebP round-trips, computed in memory with the image crate: fast and
deterministic, but not libjpeg, so a q80 here is not byte-identical to Photoshop's
q80 (for metadata survival that does not matter — image writes no metadata, so a
synthetic transform is always a clean re-encode, which is what most pipelines do).
Real routes cannot be simulated, so files an application actually produced join the
table via --captured <dir>, named <input stem>__<label>.<ext>; they appear as
captured:<label> columns. Every derivative runs through the same registry as
ht inspect, and the output is a batch document with source and transform on
each row. The aggregate view groups by <generator>__<variant> and marks each cell
M (manifest) / X (XMP field): ✓ kept, ✗ stripped, ! broken (still present, no
longer validates), + gained, · nothing to lose. No quality metric is computed:
metadata survival does not depend on PSNR; the watermark robustness ladder will.
A phone photo is often several files in one: the still image, a gain map, and in a
Motion Photo an MP4 clip. --suite none,multipart adds five byte-level edits of such
files, which re-encode nothing and leave every byte they do not touch as it was:
| transform | edit |
|---|---|
keep_still |
cut right after the still image (gain map and clip go) |
drop_clip |
cut where the motion clip starts |
cut_clip |
cut in the middle of the motion clip |
flip_clip |
flip one bit in the middle of the motion clip |
flip_still |
flip one bit in the still's compressed data |
ht survive --suite none,multipart --keep /tmp/derived corpus/differential/06-phones/*.jpg
The parts are located from the file itself (the first complete JPEG; the container
directory's MotionPhoto item). An edit is left out for an input without the part it
needs, named on stderr, and shown as - in the table.
Layer 1 (c2pa) is implemented behind the c2pa feature: cargo build --features halftone-cli/c2pa.
Present means the manifest validates and the signer chains to a configured trust
anchor (details.validation_state = Trusted); a valid signature from an untrusted signer,
or a broken hard binding, is Inconclusive with the reason in details.validation_status.
The source also reports every digitalSourceType the active manifest declares
(details.digital_source_type, with assertion, action and JSON path per occurrence),
judged against the same IPTC vocabulary as marking_metadata (halftone_core::dst,
details.vocabulary_version). The two readings are deliberately separate: the manifest
copy is signed, the XMP copy is not, and a report shows both.
Inspection is offline by default: a remote manifest reference is not fetched
(reported as Inconclusive with the URL) and OCSP is never consulted.
ht inspect --fetch-remote-manifests opts in to fetching remote-only manifests
(e.g. Adobe Firefly downloads): the file is read offline first, the reference must be
https:// to a host name (no IP literals, no localhost), and each fetch has a 20 s
limit. A fetched manifest is validated like an embedded one, with details.fetched_from
and details.fetched_at and a rationale saying the result depends on that server and
time; the batch row carries manifest.fetched_from. Files with an embedded manifest
never touch the network. survive, bench and the differential harness never fetch.
Do not enable fetching in a service that inspects untrusted uploads: the URL comes from
the file, and a public name can still resolve to a private address.
halftone-pixel is the first crate that decodes pixels (via image); still model-free.
pixel_lattice measures the period-8 residual energy rhythm left by latent-diffusion
decoders — and equally by earlier JPEG compression re-saved losslessly and by
nearest-neighbour 8× upscales, which the rationale names. PNG and lossless WebP only.
It ships dark: threshold: None, verdict always Inconclusive, statistic reported.
Promote it by constructing it with the threshold from its calibration file once
ht bench has produced one on ≥300 stratified real negatives with zero hits.
corpus/
├── real-phone/ # straight off the device
├── real-screenshot/ # your OS screenshots
├── real-messaging/ # same photos after WhatsApp/Telegram
├── gen-sdxl/ # generated locally, native resolution
└── gen-flux/
ht corpus corpus/ --out corpus.json
ht bench corpus.json --fpr 0.01 --stats-out stats.jsonl --calib-dir calib/
bench runs every statistical source (jpeg_double, pixel_lattice, later the mark
and blind layers), prints per-class FPR and per-generator TPR at the threshold, and
writes calib/<source>.calibration.json in the pack Calibration shape — including
fpr_by_real_source, which is the number that decides whether a source may leave dark
mode.
halftone/
├── Cargo.toml workspace
├── crates/
│ ├── halftone-core/ Asset, Evidence, Status, EvidenceSource trait, Registry
│ ├── halftone-c2pa/ layer 1
│ ├── halftone-container/ layer 4: jpeg quant/huffman/double-compression, png, webp, exif
│ ├── halftone-mark/ layer 2: detect + embed (DWT-DCT, AudioSeal via ort)
│ ├── halftone-blind/ layer 3: ort-backed classifiers from signed packs
│ ├── halftone-bench/ eval harness → calibration.json
│ ├── halftone-packs/ signed pack download/verify, offline license
│ ├── halftone-pixel/ pixel-domain model-free statistics (dark until calibrated)
│ ├── halftone-report/ per-file HTML/PDF report
│ └── halftone-cli/ binary `ht`
├── python/
│ ├── export/ PyTorch → ONNX export scripts only
│ └── train/ head training + calibration; never imported by Rust
├── packs/ pack manifests + calibration.json (weights are not committed)
├── schemas/verdict.schema.json semver'd JSON schema for Inspection
├── testdata/ tiny synthetic assets for unit tests
└── .github/workflows/ fmt, clippy, test, bench, pack signing
- Every source records name + version + calibration hash. Old verdicts stay readable.
Absentfrom a blind source means "not flagged at FPR x", never "human".- Text: keyed tests first; blind text is feature-gated, reports
Inconclusivebelow a minimum length, and never emits per-sentence output. - No network except
ht packs update. No telemetry. - Not for education or hiring decisions. See
halftone-report::NOTICE.
The code in this repository is dual-licensed under either the Apache License 2.0 or the MIT License, at your option.
Model packs, calibration sets and fingerprint DB updates are separate works. Eval packs are under the PolyForm Noncommercial License 1.0.0; pro packs are under commercial terms. See COMMERCIAL.md for exactly what is open and what is paid.
Forks are welcome under the licenses above. Please don't call a modified version "Halftone": verdicts from this tool carry a stated false-positive rate, and a fork with different thresholds or models shouldn't be mistaken for it. "Forked from Halftone" is fine.