#!/usr/bin/env python3
"""Simulated phone-scan test of printed resume QR codes.

This is a SIMULATION. No physical phone or printer is involved. Each attempt:

  1. prints a QR code onto a virtual sheet of paper (printer raster at 300 or
     150 dpi, dot gain, resume text and a rule line next to the code),
  2. projects that sheet into one video frame of a virtual phone camera
     (pinhole model, 26 mm-equivalent main camera, 1920x1080 or 1280x720 frame,
     hand-held distance jitter, tilt, roll),
  3. degrades the frame (lens/ISP blur, autofocus error and near-focus limit,
     hand-shake motion blur, paper texture, uneven light, occasional glare,
     exposure and flare, sensor noise, JPEG),
  4. runs three decoders on that frame and records an exact-match success.

Everything is seeded per attempt, so results do not depend on worker count or
order. Re-run (from the repo root):

  uv run --no-project --python 3.12 \
    --with segno==1.6.6 --with opencv-contrib-python-headless==4.14.0.94 \
    --with zxing-cpp==3.1.1 --with numpy==2.5.4 --with pillow==13.0.0 \
    python marketing/research/qr-scan-test/simulate.py

See README.md in this folder for the model, assumptions and limits.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import math
import os
import sys
import time
import urllib.request
import zlib
from concurrent.futures import ProcessPoolExecutor
from importlib import metadata
from pathlib import Path

import cv2
import numpy as np
import segno
import zxingcpp
from PIL import Image, ImageDraw, ImageFont

HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
OUT_RAW = HERE / "raw.csv"
OUT_JSON = REPO / "content" / "guides" / "data" / "qr-scan-test.json"
OUT_STATIC = REPO / "static" / "guides" / "qr-code-on-resume"
OUT_PUBLIC_CSV = OUT_STATIC / "qr-scan-test-2026-10.csv"

RUN_DATE = "2026-10-22"
MASTER_SEED = 20261022
TRIALS = 40
PASS_PCT = 95.0

# --------------------------------------------------------------------------
# Inputs
# --------------------------------------------------------------------------
URLS = {
    "resume_site": "https://jordanlee.aboutstack.com",
    "linkedin_clean": "https://www.linkedin.com/in/jordanlee",
    "linkedin_share": (
        "https://www.linkedin.com/in/jordan-lee-4b2a19173?utm_source=share"
        "&utm_campaign=share_via&utm_content=profile&utm_medium=ios_app"
    ),
    "drive_share": (
        "https://drive.google.com/file/d/1A2b3C4d5E6f7G8h9I0jKlMnOpQrStUvW/view?usp=sharing"
    ),
}
URL_LABELS = {
    "resume_site": "Resume site",
    "linkedin_clean": "LinkedIn (clean)",
    "linkedin_share": "LinkedIn share link",
    "drive_share": "Drive share link",
}

SIZES_IN = [0.5, 0.6, 0.75, 1.0, 1.25, 1.5, 2.0]
DISTANCES_CM = [15, 20, 30, 45]

# Printed colours. Reflectance = relative luminance of the sRGB colour,
# floored at the reflectance of printed black. Paper white = 1.0.
BLACK_FLOOR = 0.045  # laser/inkjet black ~ optical density 1.35
# halftone = which printed tone is a screen of black dots (how a printer makes
# a light gray or a pale tint); blue is treated as a solid colour ink.
COLORS = {
    "black_on_white": dict(fg="#000000", bg="#ffffff", label="Black on white", halftone=()),
    "brand_blue": dict(fg="#2451f0", bg="#ffffff", label="Blue #2451f0 on white", halftone=()),
    "light_gray": dict(fg="#9aa0a6", bg="#ffffff", label="Light gray #9aa0a6 on white", halftone=("fg",)),
    "inverted": dict(fg="#ffffff", bg="#000000", label="Inverted (white on black)", halftone=()),
    "black_on_tint": dict(fg="#000000", bg="#e8eefc", label="Black on light tint #e8eefc", halftone=("bg",)),
}
BAYER4 = (np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], np.float32) + 0.5) / 16
TEXT_COLOR = "#222222"

# --------------------------------------------------------------------------
# Camera model
# --------------------------------------------------------------------------
# Main ("1x") phone camera, ~26 mm full-frame equivalent. Quoted on the 36 mm
# side that gives 2*atan(18/26) = 69.4 deg; we use 69 deg across the frame's
# LONG axis (16:9 video is a crop of the 4:3 sensor that keeps its full width).
# With the phone held upright the long axis is vertical. Pixels are square,
# so pixels-per-degree (and pixels-per-module) does not depend on orientation.
HFOV_LONG_DEG = 69.0
FRAMES = {
    "1080p": dict(long=1920, short=1080),
    "720p": dict(long=1280, short=720),
}
for _f in FRAMES.values():
    _f["f_px"] = (_f["long"] / 2) / math.tan(math.radians(HFOV_LONG_DEG / 2))
    _f["vfov_short_deg"] = 2 * math.degrees(math.atan((_f["short"] / 2) / _f["f_px"]))

APERTURE_MM = 3.5           # entrance pupil: ~6.3 mm focal length at f/1.8
NEAR_FOCUS_MM = (100, 150)  # closest focus of a main lens, sampled per attempt
AF_ERROR_SD = 0.03          # focus lands within ~3% of the true distance (1 sd)
DIST_JITTER = 0.10          # hand-held distance is +/-10% of nominal (uniform)
ROLL_DEG = 15               # in-plane rotation, uniform +/-15 deg
TILT_DEFAULT = (0.0, 20.0)  # perspective tilt, uniform, about a random axis
LENS_SIGMA_PX = (0.5, 0.9)  # optics + demosaic + scaling softness (Gaussian sd)
SHAKE_DEG_S = 2.0           # hand-shake angular speed, half-normal scale
EXPOSURE_S = (1 / 120, 1 / 30)  # indoor video exposure time
WHITE_LEVEL = (0.55, 0.90)  # auto-exposure: paper white in linear units
FLARE = (0.005, 0.03)       # veiling glare added to every pixel (fraction of white)
LIGHT_GRADIENT = (0.0, 0.30)  # illumination change across the crop
GLARE_P = 0.15              # chance of a soft glare spot
GLARE_AMP = (0.10, 0.40)
TEXTURE_SD = 0.02           # paper texture, multiplicative
NOISE_SD_DN = (1.5, 4.0)    # sensor noise after tone curve, 8-bit DN
JPEG_Q = (75, 95)
DOT_GAIN_MM = 0.03          # printer ink spread (Gaussian sd)
SUPERSAMPLE = 3
# A "scan attempt" = a short burst of preview frames with the same hand pose,
# lighting, exposure and focus. Only tremor, shake blur and noise change per
# frame. 5 frames = 1/6 s at 30 fps (people usually hold for 1-2 s, so this
# is conservative). Success = the exact URL decodes in any frame of the burst.
FRAMES_PER_ATTEMPT = 5
TREMOR_ROLL_DEG = 0.15
TREMOR_SHIFT_PX = 0.5
LOGO_AREA = 0.17            # centre logo covers 17% of the code area

WECHAT_COMMIT = "a8b69ccc738421293254aec5ddb38bd523503252"
WECHAT_FILES = {
    "detect.prototxt": "6fb4976b32695f9f5c6305c19f12537d",
    "detect.caffemodel": "238e2b2d6f3c18d6c3a30de0c31e23cf",
    "sr.prototxt": "69db99927a70df953b471daaba03fbef",
    "sr.caffemodel": "cbfcd60361a73beb8c583eea7e8e6664",
}
WECHAT_DIR = Path(os.environ.get("QR_WECHAT_MODELS", Path.home() / ".cache" / "qr-scan-test" / "wechat"))

DECODERS = ["zxing", "opencv", "wechat"]


# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def srgb_to_linear(c: float) -> float:
    c = c / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def reflectance(hex_color: str) -> float:
    r, g, b = (int(hex_color[i:i + 2], 16) for i in (1, 3, 5))
    y = 0.2126 * srgb_to_linear(r) + 0.7152 * srgb_to_linear(g) + 0.0722 * srgb_to_linear(b)
    return max(y, BLACK_FLOOR)


def make_qr(url: str, ec: str):
    # boost_error=False: segno would otherwise silently raise the EC level.
    return segno.make(url, error=ec.lower(), boost_error=False, micro=False)


def qr_info(url: str, ec: str = "M") -> dict:
    q = make_qr(url, ec)
    return dict(url=url, chars=len(url), version=q.version, modules=q.symbol_size(border=0)[0],
                ec=q.error, mode=q.mode)


def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (0.0, 0.0)
    p = k / n
    den = 1 + z * z / n
    c = (p + z * z / (2 * n)) / den
    h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / den
    return (max(0.0, c - h) * 100, min(1.0, c + h) * 100)


def pct(k: int, n: int) -> float:
    return round(100.0 * k / n, 1) if n else 0.0


def cond_id(c: dict) -> str:
    keys = ["exp", "url", "ec", "size_in", "dist_cm", "frame", "dpi", "qz", "color", "tilt", "logo"]
    return "|".join(str(c[k]) for k in keys)


def ensure_wechat_models(download: bool) -> bool:
    ok = True
    for name, md5 in WECHAT_FILES.items():
        p = WECHAT_DIR / name
        if not p.exists() and download:
            WECHAT_DIR.mkdir(parents=True, exist_ok=True)
            url = f"https://raw.githubusercontent.com/WeChatCV/opencv_3rdparty/{WECHAT_COMMIT}/{name}"
            try:
                urllib.request.urlretrieve(url, p)
            except Exception as e:  # network optional
                print(f"[wechat] download failed for {name}: {e}", file=sys.stderr)
        if not p.exists() or hashlib.md5(p.read_bytes()).hexdigest() != md5:
            ok = False
    return ok


# --------------------------------------------------------------------------
# Conditions
# --------------------------------------------------------------------------
BASE = dict(ec="M", frame="1080p", dpi=300, qz=4, color="black_on_white", tilt="rand0-20", logo=False)


def C(exp: str, url: str, size: float, dist: float, **kw) -> dict:
    c = dict(BASE, exp=exp, url=url, size_in=round(size, 3), dist_cm=dist)
    c.update(kw)
    c["id"] = cond_id(c)
    return c


def build_conditions() -> list[dict]:
    cs: list[dict] = []
    for u in URLS:
        for s in SIZES_IN:
            for d in DISTANCES_CM:
                cs.append(C("grid", u, s, d))
    for u in URLS:
        for s in SIZES_IN:
            for d in DISTANCES_CM:
                cs.append(C("frame720", u, s, d, frame="720p"))
    for u in URLS:
        for s in SIZES_IN:
            cs.append(C("dpi150", u, s, 30, dpi=150))
    for s in (1.0, 0.75):
        for q in (4, 2, 1, 0):
            cs.append(C("quiet", "resume_site", s, 30, qz=q))
        for col in COLORS:
            cs.append(C("contrast", "resume_site", s, 30, color=col))
        for t in (0, 20, 35, 50):
            cs.append(C("tilt", "resume_site", s, 30, tilt=t))
    for s in (0.75, 1.0):
        for e in "LMQH":
            cs.append(C("ec", "drive_share", s, 30, ec=e))
    for e, logo in (("M", False), ("M", True), ("H", False), ("H", True)):
        cs.append(C("logo", "resume_site", 1.0, 30, ec=e, logo=logo))
    for d in (10, 12):
        cs.append(C("close", "resume_site", 1.0, d))
    for s in np.arange(0.40, 1.1001, 0.05):
        cs.append(C("fine", "resume_site", float(s), 30))
    for s in np.arange(0.50, 1.5001, 0.05):
        cs.append(C("fine", "drive_share", float(s), 30))
    ids = [c["id"] for c in cs]
    assert len(ids) == len(set(ids)), "duplicate condition ids"
    return cs


# --------------------------------------------------------------------------
# Printing: render the code (plus resume context) onto virtual paper
# --------------------------------------------------------------------------
def render_paper(c: dict) -> dict:
    url = URLS[c["url"]]
    q = make_qr(url, c["ec"])
    m = np.array(q.matrix, dtype=bool)  # True = dark module
    n = m.shape[0]
    dpi = c["dpi"]
    dot_mm = 25.4 / dpi
    w_dots = int(round(c["size_in"] * dpi))
    mod_dots = w_dots / n
    half = int(round(1.8 * w_dots)) + 40
    size = 2 * half
    col = COLORS[c["color"]]
    fg, bg = reflectance(col["fg"]), reflectance(col["bg"])
    inverted = c["color"] == "inverted"
    page = 1.0 if inverted else bg
    ink_text = reflectance(TEXT_COLOR)
    img = np.full((size, size), page, np.float32)

    x0 = half - w_dots // 2
    x1 = x0 + w_dots
    # Printer rasterisation: each dot takes the module under its centre, so
    # module widths alternate between floor/ceil dots like a real print.
    idx = np.minimum(((np.arange(w_dots) + 0.5) * n / w_dots).astype(int), n - 1)
    code = m[np.ix_(idx, idx)]
    qz_dots = int(round(c["qz"] * mod_dots))
    if inverted:  # dark block = code area + quiet zone, modules knocked out
        img[x0 - qz_dots:x1 + qz_dots, x0 - qz_dots:x1 + qz_dots] = bg
    img[x0:x1, x0:x1] = np.where(code, fg, bg)

    if c["logo"]:
        side = int(round(math.sqrt(LOGO_AREA) * w_dots))
        l0 = half - side // 2
        img[l0:l0 + side, l0:l0 + side] = 1.0 if not inverted else bg
        pad = max(1, int(round(side * 0.10)))
        inner = np.zeros((side - 2 * pad, side - 2 * pad), np.uint8)
        r = max(1, inner.shape[0] // 5)
        cv2.rectangle(inner, (r, 0), (inner.shape[1] - r - 1, inner.shape[0] - 1), 1, -1)
        cv2.rectangle(inner, (0, r), (inner.shape[1] - 1, inner.shape[0] - r - 1), 1, -1)
        for cx, cy in ((r, r), (inner.shape[1] - r - 1, r), (r, inner.shape[0] - r - 1),
                       (inner.shape[1] - r - 1, inner.shape[0] - r - 1)):
            cv2.circle(inner, (cx, cy), r, 1, -1)
        cv2.circle(inner, (inner.shape[1] // 2, inner.shape[0] // 2), inner.shape[0] // 4, 0, -1)
        sub = img[l0 + pad:l0 + side - pad, l0 + pad:l0 + side - pad]
        sub[inner == 1] = reflectance("#2451f0")

    # Resume context: text lines to the left of the code and a rule line
    # under it, both placed exactly at the quiet-zone distance (crowded header).
    rng = np.random.default_rng([MASTER_SEED, zlib.crc32(c["id"].encode()), 999_999])
    gap = qz_dots + (int(round(4 * mod_dots)) if inverted else 0)
    text_right = x0 - gap
    line_h = int(round(2.3 / dot_mm))
    pitch = int(round(4.6 / dot_mm))
    stroke = max(1, int(round(0.15 / dot_mm)))
    y = int(rng.integers(0, pitch))
    while y + line_h < size:
        x = text_right
        while x > 0:
            wlen = int(round(rng.uniform(2, 12) / dot_mm))
            a = max(0, x - wlen)
            ncols = max(1, (x - a) // stroke)
            pattern = rng.random(ncols) < 0.55
            for j, on in enumerate(pattern):
                if on:
                    img[y:y + line_h, a + j * stroke:a + (j + 1) * stroke] = ink_text
            x = a - int(round(1.4 / dot_mm))
        y += pitch
    rule_y = x1 + gap
    rule_h = max(1, int(round(0.4 / dot_mm)))
    img[rule_y:rule_y + rule_h, 0:min(size, x1 + int(0.3 * w_dots))] = ink_text

    # Halftone screening of light tones (ordered dither at the printer raster).
    for which in col["halftone"]:
        v = fg if which == "fg" else bg
        cover = (1 - v) / (1 - BLACK_FLOOR)
        thr = np.tile(BAYER4, (size // 4 + 1, size // 4 + 1))[:size, :size]
        mask = img == np.float32(v)
        img[mask] = np.where(thr[mask] < cover, BLACK_FLOOR, 1.0)

    img = cv2.GaussianBlur(img, (0, 0), DOT_GAIN_MM / dot_mm)
    return dict(img=img, dot_mm=dot_mm, center=half - w_dots // 2 + w_dots / 2, w_mm=w_dots * dot_mm,
                n=n, version=q.version, page=page, url=url)


# --------------------------------------------------------------------------
# Camera: one simulated preview/video frame
# --------------------------------------------------------------------------
def disk_kernel(diam: float) -> np.ndarray:
    r = diam / 2
    k = int(math.ceil(r)) * 2 + 1
    ss = 8
    yy, xx = np.mgrid[0:k * ss, 0:k * ss]
    yy = (yy + 0.5) / ss - k / 2
    xx = (xx + 0.5) / ss - k / 2
    m = ((xx ** 2 + yy ** 2) <= r * r).astype(np.float32)
    m = cv2.resize(m, (k, k), interpolation=cv2.INTER_AREA)
    return m / m.sum()


def line_kernel(length: float, ang: float) -> np.ndarray:
    k = int(math.ceil(length)) + 3
    k += 1 - k % 2
    ss = 8
    big = np.zeros((k * ss, k * ss), np.float32)
    c = k * ss / 2
    dx, dy = math.cos(ang) * length * ss / 2, math.sin(ang) * length * ss / 2
    cv2.line(big, (int(c - dx), int(c - dy)), (int(c + dx), int(c + dy)), 1.0, ss)
    m = cv2.resize(big, (k, k), interpolation=cv2.INTER_AREA)
    return m / m.sum()


def capture(c: dict, paper: dict, rng: np.random.Generator, rf: np.random.Generator,
            min_roi_px: int = 0) -> tuple[np.ndarray, dict]:
    """Render one frame.

    rng = attempt generator (pose, lighting, exposure, focus, lens: identical
    for every frame of a burst because it is re-created from the same seed);
    rf = frame generator (hand tremor, motion blur, sensor noise realisation).
    """
    fr = FRAMES[c["frame"]]
    f = fr["f_px"]
    d = c["dist_cm"] * 10 * rng.uniform(1 - DIST_JITTER, 1 + DIST_JITTER)
    if c["tilt"] == "rand0-20":
        tilt = rng.uniform(*TILT_DEFAULT)
    else:
        tilt = float(c["tilt"])
    axis = rng.uniform(0, 2 * math.pi)
    roll = rng.uniform(-ROLL_DEG, ROLL_DEG)
    # Hand tremor between consecutive frames: tiny roll + sub-pixel shift.
    roll_f = roll + rf.normal(0, TREMOR_ROLL_DEG)
    shift = rf.uniform(-TREMOR_SHIFT_PX, TREMOR_SHIFT_PX, 2)

    rv = np.array([math.cos(axis), math.sin(axis), 0.0]) * math.radians(tilt)
    r_tilt, _ = cv2.Rodrigues(rv)
    cr, sr = math.cos(math.radians(roll_f)), math.sin(math.radians(roll_f))
    rz = np.array([[cr, -sr, 0], [sr, cr, 0], [0, 0, 1]])
    rot = rz @ r_tilt
    h_mm = np.diag([f, f, 1.0]) @ np.column_stack([rot[:, 0], rot[:, 1], [0, 0, d]])
    dm = paper["dot_mm"]
    cx = paper["center"]
    a = np.array([[dm, 0, -cx * dm], [0, dm, -cx * dm], [0, 0, 1]])
    h = h_mm @ a

    # Crop window (region of interest) around the code: code + 50% each side.
    w_mm = paper["w_mm"]
    e = w_mm * 1.0
    corners = np.array([[-e, -e, 1], [e, -e, 1], [e, e, 1], [-e, e, 1]], float).T
    pr = h_mm @ corners
    pr = pr[:2] / pr[2]
    hw, hh = fr["short"] / 2, fr["long"] / 2
    u0 = max(-hw, math.floor(pr[0].min()))
    u1 = min(hw, math.ceil(pr[0].max()))
    v0 = max(-hh, math.floor(pr[1].min()))
    v1 = min(hh, math.ceil(pr[1].max()))
    if min_roi_px:
        cu, cv_ = (u0 + u1) / 2, (v0 + v1) / 2
        half = max(min_roi_px / 2, (u1 - u0) / 2, (v1 - v0) / 2)
        u0, u1 = int(cu - half), int(cu + half)
        v0, v1 = int(cv_ - half), int(cv_ + half)
    wr, hr = int(u1 - u0), int(v1 - v0)
    ss = SUPERSAMPLE
    t = np.array([[ss, 0, (shift[0] - u0) * ss], [0, ss, (shift[1] - v0) * ss], [0, 0, 1]], float)
    big = cv2.warpPerspective(paper["img"], t @ h, (wr * ss, hr * ss), flags=cv2.INTER_LINEAR,
                              borderMode=cv2.BORDER_CONSTANT, borderValue=float(paper["page"]))
    img = cv2.resize(big, (wr, hr), interpolation=cv2.INTER_AREA)

    # Paper texture (fibres), in frame space.
    tex = cv2.GaussianBlur(rng.standard_normal((hr, wr)).astype(np.float32), (0, 0), 1.2)
    tex /= tex.std() + 1e-6
    img *= 1 + TEXTURE_SD * tex

    # Focus: main lenses cannot focus closer than ~10-15 cm.
    near = rng.uniform(*NEAR_FOCUS_MM)
    d_focus = near if d < near else d * (1 + rng.normal(0, AF_ERROR_SD))
    defocus = f * APERTURE_MM * abs(1 / d - 1 / d_focus)  # blur-circle diameter, px
    lens_sigma = rng.uniform(*LENS_SIGMA_PX)
    t_exp = rng.uniform(*EXPOSURE_S)               # set by auto-exposure: per attempt
    shake = abs(rf.normal(0, SHAKE_DEG_S))         # hand shake: per frame
    motion = f * math.radians(shake) * t_exp
    m_ang = rf.uniform(0, math.pi)
    img = cv2.GaussianBlur(img, (0, 0), lens_sigma)
    if defocus > 0.6:
        img = cv2.filter2D(img, -1, disk_kernel(defocus), borderType=cv2.BORDER_REFLECT)
    if motion > 0.6:
        img = cv2.filter2D(img, -1, line_kernel(motion, m_ang), borderType=cv2.BORDER_REFLECT)

    # Light: gradient + optional glare spot, exposure, flare.
    yy, xx = np.mgrid[0:hr, 0:wr].astype(np.float32)
    xx = xx / max(wr - 1, 1) - 0.5
    yy = yy / max(hr - 1, 1) - 0.5
    g = rng.uniform(*LIGHT_GRADIENT)
    g_ang = rng.uniform(0, 2 * math.pi)
    illum = 1 + g * (math.cos(g_ang) * xx + math.sin(g_ang) * yy)
    white = rng.uniform(*WHITE_LEVEL)
    flare = rng.uniform(*FLARE)
    lin = white * (img * illum + flare)
    glare = 0.0
    if rng.random() < GLARE_P:
        glare = rng.uniform(*GLARE_AMP)
        gx, gy = rng.uniform(-0.4, 0.4, 2)
        gr = rng.uniform(0.15, 0.45)
        lin += white * glare * np.exp(-((xx - gx) ** 2 + (yy - gy) ** 2) / (2 * gr * gr))
    out = 255.0 * np.clip(lin, 0, 1) ** (1 / 2.2)
    noise = rng.uniform(*NOISE_SD_DN)
    out += rf.normal(0, noise, out.shape)
    u8 = np.clip(np.rint(out), 0, 255).astype(np.uint8)
    q = int(rng.integers(JPEG_Q[0], JPEG_Q[1] + 1))
    ok, buf = cv2.imencode(".jpg", u8, [cv2.IMWRITE_JPEG_QUALITY, q])
    u8 = cv2.imdecode(buf, cv2.IMREAD_GRAYSCALE)

    mod_mm = w_mm / paper["n"]
    params = dict(d_mm=round(d, 1), tilt_deg=round(tilt, 1), roll_deg=round(roll, 1),
                  px_per_module=round(f * mod_mm / d, 2), roi=f"{wr}x{hr}",
                  lens_sigma=round(lens_sigma, 2), defocus_px=round(defocus, 2),
                  motion_px=round(motion, 2), white=round(white, 3), flare=round(flare, 4),
                  gradient=round(g, 3), glare=round(glare, 3), noise_dn=round(noise, 2), jpeg_q=q)
    return u8, params


# --------------------------------------------------------------------------
# Decoders
# --------------------------------------------------------------------------
_W: dict = {}


def worker_init(use_wechat_cnn: bool) -> None:
    cv2.setNumThreads(1)
    _W["opencv"] = cv2.QRCodeDetector()
    if use_wechat_cnn:
        p = [str(WECHAT_DIR / n) for n in ("detect.prototxt", "detect.caffemodel", "sr.prototxt", "sr.caffemodel")]
        _W["wechat"] = cv2.wechat_qrcode_WeChatQRCode(*p)
    else:
        _W["wechat"] = cv2.wechat_qrcode_WeChatQRCode()
    _W["papers"] = {}


def decode_all(img: np.ndarray, url: str, which=DECODERS) -> dict:
    res = {}
    if "zxing" in which:
        try:
            res["zxing"] = any(r.text == url for r in
                               zxingcpp.read_barcodes(img, formats=zxingcpp.BarcodeFormat.QRCode))
        except Exception:
            res["zxing"] = False
    if "opencv" in which:
        try:
            txt, _, _ = _W["opencv"].detectAndDecode(img)
            res["opencv"] = txt == url
        except Exception:
            res["opencv"] = False
    if "wechat" in which:
        try:
            texts, _ = _W["wechat"].detectAndDecode(img)
            res["wechat"] = url in texts
        except Exception:
            res["wechat"] = False
    return res


def attempt_rng(c: dict, trial: int) -> np.random.Generator:
    return np.random.default_rng([MASTER_SEED, zlib.crc32(c["id"].encode()), trial])


def frame_rng(c: dict, trial: int, frame: int) -> np.random.Generator:
    return np.random.default_rng([MASTER_SEED, zlib.crc32(c["id"].encode()), trial, 1000 + frame])


def run_attempt(c: dict, paper: dict, t: int, decoders=DECODERS) -> dict:
    """Decode a burst of frames. Returns per decoder the 1-based frame of the
    first exact decode (0 = never), plus frame-1 parameters."""
    first = {dn: 0 for dn in decoders}
    p0 = None
    for fi in range(FRAMES_PER_ATTEMPT):
        todo = [dn for dn in decoders if not first[dn]]
        if not todo:
            break
        img, p = capture(c, paper, attempt_rng(c, t), frame_rng(c, t, fi))
        if p0 is None:
            p0 = p
        r = decode_all(img, paper["url"], todo)
        for dn in todo:
            if r[dn]:
                first[dn] = fi + 1
    return dict(p0=p0, first=first)


def run_condition(args) -> list[dict]:
    c, trials = args
    paper = render_paper(c)
    rows = []
    for t in range(trials):
        a = run_attempt(c, paper, t)
        rows.append(dict(cond_id=c["id"], trial=t, **a["p0"],
                         **{dn: int(a["first"][dn] > 0) for dn in DECODERS},
                         **{f"{dn}_f1": int(a["first"][dn] == 1) for dn in DECODERS},
                         **{f"{dn}_frame": a["first"][dn] for dn in DECODERS}))
    return rows


# --------------------------------------------------------------------------
# Aggregation
# --------------------------------------------------------------------------
def summarise(conds: list[dict], rows: list[dict]) -> dict[str, dict]:
    by: dict[str, list[dict]] = {}
    for r in rows:
        by.setdefault(r["cond_id"], []).append(r)
    out = {}
    for c in conds:
        rs = by[c["id"]]
        n = len(rs)
        k = {dname: sum(r[dname] for r in rs) for dname in DECODERS}
        k_either = sum(1 for r in rs if any(r[dn] for dn in DECODERS))
        k_f1 = {dname: sum(r[f"{dname}_f1"] for r in rs) for dname in DECODERS}
        # Headline = the stricter of the two decoder families (ZXing vs OpenCV).
        strict = "zxing" if k["zxing"] <= k["opencv"] else "opencv"
        info = qr_info(URLS[c["url"]], c["ec"])
        mod_mm = c["size_in"] * 25.4 / info["modules"]
        f = FRAMES[c["frame"]]["f_px"]
        out[c["id"]] = dict(
            c=c, n=n, k=k, k_either=k_either, k_head=k[strict], head_decoder=strict, k_f1=k_f1,
            k_head_f1=min(k_f1["zxing"], k_f1["opencv"]),
            pct_head=pct(k[strict], n), modules=info["modules"], version=info["version"],
            module_mm=round(mod_mm, 3), px_per_module=round(f * mod_mm / (c["dist_cm"] * 10), 2),
        )
    return out


def find(S: dict, **kw) -> dict:
    hits = [s for s in S.values() if all(s["c"].get(k) == v for k, v in kw.items())]
    assert len(hits) == 1, (kw, len(hits))
    return hits[0]


def min_passing(S: dict, exp: str, url: str, dist: float, sizes, metric=None, **kw) -> float | None:
    """Smallest size from which every larger tested size also passes."""
    metric = metric or (lambda st: st["pct_head"])
    best = None
    for s in sorted(sizes, reverse=True):
        st = find(S, exp=exp, url=url, size_in=round(s, 3), dist_cm=dist, **kw)
        if metric(st) >= PASS_PCT:
            best = round(s, 3)
        else:
            break
    return best


def val(st: dict) -> dict:
    return {"pct": st["pct_head"], "k": st["k_head"], "n": st["n"]}


def size_label(s: float | None, top: float) -> str:
    if s is None:
        return f"over {top:g} in"
    return f"{s:g} in"


def fmt_size_key(s: float) -> str:
    return f"{s:g}".replace(".", "_")


def build_json(conds, S, decoder_names, runtime_s, n_attempts) -> dict:
    infos = {k: qr_info(u) for k, u in URLS.items()}
    fr = FRAMES["1080p"]
    H: dict = {}
    # Min passing size, every URL x distance, both frame sizes.
    table_rows = []
    min_size = {}
    for u in URLS:
        cells = []
        for d in DISTANCES_CM:
            m = min_passing(S, "grid", u, d, SIZES_IN)
            H[f"min_in_{u}_{d}cm"] = m
            H[f"min_in_{u}_{d}cm_label"] = size_label(m, SIZES_IN[-1])
            min_size.setdefault(u, {})[f"{d}cm"] = m
            cells.append(size_label(m, SIZES_IN[-1]))
            m7 = min_passing(S, "frame720", u, d, SIZES_IN)
            H[f"min_in_{u}_{d}cm_720p"] = m7
            H[f"min_in_{u}_{d}cm_720p_label"] = size_label(m7, SIZES_IN[-1])
            min_size[u][f"{d}cm_720p"] = m7
        m150 = min_passing(S, "dpi150", u, 30, SIZES_IN)
        H[f"min_in_{u}_30cm_150dpi"] = m150
        H[f"min_in_{u}_30cm_150dpi_label"] = size_label(m150, SIZES_IN[-1])
        min_size[u]["30cm_150dpi"] = m150
        i = infos[u]
        table_rows.append({"label": f"{URL_LABELS[u]} ({i['chars']} chars, {i['modules']}x{i['modules']})",
                           "url_key": u, "values": cells})

    # Fine sweep at 30 cm -> module size / pixels per module needed.
    fine_sizes = {
        "resume_site": [round(float(s), 3) for s in np.arange(0.40, 1.1001, 0.05)],
        "drive_share": [round(float(s), 3) for s in np.arange(0.50, 1.5001, 0.05)],
    }
    fine = {}
    for u, sizes in fine_sizes.items():
        m = min_passing(S, "fine", u, 30, sizes)
        n_mod = infos[u]["modules"]
        mm = round(m * 25.4 / n_mod, 3) if m else None
        ppm = round(fr["f_px"] * (m * 25.4 / n_mod) / 300, 2) if m else None
        fine[u] = dict(min_in=m, module_mm=mm, px_per_module=ppm,
                       rows=[{"size_in": s, **val(find(S, exp="fine", url=u, size_in=s, dist_cm=30))} for s in sizes])
        H[f"fine_min_in_{u}_30cm"] = m
        H[f"fine_min_cm_{u}_30cm"] = round(m * 2.54, 2) if m else None
        H[f"module_mm_needed_{u}_30cm"] = mm
        H[f"px_per_module_needed_{u}"] = ppm
        for dn in DECODERS:
            md = min_passing(S, "fine", u, 30, sizes, metric=lambda st, dn=dn: pct(st["k"][dn], st["n"]))
            H[f"fine_min_in_{u}_30cm_{dn}"] = md
            H[f"px_per_module_needed_{u}_{dn}"] = round(fr["f_px"] * (md * 25.4 / n_mod) / 300, 2) if md else None
    H["module_mm_needed"] = max(x for x in (fine["resume_site"]["module_mm"], fine["drive_share"]["module_mm"]) if x)
    H["module_mm_needed_per_10cm"] = round(H["module_mm_needed"] / 3, 3)
    H["px_per_module_needed"] = max(x for x in (fine["resume_site"]["px_per_module"], fine["drive_share"]["px_per_module"]) if x)

    # 10:1 rule: code width >= distance / 10.
    checks = []
    for u in URLS:
        for d in DISTANCES_CM:
            rule_in = d / 10 / 2.54
            # rule size rounded up to the next tested size
            tested = [s for s in SIZES_IN if s >= rule_in - 1e-9]
            rs = tested[0] if tested else None
            m = min_size[u][f"{d}cm"]
            holds = m is not None and m <= rule_in + 1e-9
            checks.append(dict(url=u, dist_cm=d, rule_in=round(rule_in, 2), min_in=m, holds=holds,
                               next_tested_size=rs))
    checks720 = []
    for u in URLS:
        for d in DISTANCES_CM:
            rule_in = d / 10 / 2.54
            m = min_size[u][f"{d}cm_720p"]
            checks720.append(dict(url=u, dist_cm=d, rule_in=round(rule_in, 2), min_in=m,
                                  holds=m is not None and m <= rule_in + 1e-9))
    H["rule_10_1_holds_720p"] = all(x["holds"] for x in checks720)
    H["rule_10_1_holds_count_720p"] = sum(x["holds"] for x in checks720)
    for u in URLS:
        m = min_size[u]["30cm"]
        H[f"implied_ratio_grid_{u}_30cm"] = round(300 / (m * 25.4), 1) if m else None
    H["rule_10_1_holds"] = all(x["holds"] for x in checks)
    H["rule_10_1_holds_count"] = sum(x["holds"] for x in checks)
    H["rule_10_1_checks"] = len(checks)
    for u in URLS:
        H[f"rule_10_1_holds_{u}"] = all(x["holds"] for x in checks if x["url"] == u)
    H["rule_10_1_size_in_30cm"] = round(30 / 10 / 2.54, 2)
    for u in fine:
        m = fine[u]["min_in"]
        H[f"implied_ratio_{u}"] = round(300 / (m * 25.4), 1) if m else None

    # Point facts at the reference condition (1.0 in, 30 cm).
    for u in URLS:
        for s in (0.5, 0.75, 1.0, 1.5):
            st = find(S, exp="grid", url=u, size_in=s, dist_cm=30)
            H[f"pct_{u}_{fmt_size_key(s)}in_30cm"] = st["pct_head"]
    for s in (1.0, 0.75):
        sfx = "" if s == 1.0 else "_075"
        for q in (4, 2, 1, 0):
            st = find(S, exp="quiet", size_in=s, qz=q)
            H[f"quiet{q}_pct{sfx}"] = st["pct_head"]
            for dn in DECODERS:
                H[f"quiet{q}_pct{sfx}_{dn}"] = pct(st["k"][dn], st["n"])
        for col, key in (("black_on_white", "black"), ("brand_blue", "blue"), ("light_gray", "gray"),
                         ("inverted", "inverted"), ("black_on_tint", "tint")):
            st = find(S, exp="contrast", size_in=s, color=col)
            H[f"{key}_pct{sfx}"] = st["pct_head"]
            for dn in DECODERS:
                H[f"{key}_pct{sfx}_{dn}"] = pct(st["k"][dn], st["n"])
        for t in (0, 20, 35, 50):
            st = find(S, exp="tilt", size_in=s, tilt=t)
            H[f"tilt{t}_pct{sfx}"] = st["pct_head"]
    for s in (0.75, 1.0):
        for e in "LMQH":
            st = find(S, exp="ec", size_in=s, ec=e)
            H[f"ec_{e}_{fmt_size_key(s)}in_pct"] = st["pct_head"]
            H[f"ec_{e}_modules_drive_share"] = st["modules"]
    for e, logo in (("M", False), ("M", True), ("H", False), ("H", True)):
        st = find(S, exp="logo", ec=e, logo=logo)
        H[f"{'logo' if logo else 'nologo'}_{e.lower()}_pct"] = st["pct_head"]
        for dn in DECODERS:
            H[f"{'logo' if logo else 'nologo'}_{e.lower()}_pct_{dn}"] = pct(st["k"][dn], st["n"])
    H["logo_area_pct"] = round(LOGO_AREA * 100)
    H["logo_h_modules"] = qr_info(URLS["resume_site"], "H")["modules"]
    st150 = find(S, exp="dpi150", url="resume_site", size_in=1.0)
    H["dpi150_pct"] = st150["pct_head"]
    H["dpi300_pct"] = find(S, exp="grid", url="resume_site", size_in=1.0, dist_cm=30)["pct_head"]
    for d in (10, 12):
        H[f"too_close_{d}cm_pct"] = find(S, exp="close", dist_cm=d)["pct_head"]
    # Decoder spread on the main grid
    grid = [s for s in S.values() if s["c"]["exp"] == "grid"]
    tot = sum(s["n"] for s in grid)
    for dn in DECODERS:
        H[f"grid_pct_{dn}"] = pct(sum(s["k"][dn] for s in grid), tot)
    H["grid_pct_either"] = pct(sum(s["k_either"] for s in grid), tot)
    H["grid_pct_headline"] = pct(sum(s["k_head"] for s in grid), tot)
    H["grid_head_is_opencv_share"] = pct(sum(1 for s in grid if s["head_decoder"] == "opencv"), len(grid))
    for dn in DECODERS:
        H[f"grid_pct_{dn}_single_frame"] = pct(sum(s["k_f1"][dn] for s in grid), tot)
    H["grid_pct_headline_single_frame"] = pct(sum(s["k_head_f1"] for s in grid), tot)
    ref = find(S, exp="grid", url="resume_site", size_in=1.0, dist_cm=30)
    H["pct_resume_site_1in_30cm_single_frame"] = pct(ref["k_head_f1"], ref["n"])
    H["frames_per_attempt"] = FRAMES_PER_ATTEMPT
    # Decoder comparison at the reference size/distance for the long link
    for u in ("resume_site", "drive_share"):
        for s in (0.5, 0.75, 1.0):
            st = find(S, exp="grid", url=u, size_in=s, dist_cm=30)
            for dn in DECODERS:
                H[f"pct_{u}_{fmt_size_key(s)}in_30cm_{dn}"] = pct(st["k"][dn], st["n"])

    # ---------------- charts (bar-chart component shape) ----------------
    def chart(series, rows):
        return {"series": series, "rows": rows}

    i_r, i_d = infos["resume_site"], infos["drive_share"]
    charts = {}
    charts["size_30cm"] = chart(
        [{"name": f"Resume site ({i_r['chars']} chars)", "tone": "blue"},
         {"name": f"Drive share link ({i_d['chars']} chars)", "tone": "amber"}],
        [{"label": f"{s:g} in", "values": [val(find(S, exp="grid", url=u, size_in=s, dist_cm=30))
                                            for u in ("resume_site", "drive_share")]} for s in SIZES_IN])
    tones4 = ["blue", "sky", "ink", "amber"]
    order4 = ["resume_site", "linkedin_clean", "linkedin_share", "drive_share"]
    charts["size_30cm_all"] = chart(
        [{"name": f"{URL_LABELS[u]} ({infos[u]['chars']} chars)", "tone": t} for u, t in zip(order4, tones4)],
        [{"label": f"{s:g} in", "values": [val(find(S, exp="grid", url=u, size_in=s, dist_cm=30)) for u in order4]}
         for s in SIZES_IN])
    charts["resume_site_by_distance"] = chart(
        [{"name": f"{d} cm", "tone": t} for d, t in zip(DISTANCES_CM, tones4)],
        [{"label": f"{s:g} in", "values": [val(find(S, exp="grid", url="resume_site", size_in=s, dist_cm=d))
                                            for d in DISTANCES_CM]} for s in SIZES_IN])
    charts["drive_share_by_distance"] = chart(
        [{"name": f"{d} cm", "tone": t} for d, t in zip(DISTANCES_CM, tones4)],
        [{"label": f"{s:g} in", "values": [val(find(S, exp="grid", url="drive_share", size_in=s, dist_cm=d))
                                            for d in DISTANCES_CM]} for s in SIZES_IN])
    two = [{"name": "1 in code", "tone": "blue"}, {"name": "0.75 in code", "tone": "amber"}]
    charts["quiet_zone"] = chart(
        [two[0]],
        [{"label": f"{q} module{'s' if q != 1 else ''}", "values": [val(find(S, exp="quiet", size_in=1.0, qz=q))]}
         for q in (4, 2, 1, 0)])
    charts["quiet_zone_two_sizes"] = chart(
        two, [{"label": f"{q} module{'s' if q != 1 else ''}",
               "values": [val(find(S, exp="quiet", size_in=s, qz=q)) for s in (1.0, 0.75)]} for q in (4, 2, 1, 0)])
    charts["contrast"] = chart(
        [two[0]], [{"label": COLORS[col]["label"], "values": [val(find(S, exp="contrast", size_in=1.0, color=col))]}
                   for col in COLORS])
    charts["contrast_two_sizes"] = chart(
        two, [{"label": COLORS[col]["label"],
               "values": [val(find(S, exp="contrast", size_in=s, color=col)) for s in (1.0, 0.75)]} for col in COLORS])
    charts["tilt"] = chart(
        [two[0]], [{"label": f"{t}°", "values": [val(find(S, exp="tilt", size_in=1.0, tilt=t))]} for t in (0, 20, 35, 50)])
    charts["tilt_two_sizes"] = chart(
        two, [{"label": f"{t}°", "values": [val(find(S, exp="tilt", size_in=s, tilt=t)) for s in (1.0, 0.75)]}
              for t in (0, 20, 35, 50)])
    charts["error_correction"] = chart(
        [{"name": "Drive link, 1 in", "tone": "blue"}, {"name": "Drive link, 0.75 in", "tone": "amber"}],
        [{"label": f"{e} ({find(S, exp='ec', size_in=1.0, ec=e)['modules']} modules)",
          "values": [val(find(S, exp="ec", size_in=s, ec=e)) for s in (1.0, 0.75)]} for e in "LMQH"])
    charts["logo"] = chart(
        [{"name": "Resume site, 1 in, 30 cm", "tone": "blue"}],
        [{"label": lab, "values": [val(find(S, exp="logo", ec=e, logo=lg))]}
         for lab, e, lg in (("No logo, EC M", "M", False), ("Logo, EC M", "M", True),
                            ("No logo, EC H", "H", False), ("Logo, EC H", "H", True))])
    charts["dpi_30cm"] = chart(
        [{"name": "Resume site, 300 dpi", "tone": "blue"}, {"name": "Resume site, 150 dpi", "tone": "sky"},
         {"name": "Drive link, 300 dpi", "tone": "amber"}, {"name": "Drive link, 150 dpi", "tone": "ink"}],
        [{"label": f"{s:g} in", "values": [
            val(find(S, exp="grid", url="resume_site", size_in=s, dist_cm=30)),
            val(find(S, exp="dpi150", url="resume_site", size_in=s)),
            val(find(S, exp="grid", url="drive_share", size_in=s, dist_cm=30)),
            val(find(S, exp="dpi150", url="drive_share", size_in=s))]} for s in SIZES_IN])
    charts["frame_30cm"] = chart(
        [{"name": "Resume site, 1080p frame", "tone": "blue"}, {"name": "Resume site, 720p frame", "tone": "sky"},
         {"name": "Drive link, 1080p frame", "tone": "amber"}, {"name": "Drive link, 720p frame", "tone": "ink"}],
        [{"label": f"{s:g} in", "values": [
            val(find(S, exp="grid", url="resume_site", size_in=s, dist_cm=30)),
            val(find(S, exp="frame720", url="resume_site", size_in=s, dist_cm=30)),
            val(find(S, exp="grid", url="drive_share", size_in=s, dist_cm=30)),
            val(find(S, exp="frame720", url="drive_share", size_in=s, dist_cm=30))]} for s in SIZES_IN])

    trials_set = sorted({s["n"] for s in S.values()})
    return {
        "_note": "Generated by marketing/research/qr-scan-test/simulate.py. SIMULATION, not a physical phone test. Don't edit by hand.",
        "run_date": RUN_DATE,
        "simulated": True,
        "seed": MASTER_SEED,
        "n_attempts": n_attempts,
        "n_conditions": len(S),
        "n_conditions_main_grid": len(grid),
        "trials_per_condition": trials_set[0] if len(trials_set) == 1 else trials_set,
        "runtime_s": round(runtime_s, 1),
        "decoders": decoder_names,
        "headline_rule": ("Per condition, the headline success rate is the lower of zxing-cpp and OpenCV "
                          "QRCodeDetector (the stricter decoder family). A condition passes at >= 95%. "
                          "WeChat (CNN) and 'either decoder' rates are reported separately."),
        "camera": {
            "frame": "1920x1080", "frame_alt": "1280x720",
            "lens": "main camera, ~26 mm equivalent",
            "hfov_deg": HFOV_LONG_DEG, "hfov_axis": "long side of the frame (vertical when the phone is held upright)",
            "fov_short_axis_deg": round(fr["vfov_short_deg"], 1),
            "focal_px_1080p": round(fr["f_px"], 1), "focal_px_720p": round(FRAMES["720p"]["f_px"], 1),
            "distances_cm": DISTANCES_CM, "distance_jitter_pct": int(DIST_JITTER * 100),
            "near_focus_cm": [NEAR_FOCUS_MM[0] / 10, NEAR_FOCUS_MM[1] / 10],
            "aperture_mm": APERTURE_MM, "tilt_default_deg": list(TILT_DEFAULT), "roll_deg": ROLL_DEG,
            "frames_per_attempt": FRAMES_PER_ATTEMPT, "fps_assumed": 30,
        },
        "print": {"dpi": 300, "dpi_alt": 150, "quiet_zone_modules": 4, "sizes_in": SIZES_IN,
                  "dot_gain_mm": DOT_GAIN_MM, "black_reflectance": BLACK_FLOOR},
        "urls": infos,
        "urls_ec": {e: qr_info(URLS["drive_share"], e) for e in "LMQH"},
        "pass_threshold_pct": PASS_PCT,
        "headline": H,
        "min_size_in": min_size,
        "min_size_table": {"columns": [f"{d} cm" for d in DISTANCES_CM], "rows": table_rows},
        "rule_10_1": checks,
        "rule_10_1_720p": checks720,
        "fine_sweep_30cm": fine,
        "charts": charts,
    }


# --------------------------------------------------------------------------
# Output files
# --------------------------------------------------------------------------
RAW_FIELDS = ["cond_id", "exp", "url", "ec", "size_in", "dist_cm", "frame", "dpi", "qz", "color", "tilt", "logo",
              "trial", "d_mm", "tilt_deg", "roll_deg", "px_per_module", "roi", "lens_sigma", "defocus_px",
              "motion_px", "white", "flare", "gradient", "glare", "noise_dn", "jpeg_q",
              "zxing", "opencv", "wechat", "zxing_f1", "opencv_f1", "wechat_f1",
              "zxing_frame", "opencv_frame", "wechat_frame"]


def write_raw(conds, rows):
    cmap = {c["id"]: c for c in conds}
    with open(OUT_RAW, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=RAW_FIELDS)
        w.writeheader()
        for r in rows:
            c = cmap[r["cond_id"]]
            w.writerow({**{k: c[k] for k in RAW_FIELDS if k in c}, **r})


def write_public_csv(S):
    fields = ["experiment", "url_key", "url_chars", "qr_version", "modules", "ec", "size_in", "distance_cm",
              "frame", "dpi", "quiet_zone_modules", "color", "tilt", "logo", "module_mm", "px_per_module",
              "attempts", "decoded", "pct", "ci95_low", "ci95_high", "headline_decoder",
              "zxing_decoded", "opencv_decoded", "wechat_decoded", "either_decoded"]
    with open(OUT_PUBLIC_CSV, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=fields)
        w.writeheader()
        for s in S.values():
            c = s["c"]
            lo, hi = wilson(s["k_head"], s["n"])
            w.writerow(dict(
                experiment=c["exp"], url_key=c["url"], url_chars=len(URLS[c["url"]]), qr_version=s["version"],
                modules=s["modules"], ec=c["ec"], size_in=c["size_in"], distance_cm=c["dist_cm"], frame=c["frame"],
                dpi=c["dpi"], quiet_zone_modules=c["qz"], color=c["color"], tilt=c["tilt"], logo=int(c["logo"]),
                module_mm=s["module_mm"], px_per_module=s["px_per_module"], attempts=s["n"], decoded=s["k_head"],
                pct=s["pct_head"], ci95_low=round(lo, 1), ci95_high=round(hi, 1), headline_decoder=s["head_decoder"],
                zxing_decoded=s["k"]["zxing"], opencv_decoded=s["k"]["opencv"], wechat_decoded=s["k"]["wechat"],
                either_decoded=s["k_either"]))


def font(size: int):
    return ImageFont.load_default(size=size)


def qr_image(url: str, px: int, ec: str = "M", qz: int = 4) -> Image.Image:
    m = np.array(make_qr(url, ec).matrix, dtype=bool)
    n = m.shape[0]
    tot = n + 2 * qz
    idx = np.minimum((np.arange(px) * tot / px).astype(int), tot - 1) - qz
    inside = (idx >= 0) & (idx < n)
    arr = np.full((px, px), 255, np.uint8)
    ii = np.clip(idx, 0, n - 1)
    sub = m[np.ix_(ii, ii)] & inside[:, None] & inside[None, :]
    arr[sub] = 0
    return Image.fromarray(arr, "L")


def make_short_vs_long():
    W, Hh = 1600, 800
    im = Image.new("L", (W, Hh), 255)
    dr = ImageDraw.Draw(im)
    code_px = 430
    items = [("resume_site", "Resume website link"), ("drive_share", "Google Drive share link")]
    for i, (k, title) in enumerate(items):
        info = qr_info(URLS[k])
        n = info["modules"]
        cx = W / 4 + i * W / 2
        tot = n + 8
        q = qr_image(URLS[k], int(round(code_px * tot / n)))
        qz_px = (q.width - code_px) // 2
        code_top = 130
        im.paste(q, (int(cx - q.width / 2), code_top - qz_px))
        y = code_top + code_px + 40
        dr.text((cx, y), title, fill=20, font=font(32), anchor="mt")
        short = URLS[k].replace("https://", "")
        if len(short) > 46:
            short = short[:44] + "…"
        dr.text((cx, y + 44), short, fill=90, font=font(22), anchor="mt")
        mm = 25.4 / n
        dr.text((cx, y + 80), f"{info['chars']} characters · {n} x {n} modules · {mm:.2f} mm per module",
                fill=40, font=font(24), anchor="mt")
    dr.line([(W / 2, 110), (W / 2, Hh - 40)], fill=225, width=2)
    # Title drawn last so the codes' white quiet zones can't cover it.
    dr.text((W / 2, 50), "Same 1-inch printed size, different links", fill=20, font=font(40), anchor="mm")
    OUT_STATIC.mkdir(parents=True, exist_ok=True)
    im = im.quantize(colors=16)
    im.save(OUT_STATIC / "short-vs-long.png", optimize=True)


def make_capture_samples(S, use_cnn):
    worker_init(use_cnn)
    tiles = [
        (C("grid", "resume_site", 1.0, 30), "Resume site, 1 in, 30 cm"),
        (C("grid", "drive_share", 0.5, 30), "Drive link, 0.5 in, 30 cm"),
        (C("grid", "linkedin_share", 1.0, 30), "LinkedIn share link, 1 in"),
        (C("tilt", "resume_site", 1.0, 30, tilt=50), "Resume site, 1 in, tilted 50°"),
        (C("contrast", "resume_site", 1.0, 30, color="light_gray"), "Light gray, 1 in, 30 cm"),
        (C("quiet", "resume_site", 1.0, 30, qz=0), "Text touching code, 1 in"),
    ]
    # 3 x 2 grid, big type: it has to stay readable when the article scales it down.
    cols, gap = 3, 24
    tw, th, lab_h = 300, 300, 112
    rows_n = (len(tiles) + cols - 1) // cols
    W = tw * cols + gap * (cols + 1)
    Hh = (th + lab_h) * rows_n + gap * (rows_n + 1)
    im = Image.new("L", (W, Hh), 255)
    dr = ImageDraw.Draw(im)
    for i, (c, title) in enumerate(tiles):
        st = S[c["id"]]
        want = st["pct_head"] >= 50
        paper = render_paper(c)
        chosen_t = 0
        for t in range(st["n"]):  # first trial whose outcome matches the condition's majority
            a = run_attempt(c, paper, t, decoders=["zxing", "opencv"])
            ok = all(a["first"].values()) if want else not any(a["first"].values())
            if ok:
                chosen_t = t
                break
        img, p = capture(c, paper, attempt_rng(c, chosen_t), frame_rng(c, chosen_t, 0))
        # Crop tightly around the code (code width x1.5) so all tiles are comparable.
        h, w = img.shape
        code_px = paper["w_mm"] * FRAMES[c["frame"]]["f_px"] / p["d_mm"]
        half = int(min(h, w, code_px * 1.5) / 2)
        cy, cx = h // 2, w // 2
        crop = img[max(0, cy - half):cy + half, max(0, cx - half):cx + half]
        tile = Image.fromarray(crop, "L").resize((tw, th), Image.NEAREST)
        x = gap + (i % cols) * (tw + gap)
        y = gap + (i // cols) * (th + lab_h + gap)
        im.paste(tile, (x, y))
        dr.text((x, y + th + 12), title, fill=20, font=font(24))
        # State the success count plainly, so "35%" can't be read as a failure rate.
        verdict = "Reliable" if st["pct_head"] >= PASS_PCT else "Unreliable"
        dr.text((x, y + th + 46), f"{verdict}: read {st['k_head']} of {st['n']}", fill=20, font=font(22))
        dr.text((x, y + th + 78), f"~{p['px_per_module']:.1f} camera px per module", fill=90, font=font(19))
    im = im.quantize(colors=64)
    im.save(OUT_STATIC / "capture-samples.png", optimize=True)


# --------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--trials", type=int, default=TRIALS)
    ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2) - 1))
    ap.add_argument("--no-download", action="store_true", help="don't fetch WeChat CNN models")
    ap.add_argument("--limit", type=int, default=0, help="debug: only run the first N conditions")
    args = ap.parse_args()

    t0 = time.time()
    use_cnn = ensure_wechat_models(not args.no_download)
    conds = build_conditions()
    if args.limit:
        conds = conds[:args.limit]
    print(f"{len(conds)} conditions x {args.trials} trials, {args.workers} workers, WeChat CNN={use_cnn}", flush=True)
    rows: list[dict] = []
    with ProcessPoolExecutor(args.workers, initializer=worker_init, initargs=(use_cnn,)) as ex:
        for i, rs in enumerate(ex.map(run_condition, [(c, args.trials) for c in conds], chunksize=1)):
            rows.extend(rs)
            if (i + 1) % 50 == 0:
                print(f"  {i + 1}/{len(conds)} conditions, {time.time() - t0:.0f}s", flush=True)
    order = {c["id"]: i for i, c in enumerate(conds)}
    rows.sort(key=lambda r: (order[r["cond_id"]], r["trial"]))
    S = summarise(conds, rows)
    if args.limit:
        for s in S.values():
            print(s["c"]["id"], s["k"], "f1", s["k_f1"], s["pct_head"], s["px_per_module"])
        return
    v = lambda p: metadata.version(p)  # noqa: E731
    decoder_names = [f"zxing-cpp {v('zxing-cpp')}",
                     f"OpenCV {cv2.__version__} QRCodeDetector",
                     f"OpenCV {cv2.__version__} WeChatQRCode ({'CNN detector + super-resolution' if use_cnn else 'no CNN models'})"]
    OUT_STATIC.mkdir(parents=True, exist_ok=True)
    OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
    write_raw(conds, rows)
    write_public_csv(S)
    make_short_vs_long()
    make_capture_samples(S, use_cnn)
    runtime = time.time() - t0
    data = build_json(conds, S, decoder_names, runtime, len(rows))
    data["packages"] = {p: v(p) for p in ("segno", "opencv-contrib-python-headless", "zxing-cpp", "numpy", "pillow")}
    data["python"] = sys.version.split()[0]
    OUT_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
    digest = hashlib.sha256(OUT_RAW.read_bytes()).hexdigest()[:16]
    print(f"done: {len(rows)} attempts, {len(S)} conditions, {runtime:.0f}s, raw.csv sha256 {digest}")


if __name__ == "__main__":
    main()
