Files
foto-duplicate-finder/tools/make-edge-case-fixtures.py
T
Hadrian Burkhardt 4706e2669c Initial commit: Foto Dup Finder 1.0.1
An Android duplicate-photo finder. Pure-Kotlin core (SHA-256 + dHash hashing, exact and
perceptual union-find grouping, three sensitivity presets) with MediaStore access, a
persistent SQLite hash index and a tiered ScanEngine behind a Jetpack Compose UI.
No INTERNET permission, no ads, no analytics, no account.

Ships versionCode 2 / versionName 1.0.1, carrying the scan-stall hardening (BoundedCall
breaker) and verified against a 5,363-photo device library. 80 JVM unit tests, lint clean.

Release paperwork is in docs/ (Play checklist, store listing, edge-case testing), the
upload-ready Play graphics and screenshots in store-assets/, and the generators for those
assets in tools/.
2026-10-13 23:13:39 +02:00

188 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Generate hostile / edge-case image fixtures for Foto Dup Finder.
Every file here is something a real photo library can contain: an interrupted download, a
half-written JPEG, a crafted "decompression bomb", a photo whose pixels are fine but whose
EXIF says it is rotated, two byte-identical copies, and so on.
They are written to fixtures/foto-dup-edge/ inside the repository. That directory is
git-ignored — the fixtures are regenerated on demand, so no binary test data is committed:
python3 tools/make-edge-case-fixtures.py
adb push fixtures/foto-dup-edge /sdcard/DCIM/foto-dup-edge
Then scan in the app and check: nothing crashes, unreadable files are reported rather than
silently dropped, the EXIF variants of the same picture are *not* reported as duplicates of
each other (they normalise to the same pixels), and dup-a/dup-b *are*.
"""
import random
import struct
import zlib
from pathlib import Path
from PIL import Image
REPO = Path(__file__).resolve().parent.parent
OUT = REPO / "fixtures" / "foto-dup-edge"
random.seed(7)
def write(name: str, data: bytes) -> Path:
path = OUT / name
path.write_bytes(data)
return path
def gradient(width: int = 320, height: int = 240) -> Image.Image:
"""A structured base image — distinct enough from the solids to matter."""
img = Image.new("RGB", (width, height))
px = img.load()
for y in range(height):
for x in range(width):
px[x, y] = ((60 + x) % 256, (90 + y) % 256, 200)
return img
def png_chunk(kind: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
def heic_section(base: Image.Image) -> None:
"""HEIC samples, encoded locally so they carry no third-party licence.
Android can decode HEIC from API 28, so these exercise that path — and because we encode
them ourselves from our own images, they can be shipped/pushed freely.
"""
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
print("\nHEIC fixtures skipped — needs an encoder. Generate them with:")
print(" python3 -m venv /tmp/heicvenv")
print(" /tmp/heicvenv/bin/pip install pillow-heif")
print(" /tmp/heicvenv/bin/python tools/make-edge-case-fixtures.py")
return
# The iPhone default format: control, plus a byte-identical copy for the exact-duplicate path.
basic = OUT / "heic-basic.heic"
base.save(basic, "HEIF", quality=90)
write("heic-dup.heic", basic.read_bytes())
# Bigger than a thumbnail, so the subsampled fallback decode gets exercised.
gradient(2400, 1800).save(OUT / "heic-large.heic", "HEIF", quality=85)
# NOTE: HEIC EXIF *orientation* cannot be set with this encoder. Both `exif=<bytes>` and
# `exif=<Image.Exif>` are written, but the orientation tag comes back as 1 regardless, which
# would make a fixture that lies about what it tests. That one case therefore has to be
# checked with a real camera/iPhone HEIC — see docs/EDGE_CASE_TESTING.md.
# One scene encoded both ways: is a codec conversion still worth reporting?
scene = gradient(800, 600)
scene.save(OUT / "scene.jpg", "JPEG", quality=92)
scene.save(OUT / "scene.heic", "HEIF", quality=90)
# Near-identical solids in HEIC too — the band-index stress case.
for index in (0, 1):
Image.new("RGB", (400, 300), (20 + index, 40, 60)).save(
OUT / f"heic-solid-{index}.heic",
"HEIF",
quality=90,
)
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
base = gradient()
# A distinct control scene, so the duplicate pair below is unambiguous. (Previously this was
# byte-identical to dup-a/dup-b, which produced a confusing group of three.)
control = gradient(240, 180)
control = control.transpose(Image.FLIP_LEFT_RIGHT)
write("valid.jpg", _jpeg_bytes(control))
good = _jpeg_bytes(base)
# 2. Interrupted download / half-written file: valid header, no end-of-image marker.
write("truncated.jpg", good[: len(good) * 4 // 10])
# 3. Right header, garbage body.
write("garbage-body.jpg", good[:120] + bytes(random.getrandbits(8) for _ in range(4096)))
# 4. Zero bytes. MediaStore normally filters these, but a sideloaded file can slip through.
write("zero-bytes.jpg", b"")
# 5. A PNG wearing a .jpg extension — the MIME type lies.
png_bytes = _png_bytes(base)
write("renamed-png.jpg", png_bytes)
# 6. A valid JPEG with no extension at all.
write("no-extension", good)
# 7. Decompression bomb: the header declares 60000 x 60000 (14 GB decoded) but carries
# almost no pixel data. A naive decoder allocates until it dies.
ihdr = struct.pack(">IIBBBBB", 60000, 60000, 8, 2, 0, 0, 0)
write(
"decompression-bomb.png",
b"\x89PNG\r\n\x1a\n"
+ png_chunk(b"IHDR", ihdr)
+ png_chunk(b"IDAT", zlib.compress(b"\x00" * 64))
+ png_chunk(b"IEND", b""),
)
# 8. The same picture written with every EXIF orientation value. All eight are the same
# photo and must normalise to the same fingerprint.
for orientation in range(1, 9):
exif = Image.Exif()
exif[0x0112] = orientation
path = OUT / f"exif-orientation-{orientation}.jpg"
base.save(path, "JPEG", quality=90, exif=exif)
# 9. Byte-identical copies — the exact-duplicate path must find these.
write("dup-a.jpg", good)
write("dup-b.jpg", good)
# 10. Odd but legal encodings that must not break the hasher.
base.convert("L").save(OUT / "grayscale.jpg", "JPEG", quality=90)
base.convert("RGBA").save(OUT / "with-alpha.png", "PNG")
Image.new("RGB", (1, 1), (255, 0, 0)).save(OUT / "tiny-1px.jpg", "JPEG")
# 11. Near-identical solids: colour-only similarity bait, and a stress case for the banded
# index (all of these may share hash bands).
for index in range(6):
Image.new("RGB", (400, 300), (20 + index, 40, 60)).save(
OUT / f"solid-{index}.png", "PNG"
)
total = sorted(OUT.iterdir())
heic_section(base)
total = sorted(OUT.iterdir())
print(f"wrote {len(total)} fixtures to {OUT}")
for path in total:
print(f" {path.stat().st_size:>9,} {path.name}")
def _jpeg_bytes(img: Image.Image) -> bytes:
import io
buffer = io.BytesIO()
img.save(buffer, "JPEG", quality=92)
return buffer.getvalue()
def _png_bytes(img: Image.Image) -> bytes:
import io
buffer = io.BytesIO()
img.save(buffer, "PNG")
return buffer.getvalue()
if __name__ == "__main__":
main()