Add a generated demo photo library for store screenshots

The results screenshot is a screenshot *of a photo library*, so the committed
capture showed the developer's own photos. tools/make-demo-photos.py draws the
library instead: 22 procedural scenes (sunset, mountains, beach, conifers in
haze, dunes, city skyline, night sky, bokeh) with grain and a vignette, so there
are no people, no third-party content and nothing to license.

The duplicate structure is deliberate, because a folder of unrelated pictures
demonstrates nothing:

- headline group: one scene re-saved at falling quality and size until its
  reclaimable size reaches --target-mb (45 MB). Results are ranked by
  reclaimable bytes, so this group has to out-rank whatever is already on the
  device or a real photo is the first thing back in frame.
- exact pair: two byte-identical files, for the SHA-256 path.
- singles: the other scenes, once each, so the library looks lived-in.

Verified with the app's own engine on the desktop before pushing anything:
22 images -> 2 groups (PERCEPTUAL 14 copies, EXACT 2 copies) with the six
singles left alone. Regeneration is byte-identical, the output path resolves
from __file__ so it runs from any cwd, and nothing is written outside the repo.

demo-photos/ is git-ignored, like fixtures/ and store-assets/raw-screenshots/.
STORE_LISTING.md documents the library, why the headline group has a target
size, the MediaProvider scan_file recipe and the cleanup step; ROADMAP, README
and the release checklist point at it.
This commit is contained in:
2026-10-13 23:34:12 +02:00
parent 1145944659
commit 3581ffc6bb
6 changed files with 534 additions and 14 deletions
+2
View File
@@ -25,3 +25,5 @@ keystore.properties
# Generated data — regenerated on demand, never committed
fixtures/
store-assets/raw-screenshots/
demo-photos/
store-assets/screenshots/
+2
View File
@@ -177,6 +177,8 @@ listing, Data Safety, release notes, rollout), [docs/STORE_LISTING.md](docs/STOR
The upload-ready graphics and screenshots live in [`store-assets/`](store-assets/).
`tools/make-store-assets.sh` and `tools/make-store-screenshots.sh` regenerate them from the
app's own icon vectors and from `adb` captures — always inside the repo, never in `$HOME`.
`python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is
captured over, so the listing never shows a real one.
User-visible changes are tracked in [CHANGELOG.md](CHANGELOG.md); the Play "What's new"
text you type per release is separate and lives only in the Play Console.
+4 -2
View File
@@ -54,8 +54,10 @@ action except the items marked **[code]**.
- [x] **Feature graphic** 1024×500 PNG **[code]** — same script.
- [ ] At least 2 phone screenshots (up to 8). Three are captured (empty state, scanning,
results), but **re-shoot them after granting photo access on a fresh install**: the
existing ones still show the "Photo access needed" banner — see the caveat in
STORE_LISTING.md.
existing ones still show the "Photo access needed" banner, and the results capture shows
the developer's own photo library. Capture the results screen over the generated demo
library instead (`python3 tools/make-demo-photos.py`, then push and index it) — see the
caveats in STORE_LISTING.md.
⚠️ Play rejects a screenshot whose longer side is more than 2× its shorter side, so a
1080×2400 phone capture has to be cropped to 1080×2160;
`tools/make-store-screenshots.sh` does exactly that (status and navigation bars only).
+6 -5
View File
@@ -21,7 +21,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M7 | On-device validation & hardening | ✅ | Foreground `dataSync` service, one failure boundary per file, `core/DecodeMath`, hostile fixtures ([EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)), `versionName` → 1.0.0 |
| M8 | Localisation groundwork | ✅ | Every user-visible string in `res/values/strings.xml` with real `<plurals>`, shared `text/TextRes.kt`; **English is the only locale** |
| M9 | Monetisation | ⏳ | No billing code. Blocked on a product decision (free forever vs. one-time unlock vs. limited free scans); changes the Data Safety answers |
| M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit (1080×2160); all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). The captured screenshots still show the permission banner — see blockers |
| M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit (1080×2160); `tools/make-demo-photos.py` generates the photo library the results capture is taken over, so no real photos are published; all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). The captures committed today predate the demo library and still show the permission banner — see blockers |
## Release blockers (work that is not a numbered milestone)
@@ -31,7 +31,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) |
| **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it; the ready-to-paste justification is in [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 — this is a Console action, not a code change |
| **Remaining Play paperwork** | Keystore / Play App Signing, Data Safety form, hosted privacy policy and store listing — see [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) |
| **Store screenshots still show the permission banner** | The three captures were taken with the debug-only `MANAGE_EXTERNAL_STORAGE` app-op, so the app's own permission check (correctly) still reported *no access* and the banner is in frame. Re-shoot on a clean install after tapping **Grant access** — the same run that closes the permission-flow verification gap. Files, specs and caveats: [STORE_LISTING](STORE_LISTING.md) |
| **Store screenshots need one re-shoot** | Two things are wrong with the committed captures: they were taken with the debug-only `MANAGE_EXTERNAL_STORAGE` app-op, so the app's own permission check (correctly) still reported *no access* and the banner is in frame; and `03-results.png` shows the developer's real photo library. Both clear in a single run after tapping **Grant access**. ⚠️ An app-op cannot stand in for the grant — `appops set --uid <pkg> android:read_media_images allow` leaves the banner *and* a disabled *Start scan* — and MIUI refuses synthetic taps, so the re-shoot needs two real taps unless *USB debugging (Security settings)* is enabled in the developer options. The demo library that keeps real photos out of the capture is generated, validated and already indexed on the test device; files, specs and caveats: [STORE_LISTING](STORE_LISTING.md) |
| **Device-only verifications** | RAW/DNG files, a *real* iPhone HEIC with a non-upright EXIF orientation, permission revoked mid-scan, and the Android 14 “Selected photos” flow. ✅ The **index-reuse** check is done: after a scan the index holds **5,363 rows, 5,357 with a perceptual hash**, and a second scan runs in **11.0 s vs 20.2 s** cold |
## Candidate milestones (proposed, not scheduled)
@@ -49,9 +49,10 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
(`versionCode` 2). Only the on-device re-check of the 5,363-photo scan remains.
2. Finish the Play Console paperwork and roll out through **internal → closed → production**.
3. ✅ **Done** — **M10 store assets**: the icon and feature graphic are generated from the
app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, and the listing
copy is drafted and length-checked. The only remainder is the screenshot re-shoot after a
real permission grant (see blockers).
app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, the listing copy is
drafted and length-checked, and the demo library that keeps real photos out of the results
capture is generated and validated. The only remainder is the re-shoot after a real
permission grant (see blockers).
4. **Colour signature** — the flat-colour false-positive fix.
5. **M9 — monetisation**, once the product decision is made.
+70 -7
View File
@@ -17,6 +17,7 @@ release they belong to. Only the uncropped captures the screenshots are cut from
```bash
./tools/make-store-assets.sh # icon + feature graphic -> store-assets/
./tools/make-store-screenshots.sh # crop the adb captures -> store-assets/screenshots/
python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (git-ignored)
```
| Asset | Play's spec | Result | Source |
@@ -45,19 +46,81 @@ Captured on the test device (1080×2400, Android 12 / MIUI), cropped to 1080×21
| `02-scanning.png` | Scanning progress — *“Looking for visually similar photos…”* 5341 / 5363 |
| `03-results.png` | Results — 15 duplicate groups, 39.8 MB reclaimable, `BEST` badge, selection bar |
⚠️ **Two caveats before these go live.**
⚠️ **Both caveats below are outstanding for the files committed today.** One re-shoot fixes both.
1. All three still show the *“Photo access needed”* banner. The test device (Xiaomi M2101K7AG
/ MIUI) refuses `pm grant`, `install -g` and synthetic taps, so the runtime permission was
never actually granted there — the scan was driven with a debug-only
`MANAGE_EXTERNAL_STORAGE` app-op, which the app's permission check does not honour (see
[EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)). **Re-shoot on a clean install after tapping
`MANAGE_EXTERNAL_STORAGE` app-op. That app-op gets the scan through MediaStore but does
**not** satisfy the app's own `checkSelfPermission`, which is why the banner — and the
disabled *Start scan* — are genuinely in frame. **Re-shoot on a clean install after tapping
“Grant access”**: that also closes the one remaining device-only verification gap.
2. The results screenshot shows real photos from the test library. They are the developer's
own, so they can be published, but check that nothing personal is recognisable first.
Screens still worth capturing once a device grants access through the real flow — each needs
one tap, which is exactly what the MIUI test device cannot be driven to do:
Enabling *USB debugging (Security settings)* in the MIUI developer options turns synthetic
taps and `pm grant` back on. That is what would let the capture run from a script instead of
by hand.
2. `03-results.png` currently shows real photos from the test library. They are the developer's
own, so nothing is licensed against us, but the library is not ours to publish. The next
capture must be taken over the generated demo library below.
### Demo library: generated photos, so none of anyone's real ones are published
`03-results.png` is a screenshot *of a photo library*, so whatever is on the test device ends up
in the store listing along with it. `tools/make-demo-photos.py` exists so that library can be
generated instead of borrowed: every scene is drawn procedurally — sunset, mountains, beach,
conifers in haze, dunes, city skyline, night sky, bokeh — so there are no people, no third-party
content and nothing to license. It writes to `demo-photos/foto-dup-demo/`, which is git-ignored
and regenerated on demand, exactly like `fixtures/`.
The duplicate structure is deliberate, because a folder of unrelated pictures demonstrates
nothing:
- **headline group** — one scene re-saved at falling quality and size until its reclaimable
size reaches `--target-mb` (default 45 MB),
- **exact pair** — two byte-identical files, for the SHA-256 path,
- **singles** — the other scenes, once each, so the library looks lived-in.
```bash
python3 tools/make-demo-photos.py
adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file \
--arg /sdcard/DCIM/foto-dup-demo
```
Two non-obvious details, both load-bearing:
- **Why the headline group has a target size.** Results are ranked by reclaimable bytes, so the
first group on screen is the largest one. A demo group smaller than anything already on the
device would be pushed below the fold and a real photo would be back in frame. 45 MB clears
the test device's own 39.8 MB with room to spare; raise `--target-mb` if you capture on a
library whose biggest group is larger.
- **`scan_file` on the folder, not on the volume.** It indexes just that path (API 30+) instead
of forcing a MediaStore-wide rescan on a 5,000-photo device. It does recurse into the folder,
and re-running it after the folder is deleted drops those rows again — both verified on the
test device.
Check the grouping *before* pushing, rather than reading it off the phone: the desktop harness
runs the app's real engine over a scaled copy of the library and reports the two intended groups
and nothing else.
```bash
magick mogrify -resize 1024x -quality 90 -path build/demo-validate-raw demo-photos/foto-dup-demo/*.jpg
python3 tools/normalise-for-local-grouping.py build/demo-validate-raw build/demo-validate
./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' \
-DfotoLocal.dir="$PWD/build/demo-validate"
# images=22 groups=2 -> PERCEPTUAL 14 copies, EXACT 2 copies; the six singles stay ungrouped
```
Cleanup, once the captures are done:
```bash
adb shell rm -rf /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo
```
Screens still worth capturing once a device grants access through the real flow (the capture run
needs two taps on the phone — *Grant access*, then *Scan* — which is the one thing the MIUI test
device will not accept from a script):
- the **Options** sheet with the three sensitivity presets,
- the staged state (*“All duplicates are ready to go”*) after selecting duplicates,
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""Generate a synthetic photo library for store screenshots and demo runs.
Nothing in here is a photograph: every scene is drawn procedurally — gradients, ridges,
silhouettes, grain, vignette — so a screenshot taken over this library can be published
without putting anyone's private pictures in the store listing.
The duplicate structure is deliberate, which is the second reason to generate it: the app
is meant to *find* something, and a folder of unrelated pictures shows nothing.
headline group one scene re-saved at falling quality and size, to a size you set with
--target-mb. Groups are ranked by reclaimable bytes, so this has to
out-rank whatever is already in the real library or a real photo would
be the first thing on the results screen.
exact pair two byte-identical files, for the SHA-256 path
singles one copy each of the other scenes, so the library looks lived-in
Written to demo-photos/foto-dup-demo/ inside the repository (git-ignored, regenerated on
demand):
python3 tools/make-demo-photos.py
adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo
adb shell content call --uri content://media --method scan_file \\
--arg /sdcard/DCIM/foto-dup-demo
`scan_file` is the MediaProvider rescan hook (API 30+); it indexes just that path, so the
rest of the library is untouched. Then open the app, scan, and capture.
"""
from __future__ import annotations
import argparse
import math
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageChops, ImageDraw, ImageFilter
REPO = Path(__file__).resolve().parent.parent
OUT = REPO / "demo-photos" / "foto-dup-demo"
# 12 MP, the shape a phone camera writes, so file sizes land in the range real photos do.
FULL = (4032, 3024)
# Scenes are painted at a third of that and upscaled: cheap, and the softness reads as
# "photograph" rather than "illustration".
RENDER = (1344, 1008)
SEED = 20240718
GRAIN = 12
def _rng() -> np.random.Generator:
return np.random.default_rng(SEED)
def _gradient(size: tuple[int, int], stops: list[tuple[float, tuple[int, int, int]]]) -> np.ndarray:
"""Vertical gradient through (position 0..1, (r, g, b)) stops."""
width, height = size
ys = np.linspace(0.0, 1.0, height)
positions = np.array([stop[0] for stop in stops])
channels = [
np.interp(ys, positions, [stop[1][channel] for stop in stops])
for channel in range(3)
]
column = np.stack(channels, axis=-1)
return np.repeat(column[:, None, :], width, axis=1)
def _to_image(array: np.ndarray) -> Image.Image:
return Image.fromarray(np.clip(array, 0, 255).astype(np.uint8), "RGB")
def _ridge(rng: np.random.Generator, width: int, base: float, amplitude: float, roughness: float) -> np.ndarray:
"""A horizon line built from summed sine waves — smooth, never jagged."""
xs = np.linspace(0.0, 1.0, width)
line = np.full(width, base, dtype=np.float64)
for octave in range(4):
line += (amplitude / (2 ** (octave + 1))) * np.sin(
math.tau * roughness * (2**octave) * xs + rng.uniform(0, math.tau)
)
return line
def _fill_ridge(image: Image.Image, line: np.ndarray, colour: tuple[int, int, int]) -> None:
"""Fill everything below a horizon line, i.e. draw a hill or dune."""
width, height = image.size
points = [(x, float(line[x])) for x in range(width)]
points += [(width, height), (0, height)]
ImageDraw.Draw(image).polygon(points, fill=colour)
def _snow(image: Image.Image, line: np.ndarray) -> None:
"""White caps on the high parts of a ridge."""
width, _ = image.size
top, bottom = float(line.min()), float(line.max())
cutoff = top + (bottom - top) * 0.35
draw = ImageDraw.Draw(image, "RGBA")
for x in range(0, width - 6, 4):
if line[x] <= cutoff:
drop = 8 + (cutoff - line[x]) * 0.6
draw.polygon(
((x, line[x]), (x + 6, line[x]), (x + 3, line[x] + drop)),
fill=(250, 252, 255, 235),
)
def _vignette(image: Image.Image, strength: float = 0.24) -> Image.Image:
"""Darken the corners. The cheapest way to stop a gradient reading as vector art."""
width, height = image.size
ys = np.linspace(-1.0, 1.0, height)[:, None]
xs = np.linspace(-1.0, 1.0, width)[None, :]
radius = np.sqrt(xs * xs + ys * ys) / math.sqrt(2.0)
mask = 1.0 - strength * radius**2.2
return _to_image(np.asarray(image, dtype=np.float32) * mask[..., None])
def _grain(image: Image.Image, sigma: int = GRAIN) -> Image.Image:
"""Fine sensor noise. Also, conveniently, what gives a JPEG a realistic file size."""
noise = Image.effect_noise(image.size, sigma).convert("RGB")
return ImageChops.add(image, noise, scale=1.0, offset=-128)
def finish(size: tuple[int, int], painter, rng: np.random.Generator) -> Image.Image:
"""Paint a scene small, blow it up, and make it look photographed."""
scene = painter(RENDER, rng).resize(size, Image.LANCZOS)
scene = scene.filter(ImageFilter.GaussianBlur(0.5))
return _grain(_vignette(scene))
def save(
image: Image.Image,
path: Path,
quality: int,
scale: float = 1.0,
taken: str | None = None,
) -> int:
if scale != 1.0:
size = (int(image.width * scale) // 2 * 2, int(image.height * scale) // 2 * 2)
image = image.resize(size, Image.LANCZOS)
exif = Image.Exif()
exif[0x0112] = 1 # orientation — "as shot"
if taken:
exif[0x9003] = taken
image.save(path, "JPEG", quality=quality, subsampling=2, exif=exif)
return path.stat().st_size
def scene_sunset(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
horizon = int(height * 0.60)
sky = _gradient((width, horizon), [
(0.0, (44, 28, 78)), (0.55, (168, 74, 96)), (0.85, (240, 140, 72)), (1.0, (255, 208, 132)),
])
water = _gradient((width, height - horizon), [
(0.0, (252, 178, 96)), (0.30, (156, 84, 108)), (1.0, (30, 20, 52)),
])
image = _to_image(np.vstack([sky, water]))
draw = ImageDraw.Draw(image, "RGBA")
sun_x = int(width * 0.62)
radius = height * 0.07
draw.ellipse(
(sun_x - radius, horizon - radius * 1.4, sun_x + radius, horizon + radius * 0.6),
fill=(255, 240, 178, 255),
)
# The sun's reflection: a streak that widens as it comes towards the camera.
draw.polygon(
((sun_x - radius * 0.5, horizon), (sun_x + radius * 0.5, horizon),
(sun_x + radius * 2.4, height), (sun_x - radius * 2.4, height)),
fill=(255, 214, 150, 60),
)
image = image.filter(ImageFilter.GaussianBlur(2.5))
_fill_ridge(image, _ridge(rng, width, horizon + height * 0.015, height * 0.045, 1.1), (26, 18, 42))
return image
def scene_mountains(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
horizon = height * 0.72
image = _to_image(_gradient((width, height), [
(0.0, (58, 108, 180)), (0.45, (150, 190, 226)), (0.72, (226, 232, 231)), (1.0, (198, 208, 208)),
]))
for index, (base, amplitude, colour) in enumerate([
(horizon - height * 0.30, height * 0.16, (138, 160, 190)),
(horizon - height * 0.20, height * 0.14, (100, 124, 158)),
(horizon - height * 0.11, height * 0.12, (62, 82, 112)),
]):
line = _ridge(rng, width, base, amplitude, 1.4 + index * 0.8)
_fill_ridge(image, line, colour)
_snow(image, line)
return image.filter(ImageFilter.GaussianBlur(1.1))
def scene_beach(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
sea, sand = int(height * 0.55), int(height * 0.74)
image = _to_image(np.vstack([
_gradient((width, sea), [(0.0, (86, 158, 214)), (0.7, (170, 212, 236)), (1.0, (238, 246, 240))]),
_gradient((width, sand - sea), [(0.0, (34, 110, 140)), (0.6, (58, 156, 176)), (1.0, (150, 208, 208))]),
_gradient((width, height - sand), [(0.0, (238, 220, 186)), (1.0, (206, 178, 138))]),
]))
draw = ImageDraw.Draw(image, "RGBA")
for index in range(5): # foam where the water meets the sand
y = sand - 4 + index * 5
inset = width * rng.uniform(0.0, 0.25)
draw.line((inset, y, width - inset, y + rng.uniform(-3, 3)), fill=(255, 255, 255, 90), width=3)
image = image.filter(ImageFilter.GaussianBlur(1.6))
# The blur above rebound `image`; the umbrella has to be drawn through a fresh handle.
draw = ImageDraw.Draw(image, "RGBA")
pole_x = int(width * 0.30)
canopy_y = sand - (height - sand) * 0.20
apex_y = sand - (height - sand) * 0.44
half = width * 0.11
draw.line((pole_x, sand + (height - sand) * 0.85, pole_x, sand - (height - sand) * 0.25),
fill=(228, 226, 220, 255), width=6)
draw.polygon(
((pole_x - half, canopy_y), (pole_x + half, canopy_y), (pole_x, apex_y)),
fill=(226, 92, 78, 255),
)
# One pale panel, so it reads as a stripy beach umbrella rather than a red triangle.
draw.polygon(
((pole_x - half * 0.35, canopy_y), (pole_x + half * 0.05, canopy_y), (pole_x, apex_y)),
fill=(244, 240, 232, 255),
)
draw.ellipse(
(pole_x - width * 0.07, sand + (height - sand) * 0.80,
pole_x + width * 0.07, sand + (height - sand) * 0.86),
fill=(60, 48, 40, 70),
)
return image
def scene_forest(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
"""Foggy conifer hillside: three bands of trees, each behind a sheet of haze."""
width, height = size
image = _to_image(_gradient((width, height), [
(0.0, (198, 216, 208)), (0.45, (152, 180, 170)), (1.0, (86, 112, 100)),
]))
mist = Image.new("RGB", (width, height), (224, 234, 230))
for index, (base, tree, colour) in enumerate([
(height * 0.46, height * 0.09, (120, 146, 136)),
(height * 0.62, height * 0.13, (88, 114, 102)),
(height * 0.80, height * 0.19, (48, 70, 60)),
]):
if index:
# The haze belongs *in front of* the trees drawn before it.
image = Image.blend(image, mist, 0.28)
draw = ImageDraw.Draw(image)
for _ in range(30):
x = rng.uniform(-width * 0.02, width * 1.02)
top = base - rng.uniform(tree * 0.55, tree * 1.45)
half = rng.uniform(width * 0.010, width * 0.026)
draw.polygon(((x - half, base), (x + half, base), (x, top)), fill=colour)
image = image.filter(ImageFilter.GaussianBlur(1.0))
_fill_ridge(image, _ridge(rng, width, height * 0.94, height * 0.015, 0.9), (34, 50, 42))
return image
def scene_dunes(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
image = _to_image(_gradient((width, height), [
(0.0, (96, 146, 196)), (0.35, (214, 190, 158)), (0.55, (238, 206, 158)), (1.0, (198, 150, 96)),
]))
for index, colour in enumerate([(240, 214, 170), (226, 190, 138), (204, 162, 108), (176, 132, 84)]):
_fill_ridge(
image,
_ridge(rng, width, height * (0.45 + index * 0.13), height * 0.03, 0.6 + index * 0.4),
colour,
)
return image.filter(ImageFilter.GaussianBlur(1.0))
def scene_city(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
horizon = int(height * 0.72)
image = _to_image(_gradient((width, horizon), [
(0.0, (44, 44, 96)), (0.55, (150, 96, 128)), (0.85, (232, 138, 96)), (1.0, (252, 196, 132)),
]))
image.paste(
_to_image(_gradient((width, height - horizon), [(0.0, (44, 38, 62)), (1.0, (16, 14, 28))])),
(0, horizon),
)
draw = ImageDraw.Draw(image, "RGBA")
draw.ellipse(
(width * 0.70, height * 0.12, width * 0.78, height * 0.12 + width * 0.08),
fill=(250, 244, 220, 255),
)
x = 0.0
while x < width:
building = rng.uniform(width * 0.03, width * 0.075)
top = rng.uniform(height * 0.28, height * 0.60)
draw.rectangle((x, top, x + building, horizon), fill=(34, 32, 52, 255))
for wy in np.arange(top + 8, horizon - 6, 14): # lit windows
for wx in np.arange(x + 6, x + building - 4, 12):
if rng.random() < 0.45:
draw.rectangle((wx, wy, wx + 4, wy + 6), fill=(255, 226, 150, 255))
x += building + rng.uniform(2, 10)
return image.filter(ImageFilter.GaussianBlur(0.9))
def scene_night(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
image = _to_image(_gradient((width, height), [
(0.0, (6, 10, 30)), (0.5, (14, 22, 54)), (0.78, (30, 40, 74)), (1.0, (12, 16, 34)),
]))
# The milky way: a shallow diagonal band of blurred noise, screened over the sky. Kept dim
# and wide — a narrow bright band reads as a light leak, not a galaxy.
band = np.linspace(0.0, 1.0, width)[None, :] * height * 0.22 + height * 0.12
distance = np.abs(np.arange(height)[:, None] - band)
cloud = np.clip(1.0 - distance / (height * 0.20), 0, 1) * rng.random((height, width))
nebula = Image.fromarray((cloud * 120).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(11))
image = ImageChops.screen(
image,
Image.merge("RGB", (
nebula,
nebula.point(lambda v: int(v * 0.85)),
nebula.point(lambda v: min(255, int(v * 1.3))),
)),
)
draw = ImageDraw.Draw(image)
for _ in range(320):
x, y = rng.uniform(0, width), rng.uniform(0, height * 0.80)
dot = int(rng.choice([1, 1, 1, 2, 3]))
brightness = int(rng.uniform(140, 255))
draw.rectangle((x, y, x + dot, y + dot), fill=(brightness, brightness, min(255, brightness + 20)))
image = image.filter(ImageFilter.GaussianBlur(0.6))
_fill_ridge(image, _ridge(rng, width, height * 0.84, height * 0.05, 1.4), (6, 8, 18))
return image
def scene_bokeh(size: tuple[int, int], rng: np.random.Generator) -> Image.Image:
width, height = size
palette = [(246, 176, 92), (232, 106, 132), (120, 176, 232), (238, 226, 178), (168, 128, 220)]
image = Image.new("RGB", (width, height), (26, 16, 34))
draw = ImageDraw.Draw(image, "RGBA")
for _ in range(90): # out-of-focus highlights
x, y = rng.uniform(0, width), rng.uniform(0, height)
radius = rng.uniform(width * 0.015, width * 0.09)
colour = palette[int(rng.integers(len(palette)))]
draw.ellipse((x - radius, y - radius, x + radius, y + radius),
fill=colour + (int(rng.uniform(40, 110)),))
image = image.filter(ImageFilter.GaussianBlur(9))
draw = ImageDraw.Draw(image, "RGBA")
for _ in range(24): # and a few that landed in focus
x, y = rng.uniform(0, width), rng.uniform(0, height)
radius = rng.uniform(width * 0.006, width * 0.03)
colour = palette[int(rng.integers(len(palette)))]
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=colour + (190,))
return image.filter(ImageFilter.GaussianBlur(3))
SCENES = {
"sunset": scene_sunset,
"mountains": scene_mountains,
"beach": scene_beach,
"forest": scene_forest,
"dunes": scene_dunes,
"city": scene_city,
"night": scene_night,
"bokeh": scene_bokeh,
}
# Camera-roll names: what a phone writes, plus a suffix for the copies it keeps.
HEADLINE = "IMG_20240718_204512"
HEADLINE_TAKEN = "2024:07:18 20:45:12"
# Re-saves of one photo, in the order a real camera roll collects them: exports at falling
# quality, then the downscaled shares. The loop stops as soon as --target-mb is reached, so a
# bigger real library just means more copies.
HEADLINE_VARIANTS = [
(95, 1.0), (92, 1.0), (90, 1.0), (88, 1.0), (85, 1.0), (82, 1.0), (80, 1.0),
(78, 1.0), (75, 1.0), (90, 0.80), (86, 0.66), (82, 0.50),
# ...and the exports a camera roll keeps collecting afterwards.
(94, 1.0), (89, 1.0), (84, 1.0), (81, 1.0), (79, 1.0), (77, 1.0),
(88, 0.90), (84, 0.72), (80, 0.60), (78, 0.44), (76, 0.33),
]
PAIR = ("IMG_20240315_140122", "forest", 88, 0.5, "2024:03:15 14:01:22")
SINGLES = [
("IMG_20240405_091243", "mountains", "2024:04:05 09:12:43"),
("IMG_20240519_134500", "beach", "2024:05:19 13:45:00"),
("IMG_20240628_182004", "dunes", "2024:06:28 18:20:04"),
("IMG_20240810_213055", "city", "2024:08:10 21:30:55"),
("IMG_20240824_231512", "night", "2024:08:24 23:15:12"),
("IMG_20240909_193044", "bokeh", "2024:09:09 19:30:44"),
]
def reclaimable(sizes: list[int]) -> float:
"""What the app would report: everything except the largest copy, in MB."""
return (sum(sizes) - max(sizes)) / 1_000_000
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--out", type=Path, default=OUT,
help=f"output directory (default: {OUT})")
parser.add_argument("--target-mb", type=float, default=45.0,
help="reclaimable size the headline group must reach (default: 45)")
args = parser.parse_args()
rng = _rng()
args.out.mkdir(parents=True, exist_ok=True)
for stale in sorted(args.out.glob("*.jpg")):
stale.unlink()
# 1. The headline group. Results are ranked by reclaimable bytes, so this one has to
# out-rank everything in the real library or a real photo is the first thing on screen.
headline = finish(FULL, SCENES["sunset"], rng)
sizes: list[int] = []
for index, (quality, scale) in enumerate(HEADLINE_VARIANTS):
name = f"{HEADLINE}.jpg" if index == 0 else f"{HEADLINE}-{index}.jpg"
sizes.append(save(headline, args.out / name, quality, scale, HEADLINE_TAKEN))
if reclaimable(sizes) >= args.target_mb:
break
print(f"headline group: {len(sizes)} copies, {reclaimable(sizes):.1f} MB reclaimable "
f"(target {args.target_mb:g} MB)")
if reclaimable(sizes) < args.target_mb:
print(f"warning: out of variants at {reclaimable(sizes):.1f} MB — the group may not "
f"out-rank the real library. Add entries to HEADLINE_VARIANTS or lower "
f"--target-mb.", file=sys.stderr)
# 2. Two byte-identical files, for the SHA-256 path.
name, scene, quality, scale, taken = PAIR
original = args.out / f"{name}.jpg"
save(finish(FULL, SCENES[scene], rng), original, quality, scale, taken)
(args.out / f"{name}-copy.jpg").write_bytes(original.read_bytes())
# 3. One of each remaining scene, so the library looks lived-in.
for name, scene, taken in SINGLES:
save(finish(FULL, SCENES[scene], rng), args.out / f"{name}.jpg", 92, 1.0, taken)
written = sorted(args.out.glob("*.jpg"))
total = sum(path.stat().st_size for path in written)
print(f"wrote {len(written)} demo photos ({total / 1e6:.1f} MB) to {args.out}")
for path in written:
print(f" {path.stat().st_size:>9,} {path.name}")
shown = args.out.relative_to(REPO) if args.out.is_relative_to(REPO) else args.out
print("\nPush them to the device and index just that folder (API 30+):")
print(f" adb push {shown} /sdcard/DCIM/foto-dup-demo")
print(" adb shell content call --uri content://media --method scan_file \\")
print(" --arg /sdcard/DCIM/foto-dup-demo")
if __name__ == "__main__":
main()