Imprinted: invisible, camera-robust data in images
Made by scripts/readme_image.py with the current encoder: kodim23 at default strength, payload Imprinted, then printed and photographed through sim.py's curled-page channel (tilt, curl, mirror, clutter, JPEG).
Imprinted hides a short payload (up to 32 bytes) inside an ordinary picture. The encoded image:
- looks like the original: same size, no border, no QR-style markers, no visible pattern (SSIM ≥ 0.955, mean ΔL* ≤ 1 by default);
- decodes from the pixels alone, not from metadata: from the file, a screenshot, a crop, or a phone photo of a print or a screen, at an angle, in odd light, printed in grayscale, mirrored, gently curled, with clutter around it.
Typical uses: provenance tags, case or session IDs, leak forensics, linking a printed photo back to its record. The payload is plain bytes (checked by CRC-32, not encrypted or signed); bind a signature or ledger entry to it if you need proof.
Status: research prototype. The format and API may still change. Simulated results are below; real printers and phones are the final judge.
How it works
The codec is imprinted/core.py. It only changes lightness (L* in CIE Lab), so color shifts, white balance and grayscale printing don't matter.
Data cells. The image is split into cells of 4×4 sub-blocks (a sub-block prints at about 0.8 mm). Each cell carries one bit as the correlation of its sub-block means with a fixed ±1 pattern. The patterns are orthogonal to flat color and linear ramps, so lighting gradients and printer drift cancel out. The bit is quantized onto a lattice (QIM): the encoder moves each cell only to the nearest lattice point that reads the right bit, so busy texture costs a small bounded nudge instead of a lost cell.
Error correction and tiling. The payload plus CRC-32 is encoded with a rate-½ LDPC code. The codeword fills a T×T-cell tile, and the tile repeats over the whole picture, so any large enough piece decodes. The decoder soft-combines every repeat and runs belief propagation; CRC-32 rejects false reads.
Hidden sync (the invisible finder). Instead of corner markers, four faint cosines are locked to the cell grid. Each shows up as one sharp line in the spectrum, so the decoder can find the grid (spacing, rotation, mirror, perspective) anywhere in a photo. The encoder first notches the picture's own content at those frequencies so texture can't drown them. From a spectral seed the decoder tracks the pilot outward probe by probe, fits perspective and curl, then reads the cells. When a steep tilt smears every spectral line, a grid search tries plausible local grids directly and keeps the ones neighbouring probes confirm.
Strength. A perceptual mask (luminance adaptation, contrast sensitivity at the printed pattern frequency, contrast masking) sets how much each pixel may change; clipped pixels (pure black, white, saturated) are never touched. By default the encoder picks the strongest overall strength δ (up to 2.4) that keeps SSIM ≥ 0.955 and mean |ΔL*| ≤ 0.99. Smooth pictures land near δ 1.2, busy ones near δ 0.8. Pass delta= to force a value.
Measured (simulated)
sim.py --acceptance: 15 cm print, 5 trials, default strength, payload IMPRINT1. Every acceptance row passes.
| image | δ | SSIM | mean ΔL* | clean / turned / mirrored | 60% crops | print easy | gray | hard | curled |
|---|---|---|---|---|---|---|---|---|---|
| kodim23 (smooth) | 1.21 | 0.956 | 0.74 | all | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 |
| kodim05 (busy) | 0.83 | 0.976 | 0.99 | all | 5/5 | 5/5 | 5/5 | – | – |
| kodim08 (busy, periodic) | 0.92 | 0.980 | 0.98 | all | 5/5 | 5/5 | 5/5 | – | – |
hard is tilt up to ~40°, 0.6 camera px per printed px, JPEG 75. sim.py models print (AM halftone, dot gain, grayscale) and photo (3D tilt, curl, cluttered scene, light, white balance, blur, noise, JPEG, mirror). It predicts; confirm on real hardware with the strength ladder page.
Speed (v0.7.1, one desktop CPU, 4 threads): encoding takes about 1.5 s for a 768×512 picture and 25 s for 3000×2000; decoding a clean file takes 2 to 25 s, and a phone photo of a print 10 to 45 s.
Install
Needs Python 3.10+ (tested on 3.12).
pip install imprinted
That installs the imprinted library only (numpy, opencv-python-headless, pillow). For the web UI, simulator and tests, clone the repo:
git clone https://github.com/VocanicZ/Imprinted.git
cd Imprinted
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
On Windows use .venv\Scripts\pip and .venv\Scripts\python.
Use
Python:
import imprinted
imprinted.encode("photo.png", "hello", "photo_imprinted.png") # save as PNG
imprinted.decode("phone_shot.jpg") # -> "hello", or None
encode(image, payload, out=None, *, print_width_cm=15.0, strength=None)takes a path, PIL image or RGB uint8 array, and astrorbytespayload of 1 to 32 bytes. It returns the encoded PIL image (same size and mode) and saves it tooutif given. Setprint_width_cmto how wide the picture will be printed or shown: it sizes the pattern to about 0.8 mm per sub-block.strength=Nonepicks the strongest setting inside the visibility budget. RaisesValueErrorwhen the picture is too small to read back or too clipped to carry the payload.decode(image, raw=False)returns the first payload found as astr, asbyteswithraw=True, orNoneif nothing is found.
The full codec is imprinted.core: core.encode also returns stats (strength, SSIM, mean ΔL*, tiles), core.decode returns every payload found in a photo with its bit error rate and outline, and core.make_ladder builds the strength-test page.
Web UI (from a clone: encode, decode, print a strength ladder; works from a phone on the same network):
.venv/bin/uvicorn web:app --host 0.0.0.0 --port 8765
# open http://<this-pc-ip>:8765
Set the print width when encoding: it picks the sub-block size so the pattern prints at about 0.8 mm. Decode attempts are logged to results/decode_log.csv.
Simulator:
nice .venv/bin/python sim.py --trials 2 photo.png # decode rates by strength x print/photo preset
nice .venv/bin/python sim.py --acceptance # full acceptance table, exits 0 only on PASS
nice .venv/bin/python sim.py --quick # kodim23, 1 trial
--acceptance writes acceptance.json, each encoded image, its ×10 difference view and its strength ladder to results/acceptance.
Repository layout
| path | what |
|---|---|
imprinted/__init__.py |
the library's simple encode / decode |
imprinted/core.py |
the codec: full encode, decode, make_ladder |
pyproject.toml |
pip package (imprinted) |
web.py, web/index.html |
FastAPI web UI |
sim.py |
print/photo simulator and acceptance benchmark |
test_imprinted.py |
test suite |
testdata/ |
Kodak test images (kodim05, 08, 23) |
scripts/ci-local.sh |
local CI gate |
scripts/readme_image.py |
regenerates versus.png |
docs/reports/ |
experiment reports |
imprint.py, utils/, test.py, PROTOTYPE.md |
the earlier color-variant (CVP) prototype with visible corner markers, kept for reference |
Contributing
Bug reports and PRs are welcome. Open PRs against main.
Reporting a bug. Include the input image (or a small one that reproduces it), the exact command or code, the payload, and what you expected vs. what happened. For decode failures, attach the photo and say how it was taken (printer, paper size, phone, distance, angle).
Making a change.
- Branch from
main. - Run the tests that cover your change while you work, then the full gate before you open the PR (below).
- If you touch the codec, run
sim.py --acceptanceand put the table in the PR. A change that makes any row FAIL needs a strong reason. - If the encoder's output changes, regenerate the README figure:
.venv/bin/python scripts/readme_image.py(exits non-zero if it no longer decodes). - Use conventional commit messages (
feat:,fix:,docs:, ...) and reference the issue (closes #N).
There is no GitHub CI: scripts/ci-local.sh is the gate, and it builds .venv itself if missing.
Test loop (rtdd, then ci-local)
The suite is slow (each test encodes and decodes a full image), so don't rerun all of it on every edit. rtdd keeps a per-test coverage map (.rtdd/map.jsonl, committed) and runs only the tests that cover your change:
rtdd which --base origin/main # tests covering your diff + changed lines nothing covers; runs nothing
rtdd run --base origin/main # run just those tests, refresh their map rows
scripts/ci-local.sh # full gate before you push/merge
rtddruns pytest throughscripts/rtdd-pytest.sh(adapter:.rtdd/adapters/python.yaml). It reuses ci-local's.venv, addspytest-cov+pytest-reportlog, and runs undernice -n 10.- An empty selection is not green: the map knows nothing about that change, so run
scripts/ci-local.sh. - Keep
OMP_NUM_THREADS/OPENBLAS_NUM_THREADSat 4 or lower (both scripts default to 4) and run sims undernice, e.g.OMP_NUM_THREADS=4 nice -n 10 .venv/bin/python sim.py --trials 2 photo.png. Unbounded BLAS threads stall everything else on a shared machine. - After adding or renaming tests, re-seed with
rtdd seed(one full instrumented run) and commit.rtdd/map.jsonl. Never commit.rtdd/junit.xml,.rtdd/meta.jsonor.coverage.
License
MIT. See LICENSE. Test images in testdata/ are from the Kodak Lossless True Color Image Suite, released for unrestricted use.
Metadata
Release files for imprinted 0.7.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| imprinted-0.7.1.tar.gz | 36.7 kB | Without provenance | Details for imprinted-0.7.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| imprinted-0.7.1-py3-none-any.whl | Python 3 | none | any | Details for imprinted-0.7.1-py3-none-any.whl |
Total release size: 69.7 kB
Release files / imprinted-0.7.1.tar.gz
| Download URL | imprinted-0.7.1.tar.gz |
|---|---|
| Size | 36.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9c0f06d43afc827f6228e2958fd5fad7f5e112672d63f62daf615261a4dea991
|
|
BLAKE2b-256 checksum How to use checksums |
39cb7c5ff3f41a554f4b22645a9210b396e234202e98bb40a90403aaabafd615
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / imprinted-0.7.1-py3-none-any.whl
| Download URL | imprinted-0.7.1-py3-none-any.whl |
|---|---|
| Size | 33.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7308e061f64571a8838dfeb2117a560811b0fd7eb8f8ec3b25b82365b981ac0d
|
|
BLAKE2b-256 checksum How to use checksums |
e74001cde811df0bcad93ba2dfc830a62e4588fb78eaff52baf3ca6633e7a592
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for imprinted 0.7.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.