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