OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / distribute / hypertps-win64 / scripts / lod-compare.py

92 lines · python ref:
#!/usr/bin/env python3
"""lod-compare.py — measure texture-LOD sharpness against the rasterizer.

Texture LOD cannot be eyeballed. Going from LOD 0 (what a hit shader gets when
dFdx is a zero stub) to correct mip selection makes the image LOOK blurrier, so
"it over-blurred" is exactly what a CORRECT fix also looks like. That ambiguity
is why the ray-cone change was shipped and reverted twice (hyper-games #11 §4.6,
§7.4) — nobody had a number.

The rasterizer is the ground truth: it has real hardware derivatives, its LOD
selection is correct, and it renders the same shader math. So the question is
not "does this look blurry" but "does the path-traced image carry the same
amount of high-frequency texture detail as the raster image of the same
viewpoint".

Metric: mean absolute Laplacian (high-frequency energy) over the region that
actually has terrain in it. LOD 0 oversamples minified texture and shows MORE
high-frequency energy than raster; over-blurring shows LESS. Correct LOD lands
near raster.

    hf(raster)  = reference
    hf(pt)/hf(raster) >> 1  -> PT is too sharp (LOD 0, aliasing)
    hf(pt)/hf(raster) ~= 1  -> PT LOD matches the rasterizer
    hf(pt)/hf(raster) << 1  -> PT is over-blurred (footprint too large)

Usage:
    python3 scripts/lod-compare.py <raster.png> <pt-before.png> [pt-after.png ...]

The HUD occupies the top of the frame and is not scene content; --skip-top
drops it (default 160 px).
"""
import sys

import numpy as np
from PIL import Image


def load_luma(path, skip_top):
    img = Image.open(path).convert("RGB")
    a = np.asarray(img, dtype=np.float64) / 255.0
    a = a[skip_top:, :, :]
    # Rec.709 luma; texture detail is overwhelmingly luminance.
    return 0.2126 * a[:, :, 0] + 0.7152 * a[:, :, 1] + 0.0722 * a[:, :, 2]


def hf_energy(luma, floor=0.02):
    """Mean |Laplacian| over pixels bright enough to be scene content.

    The void/sky in this scene is near-black and carries no texture; including
    it would dilute the metric by however much of the frame happens to be empty,
    which changes with the camera and would make runs incomparable.
    """
    lap = (
        -4.0 * luma[1:-1, 1:-1]
        + luma[:-2, 1:-1]
        + luma[2:, 1:-1]
        + luma[1:-1, :-2]
        + luma[1:-1, 2:]
    )
    mask = luma[1:-1, 1:-1] > floor
    if mask.sum() == 0:
        return 0.0, 0
    return float(np.abs(lap[mask]).mean()), int(mask.sum())


def main(argv):
    skip_top = 160
    args = [a for a in argv[1:] if not a.startswith("--")]
    for a in argv[1:]:
        if a.startswith("--skip-top="):
            skip_top = int(a.split("=", 1)[1])
    if len(args) < 2:
        print(__doc__)
        return 2

    ref_path, rest = args[0], args[1:]
    ref_hf, ref_n = hf_energy(load_luma(ref_path, skip_top))
    print(f"{'image':<52} {'hf energy':>10} {'vs raster':>10} {'px':>9}")
    print("-" * 85)
    print(f"{ref_path.split('/')[-1]:<52} {ref_hf:>10.5f} {'1.000 (ref)':>10} {ref_n:>9}")
    for p in rest:
        hf, n = hf_energy(load_luma(p, skip_top))
        ratio = hf / ref_hf if ref_hf > 0 else float("nan")
        print(f"{p.split('/')[-1]:<52} {hf:>10.5f} {ratio:>10.3f} {n:>9}")
    print()
    print("ratio >> 1 : PT too sharp (LOD 0)   ~= 1 : matches raster   << 1 : over-blurred")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))