#!/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()