What this is — in one breath
watermark.py hides a short owner mark (for example
Chris Fernandez — Berserk 2026) inside a photo's brightness,
spread across the whole picture and repeated, so ordinary handling —
a JPEG re-save, a resize, a screenshot — does not wash it out.
Reading a picture back either finds the exact mark, verified by a
checksum, or honestly reports NO MARK. It never guesses.
Use it — three commands, in a terminal, in this folder
1 · Mark one of your own photos (mark text: up to 32 characters):
python3 watermark.py mark my-photo.jpg my-photo-marked.png --id "Chris Fernandez — Berserk 2026"
2 · Read a picture back (works on PNG or JPEG):
python3 watermark.py read some-copy.jpg
Success looks like MARK FOUND: Chris Fernandez — Berserk 2026
with crc: … (verified). Failure looks like
NO MARK: … — that is a real answer, not an error to retry away.
3 · Re-run the whole test bench yourself (regenerates the sample and every attack):
python3 run_fixtures.py
Measured survival — our own sample, our own attacks
| Handling applied to the marked copy | Result |
|---|---|
| Plain PNG re-save | SURVIVE |
| JPEG re-save, quality 85 | SURVIVE |
| JPEG re-save, quality 70 | SURVIVE |
| 50% resize (800×600 → 400×300) | SURVIVE |
| 50% resize and JPEG q85 together | SURVIVE |
| PNG screenshot-style re-save | SURVIVE |
| 25% crop (kept top-left 75% × 75%) | DIE — no mark read |
6 of 7 attacks survived on the
synthetic sample (seed-fixed, generated by make_sample.py).
Controls also passed: an unmarked photo reads NO MARK; a different mark
reads back as itself. Full commands and counts: FIXTURES-1.md.
One sample is evidence about this sample — real-photo behaviour is
labelled UNPROVEN in the build record until the owner tests his own.
How it works — the darkroom version
The picture is divided into a 32 × 32 grid drawn relative to the picture's size — halve the picture and the grid halves with it, which is why resizing does not tear the mark. Each cell's average brightness is nudged onto a numbered step; even steps whisper “0”, odd steps whisper “1”. The mark — a magic word, your text, and a checksum, 312 bits in all — is dealt across all 1,024 cells, so every bit gets 3–4 independent cells voting on it at read time. JPEG noise can flip a cell; it rarely flips a majority. Cropping moves the grid itself, and the mark, honestly, dies with it.