#!/usr/bin/env python3
"""Measure existing relighting PNGs. Never writes or transforms image files.

Requires Python 3, Pillow and NumPy. Download this script, coffee.png and the
three named outputs into one folder, then run: python measure-artifacts.py
The repository layout is also supported. Use --source for another source path.
"""

import argparse
import hashlib
import json
import os
from pathlib import Path

import numpy as np
from PIL import Image


FILES = (
    ("left-030", "coffee-left-030.png"),
    ("right-030", "coffee-right-030.png"),
    ("right-070", "coffee-right-070.png"),
)
REGIONS = {
    "left-table": [0, 100, 70, 300],
    "right-table": [500, 50, 599, 250],
    "cup-interior": [235, 75, 365, 140],
}


def fingerprint(path, directory):
    data = path.read_bytes()
    return {
        "file": os.path.relpath(path, directory),
        "bytes": len(data),
        "sha256": hashlib.sha256(data).hexdigest(),
    }


def read_image(path, directory):
    with Image.open(path) as im:
        im.load()
        rgb = np.array(im.convert("RGB"), dtype=np.int16)
        alpha = np.array(im.convert("RGBA"), dtype=np.uint8)[:, :, 3]
        stats = {
            **fingerprint(path, directory),
            "width": im.width,
            "height": im.height,
            "mode": im.mode,
            "pixels": im.width * im.height,
            "fullyOpaquePixels": int(np.count_nonzero(alpha == 255)),
            "meanRgbCodeValues": [round(float(v), 6) for v in rgb.mean(axis=(0, 1))],
            "anyChannelAt255Pixels": int(np.count_nonzero((rgb == 255).any(axis=2))),
            "anyChannelAt255Percent": round(float((rgb == 255).any(axis=2).mean() * 100), 6),
            "allChannelsAt255Pixels": int(np.count_nonzero((rgb == 255).all(axis=2))),
            "allChannelsAt255Percent": round(float((rgb == 255).all(axis=2).mean() * 100), 6),
            "blackPixels": int(np.count_nonzero((rgb == 0).all(axis=2))),
        }
    return rgb, stats


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source", type=Path)
    parser.add_argument("--outputs-dir", type=Path, default=Path(__file__).resolve().parent)
    args = parser.parse_args()
    directory = args.outputs_dir.resolve()
    source = args.source
    if source is None:
        source = directory / "coffee.png"
        if not source.exists():
            source = directory / "../../images/coffee.png"
    source = source.resolve()
    original, input_stats = read_image(source, directory)
    if original.shape != (400, 600, 3):
        raise ValueError("This case expects the unmodified 600 × 400 coffee photograph.")
    cases = []
    output_arrays = {}
    for case_id, filename in FILES:
        rgb, output_stats = read_image(directory / filename, directory)
        if rgb.shape != original.shape:
            raise ValueError(f"Source/output dimensions differ: {filename}")
        output_arrays[case_id] = rgb
        delta = rgb - original
        border = np.zeros(rgb.shape[:2], dtype=bool)
        border[-1, :] = True
        border[:, -1] = True
        black = (rgb == 0).all(axis=2)
        cases.append({
            "id": case_id,
            "output": output_stats,
            "sourceComparison": {
                "meanAbsoluteRgbCodeDifference": round(float(np.abs(delta).mean()), 6),
                "pixelsWithAnyDecreasedChannel": int(np.count_nonzero((delta < 0).any(axis=2))),
                "interiorPixelsWithAnyDecreasedChannel": int(np.count_nonzero((delta[:-1, :-1] < 0).any(axis=2))),
                "lastRowEntirelyBlack": bool(black[-1, :].all()),
                "lastColumnEntirelyBlack": bool(black[:, -1].all()),
                "blackPixelsOnLastRowOrColumn": int(np.count_nonzero(black & border)),
                "borderUnionPixelCount": int(border.sum()),
            },
            "regions": [
                {
                    "id": name,
                    "boundsX0Y0X1Y1": bounds,
                    "meanSignedRgbCodeDifference": round(float(delta[bounds[1]:bounds[3], bounds[0]:bounds[2]].mean()), 6),
                }
                for name, bounds in REGIONS.items()
            ],
        })
    pairs = []
    for first, second in [("left-030", "right-030"), ("right-030", "right-070")]:
        delta = output_arrays[second] - output_arrays[first]
        pairs.append({
            "first": first,
            "second": second,
            "meanAbsoluteRgbCodeDifference": round(float(np.abs(delta).mean()), 6),
            "changedPixels": int(np.count_nonzero((delta != 0).any(axis=2))),
        })
    report = {
        "schemaVersion": 1,
        "recordType": "measurements-of-existing-browser-export-files",
        "input": input_stats,
        "method": {
            "caseLabelBoundary": "The historical left-030/right-030 filenames refer to the displayed 0.30 slider, not a measured shader intensity. Source analysis indicates an internal initial value of 0.40 until the explicit third adjustment to 0.70; see run.json. This script measures only image files.",
            "colorSpaceBoundary": "Statistics operate on encoded 8-bit RGB code values, not linear-light radiance, exposure stops or colorimetric truth.",
            "clippingBoundary": "A channel value of 255 is an endpoint count. It does not prove every counted pixel is a new or objectionable clipped highlight.",
            "comparison": "Same-size files are compared directly; no crop, resize, color conversion profile, alignment correction or output image is written.",
            "regions": "Coordinates are source-image pixel boxes [x0,y0,x1,y1], excluding x1/y1. Region means average the signed differences of all three RGB channels.",
            "border": "The final row and final column share their bottom-right pixel; their union contains 999 pixels in a 600 × 400 image.",
            "scope": "One source, three browser exports; no model benchmark, physical lighting reference, inference speed or network measurement.",
        },
        "cases": cases,
        "outputPairs": pairs,
    }
    (directory / "measurements.json").write_text(json.dumps(report, indent=2) + "\n")
    artifact_paths = [source, *(directory / filename for _, filename in FILES), Path(__file__).resolve(), directory / "run.json", directory / "measurements.json"]
    artifacts = [fingerprint(path, directory) for path in artifact_paths if path.exists()]
    manifest = {
        "schemaVersion": 1,
        "description": "Source, untouched browser exports, run notes, measurements and measurement script. The manifest itself is excluded to avoid a recursive hash.",
        "artifacts": artifacts,
    }
    (directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
    print(json.dumps({"cases": len(cases), "artifacts": len(artifacts), "measurements": str(directory / "measurements.json")}))


if __name__ == "__main__":
    main()
