Skip to content

Commit 77614f3

Browse files
committed
merian-graph: mcpg: renderer evaluation tooling
run.py renders references (if missing) and cold-start/pre-trained measurement runs (powers-of-two captures via Image Write; pre-train clears accumulation on the capture start event), picking the scene graph by file extension, then produces metrics (RMSE/relMSE), labeled previews, image grids including the reference, and log-log convergence plots via plots.py. make_sun_env.py writes a deterministic sun+sky env map. Some CLI pointers (dual chain, seed offset) target render properties from the mcpg dual-chain branch.
1 parent 12ed877 commit 77614f3

5 files changed

Lines changed: 496 additions & 0 deletions

File tree

examples/eval/mcpg_capture.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"cli": {
3+
"capture-enable": {
4+
"pointer": "/nodes/capture/properties/enable"
5+
},
6+
"capture-file": {
7+
"pointer": "/nodes/capture/properties/filename"
8+
},
9+
"capture-iteration": {
10+
"pointer": "/nodes/capture/properties/iteration"
11+
},
12+
"capture-power": {
13+
"pointer": "/nodes/capture/properties/iteration power"
14+
},
15+
"capture-quit": {
16+
"pointer": "/nodes/capture/properties/advanced/exit at iteration"
17+
},
18+
"capture-start": {
19+
"pointer": "/nodes/capture/properties/advanced/start at run"
20+
},
21+
"dual-chain": {
22+
"pointer": "/nodes/render/properties/mc/dual chain"
23+
},
24+
"guiding-prob": {
25+
"pointer": "/nodes/render/properties/guiding prob"
26+
},
27+
"guiding-type": {
28+
"pointer": "/nodes/render/properties/directional sampling type"
29+
},
30+
"reference-mode": {
31+
"pointer": "/nodes/render/properties/reference mode"
32+
},
33+
"seed-offset": {
34+
"pointer": "/nodes/render/properties/seed offset"
35+
}
36+
},
37+
"graph_properties": {
38+
"fps limiter": false
39+
},
40+
"nodes": {
41+
"accumulate": {
42+
"$+$outputs": [
43+
"out->capture.src"
44+
],
45+
"properties": {
46+
"clear event pattern": "/user/clear,//geometry_changed,//transform_changed,//camera_changed,//bounces_changed,/capture/start"
47+
}
48+
},
49+
"capture": {
50+
"enabled": true,
51+
"properties": {
52+
"advanced": {
53+
"exit at iteration": 4096
54+
},
55+
"enable": true,
56+
"filename": "/tmp/mcpg-eval/{record_iteration:05}",
57+
"format": "PFM",
58+
"iteration": 1,
59+
"iteration offset": 0,
60+
"iteration power": 2,
61+
"trigger": "iteration"
62+
},
63+
"type": "Image Write"
64+
},
65+
"render": {
66+
"properties": {
67+
"mc": {
68+
"mc_direct": {
69+
"grid distribution dimension": 1.5,
70+
"grid level bias": 0.0,
71+
"grid tan(alpha/2)": 0.003
72+
}
73+
}
74+
}
75+
}
76+
}
77+
}

examples/eval/sponza_camera.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"nodes": {
3+
"scene": {
4+
"properties": {
5+
"scene": {
6+
"cameras": {
7+
"store cameras": true,
8+
"active": 0,
9+
"position": [-9.5, 1.7, -0.3],
10+
"target": [5.0, 2.5, 0.0],
11+
"up": [0.0, 1.0, 0.0]
12+
}
13+
}
14+
}
15+
}
16+
}
17+
}

scripts/eval/make_sun_env.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env python3
2+
"""Write a deterministic lat-long environment map (Radiance .hdr) for renderer evaluations:
3+
a sky gradient plus a small bright sun for prominent, hard direct light.
4+
5+
make_sun_env.py out.hdr [--width 2048] [--sun-intensity 400]
6+
"""
7+
8+
import argparse
9+
10+
import numpy as np
11+
12+
13+
def write_radiance_hdr(path, image):
14+
"""RGBE, per the Radiance .hdr format stb_image reads."""
15+
height, width, _ = image.shape
16+
brightest = np.max(image, axis=2)
17+
exponent = np.zeros_like(brightest)
18+
mantissa = np.zeros_like(image)
19+
nonzero = brightest > 1e-32
20+
m, e = np.frexp(brightest[nonzero])
21+
exponent[nonzero] = e
22+
mantissa[nonzero] = image[nonzero] * (m / brightest[nonzero])[:, None]
23+
24+
rgbe = np.zeros((height, width, 4), np.uint8)
25+
rgbe[..., :3] = np.clip(mantissa * 256.0, 0, 255).astype(np.uint8)
26+
rgbe[..., 3] = np.clip(exponent + 128, 0, 255).astype(np.uint8)
27+
28+
with open(path, "wb") as f:
29+
f.write(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n")
30+
f.write(f"-Y {height} +X {width}\n".encode())
31+
rgbe.tofile(f)
32+
33+
34+
def main():
35+
ap = argparse.ArgumentParser()
36+
ap.add_argument("out")
37+
ap.add_argument("--width", type=int, default=2048)
38+
ap.add_argument("--sun-intensity", type=float, default=400.0)
39+
ap.add_argument("--sun-radius", type=float, default=0.045, help="radians")
40+
ap.add_argument("--sun-elevation", type=float, default=0.6, help="radians")
41+
ap.add_argument("--sun-azimuth", type=float, default=2.2, help="radians")
42+
args = ap.parse_args()
43+
44+
width, height = args.width, args.width // 2
45+
# theta from +Y down, phi around
46+
theta = (np.arange(height) + 0.5) / height * np.pi
47+
phi = (np.arange(width) + 0.5) / width * 2.0 * np.pi
48+
theta, phi = np.meshgrid(theta, phi, indexing="ij")
49+
50+
up = np.cos(theta)
51+
sky = np.stack(
52+
[
53+
0.25 + 0.35 * np.clip(up, 0, 1),
54+
0.38 + 0.45 * np.clip(up, 0, 1),
55+
0.62 + 0.60 * np.clip(up, 0, 1),
56+
],
57+
axis=-1,
58+
).astype(np.float32)
59+
ground = np.array([0.22, 0.20, 0.17], np.float32)
60+
image = np.where(up[..., None] > 0.0, sky, ground[None, None, :] * (1.0 + up[..., None]))
61+
62+
direction = np.stack(
63+
[np.sin(theta) * np.cos(phi), np.cos(theta), np.sin(theta) * np.sin(phi)], axis=-1
64+
)
65+
sun_theta = np.pi / 2.0 - args.sun_elevation
66+
sun_dir = np.array(
67+
[
68+
np.sin(sun_theta) * np.cos(args.sun_azimuth),
69+
np.cos(sun_theta),
70+
np.sin(sun_theta) * np.sin(args.sun_azimuth),
71+
],
72+
np.float32,
73+
)
74+
cos_sun = np.clip(direction @ sun_dir, -1.0, 1.0)
75+
image = image + args.sun_intensity * (np.arccos(cos_sun) < args.sun_radius)[..., None]
76+
77+
write_radiance_hdr(args.out, image.astype(np.float32))
78+
print(f"{args.out}: {width}x{height}, sun {args.sun_intensity}")
79+
80+
81+
if __name__ == "__main__":
82+
main()

scripts/eval/plots.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#!/usr/bin/env python3
2+
"""Metrics, labeled previews, image grids and convergence plots for renderer evaluations.
3+
4+
plots.py <eval_dir> <out_dir>
5+
6+
Expects <eval_dir>/<scene>/{ref,cold_bsdf,cold_old,cold_new,pre_bsdf,pre_old,pre_new}/<iter>.pfm.
7+
"""
8+
9+
import json
10+
import sys
11+
from pathlib import Path
12+
13+
import matplotlib
14+
15+
matplotlib.use("Agg")
16+
import matplotlib.pyplot as plt
17+
import numpy as np
18+
from PIL import Image, ImageDraw, ImageFont
19+
20+
TECHNIQUES = [
21+
("bsdf", "BSDF sampling", "#2a78d6"),
22+
("old", "MCPG (single chain)", "#eb6834"),
23+
("new", "MCPG (dual chain)", "#1baf7a"),
24+
]
25+
PROTOCOLS = [
26+
("cold", "cold start (guiding trains while accumulating)"),
27+
("pre", "after 1024 training frames"),
28+
]
29+
30+
LUM = np.array([0.2126, 0.7152, 0.0722], np.float32)
31+
FONT = Path(matplotlib.get_data_path()) / "fonts" / "ttf" / "DejaVuSans.ttf"
32+
33+
34+
def read_pfm(path):
35+
with open(path, "rb") as f:
36+
header = f.readline().rstrip()
37+
if header not in (b"PF", b"Pf"):
38+
raise ValueError(f"{path}: not a PFM file ({header!r})")
39+
channels = 3 if header == b"PF" else 1
40+
while True:
41+
line = f.readline()
42+
if not line.startswith(b"#"):
43+
break
44+
width, height = (int(v) for v in line.split())
45+
scale = float(f.readline().rstrip())
46+
data = np.fromfile(f, "<f4" if scale < 0 else ">f4", width * height * channels)
47+
return np.flipud(data.reshape(height, width, channels)).astype(np.float32)
48+
49+
50+
def finite(image):
51+
return np.nan_to_num(image, nan=0.0, posinf=0.0, neginf=0.0)
52+
53+
54+
def tonemap(image, exposure):
55+
x = np.clip(image * exposure, 0.0, None)
56+
x = x / (1.0 + x)
57+
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1 / 2.4) - 0.055)
58+
59+
60+
def to_u8(image, exposure):
61+
return (np.clip(tonemap(image, exposure), 0, 1) * 255).astype(np.uint8)
62+
63+
64+
def label(image_u8, text):
65+
img = Image.fromarray(image_u8)
66+
draw = ImageDraw.Draw(img, "RGBA")
67+
size = max(16, img.height // 40)
68+
font = ImageFont.truetype(str(FONT), size)
69+
pad = size // 2
70+
box = draw.textbbox((pad, pad), text, font=font)
71+
draw.rectangle((0, 0, box[2] + pad, box[3] + pad), fill=(0, 0, 0, 180))
72+
draw.text((pad, pad), text, fill=(255, 255, 255, 255), font=font)
73+
return np.asarray(img)
74+
75+
76+
def save_labeled(image, exposure, text, path):
77+
Image.fromarray(label(to_u8(image, exposure), text)).save(path)
78+
79+
80+
def metrics(ref, test, scale):
81+
r, t = ref * scale, test * scale
82+
d = t - r
83+
return {
84+
"rmse": float(np.sqrt(np.mean(d * d))),
85+
# Rousselle-style relative MSE on brightness-normalized images
86+
"rel_mse": float(np.mean(d * d / (r * r + 1e-2))),
87+
}
88+
89+
90+
def style_axis(ax):
91+
ax.grid(True, which="major", color="#e8e7e4", linewidth=0.7)
92+
ax.grid(True, which="minor", color="#f3f2f0", linewidth=0.5)
93+
for spine in ("top", "right"):
94+
ax.spines[spine].set_visible(False)
95+
for spine in ("left", "bottom"):
96+
ax.spines[spine].set_color("#c9c8c4")
97+
ax.tick_params(colors="#52514e", labelsize=9)
98+
ax.set_xticks([1, 4, 16, 64, 256, 1024, 4096])
99+
ax.set_xticklabels(["1", "4", "16", "64", "256", "1024", "4096"])
100+
101+
102+
def plot_protocol(ax, runs, protocol, metric):
103+
for key, name, color in TECHNIQUES:
104+
curve = runs[f"{protocol}_{key}"]
105+
spp = np.array(sorted(curve))
106+
err = np.array([curve[s][metric] for s in spp])
107+
ax.loglog(spp, err, color=color, linewidth=1.8, marker="o", markersize=4, label=name)
108+
109+
110+
def eval_scene(scene_dir, out_dir, results):
111+
scene = scene_dir.name
112+
ref_pfm = next(iter(sorted((scene_dir / "ref").glob("*.pfm"))))
113+
ref = finite(read_pfm(ref_pfm))
114+
lum = ref @ LUM
115+
scale = 1.0 / float(np.mean(lum))
116+
# photographic log-average key, so a bright window does not underexpose the interior
117+
exposure = 0.18 / float(np.exp(np.mean(np.log(lum + 1e-4))))
118+
ref_spp = 16 * int(ref_pfm.stem)
119+
save_labeled(ref, exposure, f"{scene} — reference, BSDF {ref_spp} spp",
120+
out_dir / f"{scene}_reference.png")
121+
122+
runs = {}
123+
for protocol, _ in PROTOCOLS:
124+
for key, name, _ in TECHNIQUES:
125+
run = f"{protocol}_{key}"
126+
curve = {}
127+
for pfm in sorted((scene_dir / run).glob("*.pfm")):
128+
curve[int(pfm.stem)] = metrics(ref, finite(read_pfm(pfm)), scale)
129+
runs[run] = curve
130+
print(scene, run, {s: round(v["rmse"], 4) for s, v in sorted(curve.items())})
131+
for spp in (1, 4):
132+
pfm = scene_dir / run / f"{spp:05}.pfm"
133+
if pfm.exists():
134+
save_labeled(finite(read_pfm(pfm)), exposure,
135+
f"{scene}{name}, {spp} spp ({protocol})",
136+
out_dir / f"{scene}_{run}_{spp}spp.png")
137+
results[scene] = {"exposure": exposure, "runs": runs}
138+
139+
for protocol, subtitle in PROTOCOLS:
140+
fig, ax = plt.subplots(figsize=(7.5, 5.2), dpi=160)
141+
fig.patch.set_facecolor("#fcfcfb")
142+
ax.set_facecolor("#fcfcfb")
143+
plot_protocol(ax, runs, protocol, "rmse")
144+
ax.set_xlabel("samples per pixel", color="#0b0b0b")
145+
ax.set_ylabel("RMSE", color="#0b0b0b")
146+
ax.set_title(f"{scene}{subtitle}", color="#0b0b0b", fontsize=11, loc="left")
147+
style_axis(ax)
148+
ax.legend(frameon=False, fontsize=9, loc="lower left")
149+
fig.tight_layout()
150+
fig.savefig(out_dir / f"{scene}_convergence_{protocol}.png",
151+
facecolor=fig.get_facecolor(), bbox_inches="tight")
152+
plt.close(fig)
153+
154+
# comparison sheet: rows 1/4 spp, columns reference | BSDF | single chain | dual chain
155+
ref_tile = label(to_u8(ref, exposure), f"reference — {ref_spp} spp")
156+
for protocol, _ in PROTOCOLS:
157+
rows = []
158+
for spp in (1, 4):
159+
row = [ref_tile] + [
160+
label(to_u8(finite(read_pfm(scene_dir / f"{protocol}_{key}" / f"{spp:05}.pfm")),
161+
exposure), f"{name}{spp} spp")
162+
for key, name, _ in TECHNIQUES
163+
]
164+
rows.append(np.concatenate(row, axis=1))
165+
Image.fromarray(np.concatenate(rows, axis=0)).save(
166+
out_dir / f"{scene}_sheet_{protocol}.png")
167+
168+
169+
def evaluate(eval_dir, out_dir):
170+
out_dir.mkdir(parents=True, exist_ok=True)
171+
172+
results = {}
173+
scenes = [d for d in sorted(eval_dir.iterdir()) if any((d / "ref").glob("*.pfm"))]
174+
for scene_dir in scenes:
175+
eval_scene(scene_dir, out_dir, results)
176+
(out_dir / "metrics.json").write_text(json.dumps(results, indent=1))
177+
178+
# combined small-multiples figure, one panel per scene
179+
for protocol, subtitle in PROTOCOLS:
180+
fig, axes = plt.subplots(1, len(scenes), figsize=(4.6 * len(scenes), 4.2), dpi=160)
181+
fig.patch.set_facecolor("#fcfcfb")
182+
for ax, scene_dir in zip(np.atleast_1d(axes), scenes):
183+
ax.set_facecolor("#fcfcfb")
184+
plot_protocol(ax, results[scene_dir.name]["runs"], protocol, "rmse")
185+
ax.set_title(scene_dir.name, color="#0b0b0b", fontsize=10, loc="left")
186+
ax.set_xlabel("spp", color="#52514e", fontsize=9)
187+
style_axis(ax)
188+
np.atleast_1d(axes)[0].set_ylabel("RMSE", color="#0b0b0b")
189+
np.atleast_1d(axes)[0].legend(frameon=False, fontsize=8.5, loc="lower left")
190+
fig.suptitle(f"Convergence, {subtitle}", color="#0b0b0b", fontsize=11, x=0.02, ha="left")
191+
fig.tight_layout(rect=(0, 0, 1, 0.95))
192+
fig.savefig(out_dir / f"convergence_all_{protocol}.png",
193+
facecolor=fig.get_facecolor(), bbox_inches="tight")
194+
plt.close(fig)
195+
196+
print("wrote", out_dir)
197+
198+
199+
if __name__ == "__main__":
200+
evaluate(Path(sys.argv[1]), Path(sys.argv[2]))

0 commit comments

Comments
 (0)