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.
@@ -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.
@@ -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
+ ![Original, Imprinted, ×10 difference, and a simulated phone photo of a curled print decoding the payload](https://raw.githubusercontent.com/VocanicZ/Imprinted/main/versus.png)
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
+ ![Original, Imprinted, ×10 difference, and a simulated phone photo of a curled print decoding the payload](https://raw.githubusercontent.com/VocanicZ/Imprinted/main/versus.png)
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")