imprinted 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imprinted-0.7.0/LICENSE +21 -0
- imprinted-0.7.0/PKG-INFO +171 -0
- imprinted-0.7.0/README.md +153 -0
- imprinted-0.7.0/imprinted/__init__.py +59 -0
- imprinted-0.7.0/imprinted/core.py +1357 -0
- imprinted-0.7.0/imprinted.egg-info/PKG-INFO +171 -0
- imprinted-0.7.0/imprinted.egg-info/SOURCES.txt +10 -0
- imprinted-0.7.0/imprinted.egg-info/dependency_links.txt +1 -0
- imprinted-0.7.0/imprinted.egg-info/requires.txt +3 -0
- imprinted-0.7.0/imprinted.egg-info/top_level.txt +1 -0
- imprinted-0.7.0/pyproject.toml +28 -0
- imprinted-0.7.0/setup.cfg +4 -0
imprinted-0.7.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nathaniel Song
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
imprinted-0.7.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imprinted
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Hide a short payload in a picture, invisibly; read it back from the file or a phone photo of a print.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/VocanicZ/Imprinted
|
|
7
|
+
Keywords: watermark,steganography,print,camera,image
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=2
|
|
15
|
+
Requires-Dist: opencv-python-headless>=4.8
|
|
16
|
+
Requires-Dist: pillow>=10
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Imprinted: invisible, camera-robust data in images
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
*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).*
|
|
24
|
+
|
|
25
|
+
Imprinted hides a short payload (up to 32 bytes) inside an ordinary picture. The encoded image:
|
|
26
|
+
|
|
27
|
+
* **looks like the original**: same size, no border, no QR-style markers, no visible pattern (SSIM ≥ 0.955, mean ΔL\* ≤ 1 by default);
|
|
28
|
+
* **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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
> Status: research prototype. The format and API may still change. Simulated results are below; real printers and phones are the final judge.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
**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.
|
|
41
|
+
|
|
42
|
+
**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.
|
|
43
|
+
|
|
44
|
+
**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.
|
|
45
|
+
|
|
46
|
+
**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.
|
|
47
|
+
|
|
48
|
+
### Measured (simulated)
|
|
49
|
+
|
|
50
|
+
`sim.py --acceptance`: 15 cm print, 5 trials, default strength, payload `IMPRINT1`. Every acceptance row passes.
|
|
51
|
+
|
|
52
|
+
| image | δ | SSIM | mean ΔL\* | clean / turned / mirrored | 60% crops | print easy | gray | hard | curled |
|
|
53
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
54
|
+
| kodim23 (smooth) | 1.21 | 0.956 | 0.74 | all | 5/5 | 5/5 | 5/5 | 4/5 | 5/5 |
|
|
55
|
+
| kodim05 (busy) | 0.83 | 0.976 | 0.99 | all | 5/5 | 5/5 | 5/5 | – | – |
|
|
56
|
+
| kodim08 (busy, periodic) | 0.92 | 0.980 | 0.98 | all | 5/5 | 5/5 | 5/5 | – | – |
|
|
57
|
+
|
|
58
|
+
`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.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
Needs Python 3.10+ (tested on 3.12).
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install git+https://github.com/VocanicZ/Imprinted.git
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That installs the `imprinted` library only (numpy, opencv-python-headless, pillow). For the web UI, simulator and tests, clone the repo:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/VocanicZ/Imprinted.git
|
|
74
|
+
cd Imprinted
|
|
75
|
+
python3 -m venv .venv
|
|
76
|
+
.venv/bin/pip install -r requirements.txt
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On Windows use `.venv\Scripts\pip` and `.venv\Scripts\python`.
|
|
80
|
+
|
|
81
|
+
## Use
|
|
82
|
+
|
|
83
|
+
**Python:**
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import imprinted
|
|
87
|
+
|
|
88
|
+
imprinted.encode("photo.png", "hello", "photo_imprinted.png") # save as PNG
|
|
89
|
+
imprinted.decode("phone_shot.jpg") # -> "hello", or None
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
* `encode(image, payload, out=None, *, print_width_cm=15.0, strength=None)` takes a path, PIL image or RGB uint8 array, and a `str` or `bytes` payload of 1 to 32 bytes. It returns the encoded PIL image (same size and mode) and saves it to `out` if given. Set `print_width_cm` to how wide the picture will be printed or shown: it sizes the pattern to about 0.8 mm per sub-block. `strength=None` picks the strongest setting inside the visibility budget. Raises `ValueError` when the picture is too small to read back or too clipped to carry the payload.
|
|
93
|
+
* `decode(image, raw=False)` returns the first payload found as a `str`, as `bytes` with `raw=True`, or `None` if nothing is found.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
**Web UI** (from a clone: encode, decode, print a strength ladder; works from a phone on the same network):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
.venv/bin/uvicorn web:app --host 0.0.0.0 --port 8765
|
|
101
|
+
# open http://<this-pc-ip>:8765
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
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`.
|
|
105
|
+
|
|
106
|
+
**Simulator:**
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
nice .venv/bin/python sim.py --trials 2 photo.png # decode rates by strength x print/photo preset
|
|
110
|
+
nice .venv/bin/python sim.py --acceptance # full acceptance table, exits 0 only on PASS
|
|
111
|
+
nice .venv/bin/python sim.py --quick # kodim23, 1 trial
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`--acceptance` writes `acceptance.json`, each encoded image, its ×10 difference view and its strength ladder to `results/acceptance`.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Repository layout
|
|
119
|
+
|
|
120
|
+
| path | what |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `imprinted/__init__.py` | the library's simple `encode` / `decode` |
|
|
123
|
+
| `imprinted/core.py` | the codec: full `encode`, `decode`, `make_ladder` |
|
|
124
|
+
| `pyproject.toml` | pip package (`imprinted`) |
|
|
125
|
+
| `web.py`, `web/index.html` | FastAPI web UI |
|
|
126
|
+
| `sim.py` | print/photo simulator and acceptance benchmark |
|
|
127
|
+
| `test_imprinted.py` | test suite |
|
|
128
|
+
| `testdata/` | Kodak test images (kodim05, 08, 23) |
|
|
129
|
+
| `scripts/ci-local.sh` | local CI gate |
|
|
130
|
+
| `scripts/readme_image.py` | regenerates `versus.png` |
|
|
131
|
+
| `docs/reports/` | experiment reports |
|
|
132
|
+
| `imprint.py`, `utils/`, `test.py`, `PROTOTYPE.md` | the earlier color-variant (CVP) prototype with visible corner markers, kept for reference |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Contributing
|
|
137
|
+
|
|
138
|
+
Bug reports and PRs are welcome. Open PRs against `main`.
|
|
139
|
+
|
|
140
|
+
**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).
|
|
141
|
+
|
|
142
|
+
**Making a change.**
|
|
143
|
+
|
|
144
|
+
1. Branch from `main`.
|
|
145
|
+
2. Run the tests that cover your change while you work, then the full gate before you open the PR (below).
|
|
146
|
+
3. If you touch the codec, run `sim.py --acceptance` and put the table in the PR. A change that makes any row FAIL needs a strong reason.
|
|
147
|
+
4. 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).
|
|
148
|
+
5. Use conventional commit messages (`feat:`, `fix:`, `docs:`, ...) and reference the issue (`closes #N`).
|
|
149
|
+
|
|
150
|
+
There is no GitHub CI: `scripts/ci-local.sh` is the gate, and it builds `.venv` itself if missing.
|
|
151
|
+
|
|
152
|
+
### Test loop (rtdd, then ci-local)
|
|
153
|
+
|
|
154
|
+
The suite is slow (each test encodes and decodes a full image), so don't rerun all of it on every edit. [rtdd](https://github.com/VocanicZ/rtdd) keeps a per-test coverage map (`.rtdd/map.jsonl`, committed) and runs only the tests that cover your change:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
rtdd which --base origin/main # tests covering your diff + changed lines nothing covers; runs nothing
|
|
158
|
+
rtdd run --base origin/main # run just those tests, refresh their map rows
|
|
159
|
+
scripts/ci-local.sh # full gate before you push/merge
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
* `rtdd` runs pytest through `scripts/rtdd-pytest.sh` (adapter: `.rtdd/adapters/python.yaml`). It reuses ci-local's `.venv`, adds `pytest-cov` + `pytest-reportlog`, and runs under `nice -n 10`.
|
|
163
|
+
* An empty selection is not green: the map knows nothing about that change, so run `scripts/ci-local.sh`.
|
|
164
|
+
* Keep `OMP_NUM_THREADS` / `OPENBLAS_NUM_THREADS` at **4 or lower** (both scripts default to 4) and run sims under `nice`, 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.
|
|
165
|
+
* 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.json` or `.coverage`.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT. See [LICENSE](LICENSE). Test images in `testdata/` are from the Kodak Lossless True Color Image Suite, released for unrestricted use.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Imprinted: invisible, camera-robust data in images
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
*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).*
|
|
6
|
+
|
|
7
|
+
Imprinted hides a short payload (up to 32 bytes) inside an ordinary picture. The encoded image:
|
|
8
|
+
|
|
9
|
+
* **looks like the original**: same size, no border, no QR-style markers, no visible pattern (SSIM ≥ 0.955, mean ΔL\* ≤ 1 by default);
|
|
10
|
+
* **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.
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
> Status: research prototype. The format and API may still change. Simulated results are below; real printers and phones are the final judge.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
**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.
|
|
23
|
+
|
|
24
|
+
**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.
|
|
25
|
+
|
|
26
|
+
**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.
|
|
27
|
+
|
|
28
|
+
**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.
|
|
29
|
+
|
|
30
|
+
### Measured (simulated)
|
|
31
|
+
|
|
32
|
+
`sim.py --acceptance`: 15 cm print, 5 trials, default strength, payload `IMPRINT1`. Every acceptance row passes.
|
|
33
|
+
|
|
34
|
+
| image | δ | SSIM | mean ΔL\* | clean / turned / mirrored | 60% crops | print easy | gray | hard | curled |
|
|
35
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
36
|
+
| kodim23 (smooth) | 1.21 | 0.956 | 0.74 | all | 5/5 | 5/5 | 5/5 | 4/5 | 5/5 |
|
|
37
|
+
| kodim05 (busy) | 0.83 | 0.976 | 0.99 | all | 5/5 | 5/5 | 5/5 | – | – |
|
|
38
|
+
| kodim08 (busy, periodic) | 0.92 | 0.980 | 0.98 | all | 5/5 | 5/5 | 5/5 | – | – |
|
|
39
|
+
|
|
40
|
+
`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.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
Needs Python 3.10+ (tested on 3.12).
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install git+https://github.com/VocanicZ/Imprinted.git
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That installs the `imprinted` library only (numpy, opencv-python-headless, pillow). For the web UI, simulator and tests, clone the repo:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/VocanicZ/Imprinted.git
|
|
56
|
+
cd Imprinted
|
|
57
|
+
python3 -m venv .venv
|
|
58
|
+
.venv/bin/pip install -r requirements.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
On Windows use `.venv\Scripts\pip` and `.venv\Scripts\python`.
|
|
62
|
+
|
|
63
|
+
## Use
|
|
64
|
+
|
|
65
|
+
**Python:**
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import imprinted
|
|
69
|
+
|
|
70
|
+
imprinted.encode("photo.png", "hello", "photo_imprinted.png") # save as PNG
|
|
71
|
+
imprinted.decode("phone_shot.jpg") # -> "hello", or None
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
* `encode(image, payload, out=None, *, print_width_cm=15.0, strength=None)` takes a path, PIL image or RGB uint8 array, and a `str` or `bytes` payload of 1 to 32 bytes. It returns the encoded PIL image (same size and mode) and saves it to `out` if given. Set `print_width_cm` to how wide the picture will be printed or shown: it sizes the pattern to about 0.8 mm per sub-block. `strength=None` picks the strongest setting inside the visibility budget. Raises `ValueError` when the picture is too small to read back or too clipped to carry the payload.
|
|
75
|
+
* `decode(image, raw=False)` returns the first payload found as a `str`, as `bytes` with `raw=True`, or `None` if nothing is found.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
**Web UI** (from a clone: encode, decode, print a strength ladder; works from a phone on the same network):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
.venv/bin/uvicorn web:app --host 0.0.0.0 --port 8765
|
|
83
|
+
# open http://<this-pc-ip>:8765
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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`.
|
|
87
|
+
|
|
88
|
+
**Simulator:**
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
nice .venv/bin/python sim.py --trials 2 photo.png # decode rates by strength x print/photo preset
|
|
92
|
+
nice .venv/bin/python sim.py --acceptance # full acceptance table, exits 0 only on PASS
|
|
93
|
+
nice .venv/bin/python sim.py --quick # kodim23, 1 trial
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--acceptance` writes `acceptance.json`, each encoded image, its ×10 difference view and its strength ladder to `results/acceptance`.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Repository layout
|
|
101
|
+
|
|
102
|
+
| path | what |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `imprinted/__init__.py` | the library's simple `encode` / `decode` |
|
|
105
|
+
| `imprinted/core.py` | the codec: full `encode`, `decode`, `make_ladder` |
|
|
106
|
+
| `pyproject.toml` | pip package (`imprinted`) |
|
|
107
|
+
| `web.py`, `web/index.html` | FastAPI web UI |
|
|
108
|
+
| `sim.py` | print/photo simulator and acceptance benchmark |
|
|
109
|
+
| `test_imprinted.py` | test suite |
|
|
110
|
+
| `testdata/` | Kodak test images (kodim05, 08, 23) |
|
|
111
|
+
| `scripts/ci-local.sh` | local CI gate |
|
|
112
|
+
| `scripts/readme_image.py` | regenerates `versus.png` |
|
|
113
|
+
| `docs/reports/` | experiment reports |
|
|
114
|
+
| `imprint.py`, `utils/`, `test.py`, `PROTOTYPE.md` | the earlier color-variant (CVP) prototype with visible corner markers, kept for reference |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Contributing
|
|
119
|
+
|
|
120
|
+
Bug reports and PRs are welcome. Open PRs against `main`.
|
|
121
|
+
|
|
122
|
+
**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).
|
|
123
|
+
|
|
124
|
+
**Making a change.**
|
|
125
|
+
|
|
126
|
+
1. Branch from `main`.
|
|
127
|
+
2. Run the tests that cover your change while you work, then the full gate before you open the PR (below).
|
|
128
|
+
3. If you touch the codec, run `sim.py --acceptance` and put the table in the PR. A change that makes any row FAIL needs a strong reason.
|
|
129
|
+
4. 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).
|
|
130
|
+
5. Use conventional commit messages (`feat:`, `fix:`, `docs:`, ...) and reference the issue (`closes #N`).
|
|
131
|
+
|
|
132
|
+
There is no GitHub CI: `scripts/ci-local.sh` is the gate, and it builds `.venv` itself if missing.
|
|
133
|
+
|
|
134
|
+
### Test loop (rtdd, then ci-local)
|
|
135
|
+
|
|
136
|
+
The suite is slow (each test encodes and decodes a full image), so don't rerun all of it on every edit. [rtdd](https://github.com/VocanicZ/rtdd) keeps a per-test coverage map (`.rtdd/map.jsonl`, committed) and runs only the tests that cover your change:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
rtdd which --base origin/main # tests covering your diff + changed lines nothing covers; runs nothing
|
|
140
|
+
rtdd run --base origin/main # run just those tests, refresh their map rows
|
|
141
|
+
scripts/ci-local.sh # full gate before you push/merge
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
* `rtdd` runs pytest through `scripts/rtdd-pytest.sh` (adapter: `.rtdd/adapters/python.yaml`). It reuses ci-local's `.venv`, adds `pytest-cov` + `pytest-reportlog`, and runs under `nice -n 10`.
|
|
145
|
+
* An empty selection is not green: the map knows nothing about that change, so run `scripts/ci-local.sh`.
|
|
146
|
+
* Keep `OMP_NUM_THREADS` / `OPENBLAS_NUM_THREADS` at **4 or lower** (both scripts default to 4) and run sims under `nice`, 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.
|
|
147
|
+
* 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.json` or `.coverage`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT. See [LICENSE](LICENSE). Test images in `testdata/` are from the Kodak Lossless True Color Image Suite, released for unrestricted use.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Imprinted: hide a short payload in a picture, read it back from the file or a photo of a print.
|
|
2
|
+
|
|
3
|
+
import imprinted
|
|
4
|
+
imprinted.encode("photo.png", "hello", "photo_imprinted.png")
|
|
5
|
+
imprinted.decode("phone_shot.jpg") # -> "hello", or None
|
|
6
|
+
|
|
7
|
+
`imprinted.core` is the full codec (encode stats, every Imprinted found in a photo, the strength ladder).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from PIL import Image, ImageOps
|
|
15
|
+
|
|
16
|
+
from . import core
|
|
17
|
+
|
|
18
|
+
__version__ = "0.7.0"
|
|
19
|
+
__all__ = ["encode", "decode", "core"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load(image) -> np.ndarray:
|
|
23
|
+
"""Path, PIL image or uint8 array (RGB, RGBA or grayscale) -> uint8 array, EXIF rotation applied."""
|
|
24
|
+
if isinstance(image, np.ndarray):
|
|
25
|
+
return image
|
|
26
|
+
if isinstance(image, (str, os.PathLike)):
|
|
27
|
+
image = Image.open(image)
|
|
28
|
+
image = ImageOps.exif_transpose(image)
|
|
29
|
+
if image.mode not in ("L", "RGB", "RGBA"):
|
|
30
|
+
image = image.convert("RGBA" if "A" in image.getbands() or "transparency" in image.info else "RGB")
|
|
31
|
+
return np.asarray(image)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode(image, payload: str | bytes, out=None, *, print_width_cm: float = 15.0,
|
|
35
|
+
strength: float | None = None) -> Image.Image:
|
|
36
|
+
"""Hide payload (1..32 bytes; a str is UTF-8 encoded) in image (a path, PIL image or RGB uint8
|
|
37
|
+
array). Returns the encoded picture (same size and mode) and saves it to out when given; save
|
|
38
|
+
it lossless (PNG). print_width_cm: how wide it will be printed or shown, which sets the pattern
|
|
39
|
+
size. strength: None takes the strongest within the visibility budget (SSIM >= 0.955, mean
|
|
40
|
+
dL* <= 0.99). ValueError when the picture is too small or too clipped to carry the payload."""
|
|
41
|
+
rgb = _load(image)
|
|
42
|
+
if isinstance(payload, str):
|
|
43
|
+
payload = payload.encode()
|
|
44
|
+
b = core.subblock_for_print(rgb.shape[1], print_width_cm * 10)
|
|
45
|
+
enc, _ = core.encode(rgb, payload, delta=strength, subblock_px=b)
|
|
46
|
+
img = Image.fromarray(enc)
|
|
47
|
+
if out is not None:
|
|
48
|
+
img.save(out)
|
|
49
|
+
return img
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode(image, raw: bool = False) -> str | bytes | None:
|
|
53
|
+
"""First payload found in image (a file, screenshot or photo: path, PIL image or RGB uint8
|
|
54
|
+
array), or None. A str (UTF-8) unless raw, then bytes."""
|
|
55
|
+
found = core.decode(_load(image))["found"]
|
|
56
|
+
if not found:
|
|
57
|
+
return None
|
|
58
|
+
payload = found[0]["payload"]
|
|
59
|
+
return payload if raw else payload.decode("utf-8", "replace")
|