Fastfill-test / code /fastfill /v2 /ops /method_figures.py
XXXpilar's picture
FastFill v4 base (step 6552): LoRA adapter, geometry heads, tokenizer, training code, predict.py
e6f80f3 verified
Raw History Blame Contribute Delete
13.2 kB
"""Results table, trade-off scatter, metric bars and room comparisons of several methods on the same rows (no model,
no network).
python -m fastfill.v2.ops.method_figures --config methods.json --out DIR [--rooms 3,40,77]
``methods.json``: {"rows": rows.jsonl, "templates": relation templates (``layout_quality mine``), "methods": [{"name",
"predictions", "report", "llm_runs" (optional: a directory of room_<k>.json with token usage, llm_probe / llm_hybrid),
"latency_s" (optional: seconds per room when the room files do not say, e.g. the FastFill decode), "rooms" (optional,
default true: drawn in the room comparisons)}]}. ``report`` is ``fastfill.v2.evaluate``'s report.json of the
predictions. Legality comes from the report, plausibility (REL, ACC, NAV) and SceneEval-style COL / OPC from
``layout_quality``; a ground-truth row (the labels scored the same way) is added for reference.
Writes DIR/results_table.md and .png, tradeoff.png (clean rooms against relation plausibility, one point per method,
the ground truth and a shuffled ground truth as references), results_bars.png (one panel per metric) and
rooms/row<k>.png: each method's top-down plan of one room (vlm_judge.draw_plan) with its problem count
(``llm_workflow.workflow_problems``) and relation score, the ground truth first. Rooms are chosen by a fixed rule,
not by any method's outcome: in test order, the first room of each common room type (bedroom, living, dining,
study / office, kitchen, bathroom) with 6-25 objects, at least three relation checks on its ground truth, and a layout from every drawn method.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import statistics
import textwrap
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from fastfill.v2.ops import layout_quality as lq # noqa: E402
from fastfill.v2.ops.remask_ablation import summarize # noqa: E402
from fastfill.v2.schema import migrate_legacy_row # noqa: E402
HEADLINE = (("clean_room_rate", "Clean rooms (%) ↑", 100, "{:.1f}"), ("constraint_pass", "Constraints satisfied (%) ↑", 100, "{:.1f}"),
("REL", "Relation plausibility (%) ↑", 100, "{:.1f}"), ("SG", "Scene-graph recall (%) ↑", 100, "{:.1f}"), ("ACC", "Accessible objects (%) ↑", 100, "{:.1f}"),
("COL", "Objects in a collision (%) ↓", 100, "{:.1f}"), ("footprint_outside", "Objects outside the room (%) ↓", 100, "{:.1f}"),
("OPC", "Doors blocked (%) ↓", 100, "{:.1f}"), ("log_size", "Size error (log) ↓", 1, "{:.3f}"),
("success", "Rooms with a layout (%) ↑", 100, "{:.1f}"), ("seconds", "Seconds per room ↓", 1, "{:.3g}"),
("tokens", "LLM tokens per room ↓", 1, "{:,.0f}"))
COLORS = ("#7f8c8d", "#8e44ad", "#e67e22", "#16a085", "#2e86de", "#c0392b", "#f1c40f")
REFERENCE = "Ground truth (reference)"
ROOM_GROUPS = (("bedroom", ("bed",)), ("living room", ("living",)), ("dining room", ("dining",)),
("study / office", ("study", "office", "library")), ("kitchen", ("kitchen",)), ("bathroom", ("bath",)))
def jsonl(path):
return [json.loads(l) for l in Path(path).read_text().splitlines() if l.strip()]
def usage(run):
rooms = [json.loads(p.read_text()) for p in sorted(Path(run).glob("room_*.json"))]
tokens = sum((u.get("prompt_tokens") or 0) + (u.get("completion_tokens") or 0) for r in rooms for u in r.get("usage") or [])
seconds = [r["latency_s"] for r in rooms if not r.get("error") and r.get("latency_s") is not None]
return tokens / max(len(rooms), 1), statistics.median(seconds) if seconds else None
def metrics(method, rows, templates):
report = json.loads(Path(method["report"]).read_text())
m = summarize(report)
m["success"] = 1 - report["inference_failed_requests"] / report["requests"]
m["tokens"], m["seconds"] = 0., method.get("latency_s")
if method.get("llm_runs"):
m["tokens"], m["seconds"] = usage(method["llm_runs"])
m.update(lq.score_method(rows, [p.get("layout") for p in jsonl(method["predictions"])], templates))
return m
def reference(rows, templates, report):
"""The labels scored like the methods: legality from a method's report (its ground-truth side), the rest computed."""
m = {k: None for k, *_ in HEADLINE}
m.update(lq.score_method(rows, [r["target"] for r in rows], templates))
m["clean_room_rate"] = (report["validation"].get("ground_truth") or {}).get("clean_room_rate")
gt = report["collapse"]["ground_truth"]
m["footprint_outside"] = gt["footprint_outside_fraction"]
m["success"], m["tokens"] = 1., None
return m
def table(names, rows, out):
lines = ["| Method | " + " | ".join(h[1] for h in HEADLINE) + " |", "|---" * (len(HEADLINE) + 1) + "|"]
cells = []
for name, m in zip(names, rows):
cell = [("–" if m.get(k) is None else f.format(scale * m[k])) for k, _, scale, f in HEADLINE]
cells.append(cell)
lines.append(f"| {name} | " + " | ".join(cell) + " |")
(out / "results_table.md").write_text("\n".join(lines) + "\n")
fig, ax = plt.subplots(figsize=(2.4 + 1.3 * len(HEADLINE), .55 * len(names) + 1.4))
ax.axis("off")
t = ax.table(cellText=cells, rowLabels=names, colLabels=[textwrap.fill(h[1], 14) for h in HEADLINE], loc="center",
cellLoc="center")
t.auto_set_font_size(False)
t.set_fontsize(9)
t.scale(1, 2.1)
for c in range(len(HEADLINE)):
t[0, c].set_height(t[0, c].get_height() * 2.2)
methods = [r for r, n in enumerate(names) if n != REFERENCE]
for c, (k, label, _, _) in enumerate(HEADLINE): # best method value of each column in bold (the reference excluded)
values = {r: rows[r].get(k) for r in methods if rows[r].get(k) is not None}
if not values or (k == "tokens" and not any(values.values())):
continue
best = (min if "↓" in label else max)(values.values())
for r, v in values.items():
if v == best:
t[r + 1, c].set_text_props(weight="bold")
t[r + 1, c].set_facecolor("#e8f6ef")
for r, n in enumerate(names):
if n == REFERENCE:
for c in range(-1, len(HEADLINE)):
t[r + 1, c].set_facecolor("#f2f3f4")
t[r + 1, c].set_text_props(style="italic", color="#555555")
fig.savefig(out / "results_table.png", dpi=170, bbox_inches="tight")
plt.close(fig)
def tradeoff(names, rows, shuffled, out, title):
fig, ax = plt.subplots(figsize=(8.5, 6.5))
for i, (name, m) in enumerate(zip(names, rows)):
if m.get("clean_room_rate") is None or m.get("REL") is None:
continue
ref = name == REFERENCE
ax.scatter(100 * m["clean_room_rate"], 100 * m["REL"], s=260 if not ref else 330, marker="*" if ref else "o",
color="#555555" if ref else COLORS[i % len(COLORS)], edgecolor="black", zorder=3)
note = "" if ref or m.get("log_size") is None else f"\nsize err {m['log_size']:.3f}, {m['tokens']:,.0f} tok/room"
x, y = 100 * m["clean_room_rate"], 100 * m["REL"]
crowded = any(0 < 100 * o["clean_room_rate"] - x < 35 and abs(100 * o["REL"] - y) < 10 for o in rows
if o is not m and o.get("clean_room_rate") is not None and o.get("REL") is not None)
ax.annotate(name + note, (x, y), xytext=(-12, 8) if crowded else (10, -4), textcoords="offset points",
ha="right" if crowded else "left", fontsize=9, color="#333333", style="italic" if ref else "normal")
ax.axhline(100 * shuffled, color="#b03a2e", ls="--", lw=1)
ax.text(1, 100 * shuffled + 1, "shuffled ground truth (arrangement at random)", color="#b03a2e", fontsize=9)
ax.set(xlim=(0, 105), ylim=(0, 100), xlabel="Clean rooms (%): no collision, nothing outside, no door blocked ↑",
ylabel="Relation plausibility (%): mined layout rules satisfied ↑")
ax.set_title(title, fontsize=11, weight="bold")
ax.grid(ls=":", alpha=.6)
fig.tight_layout()
fig.savefig(out / "tradeoff.png", dpi=170)
plt.close(fig)
def bars(names, rows, out, title):
fig, axes = plt.subplots(3, 4, figsize=(21, 13.5)) # 12 panels
for ax, (k, label, scale, f) in zip(axes.flat, HEADLINE):
values = [None if m.get(k) is None else m[k] * scale for m in rows]
colors = ["#d5d8dc" if n == REFERENCE else COLORS[i % len(COLORS)] for i, n in enumerate(names)]
b = ax.bar(range(len(names)), [v or 0 for v in values], color=colors, hatch=[("//" if n == REFERENCE else "") for n in names])
for x, v in zip(b, values):
ax.text(x.get_x() + x.get_width() / 2, v or 0, "–" if v is None else f.format(v), ha="center", va="bottom", fontsize=9)
if k == "seconds":
ax.set_yscale("log")
else:
ax.set_ylim(0, max([v for v in values if v is not None] + [1e-9]) * 1.2)
ax.set_xticks(range(len(names)), [textwrap.fill(n, 18) for n in names], rotation=25, ha="right", fontsize=8)
ax.set_title(label, fontsize=11)
ax.grid(axis="y", ls=":", alpha=.6)
for ax in list(axes.flat)[len(HEADLINE):]:
ax.axis("off")
fig.suptitle(title, fontsize=14, weight="bold")
fig.subplots_adjust(left=.04, right=.99, top=.92, bottom=.09, hspace=.75, wspace=.22)
fig.savefig(out / "results_bars.png", dpi=150)
plt.close(fig)
def rooms(config, out, chosen, templates):
from fastfill.v2.llm_workflow import workflow_problems
from fastfill.v2.ops.vlm_judge import draw_plan, items
rows = [migrate_legacy_row(r) for r in jsonl(config["rows"])]
drawn = [m for m in config["methods"] if m.get("rooms", True)]
layouts = [[p.get("layout") for p in jsonl(m["predictions"])] for m in drawn]
if not chosen:
chosen, seen = [], set()
for k, r in enumerate(rows):
kind = next((g for g, words in ROOM_GROUPS
if any(w in (r["condition"]["room"].get("room_type") or "").lower() for w in words)), None)
if kind and kind not in seen and 6 <= len(r["condition"]["objects"]) <= 25 \
and all(l[k] is not None for l in layouts) and lq.rel(lq.boxes(r, r["target"]), templates)[1] >= 3:
seen.add(kind)
chosen.append(k)
(out / "rooms").mkdir(parents=True, exist_ok=True)
panels = [("Ground truth", None)] + [(m["name"], l) for m, l in zip(drawn, layouts)]
for k in chosen:
row = rows[k]
fig, axes = plt.subplots(1, len(panels), figsize=(5.2 * len(panels), 6.4))
for ax, (name, ls) in zip(axes, panels):
layout = row["target"] if ls is None else ls[k]
if layout is None:
ax.axis("off")
ax.set_title(f"{name}\nno layout", fontsize=12)
continue
its, _ = items(row, layout)
draw_plan(ax, row, its)
problems = workflow_problems(layout, row["condition"])
hits, total = lq.rel(lq.boxes(row, layout), templates)
ax.set_title(f"{name}\n{len(problems)} problem{'s' if len(problems) != 1 else ''} Β· relations {hits}/{total}",
fontsize=12, color="#1e8449" if not problems else "#b03a2e", weight="bold")
room = row["condition"]["room"]
fig.suptitle(f"test300 room {k}: {row['provenance']['source']}, {room.get('room_type') or 'room'}, "
f"{len(row['condition']['objects'])} objects", fontsize=14, weight="bold")
fig.tight_layout(rect=(0, 0, 1, .95))
fig.savefig(out / "rooms" / f"row{k:03d}.png", dpi=110, bbox_inches="tight")
plt.close(fig)
print(f"wrote rooms/row{k:03d}.png", flush=True)
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--config", type=Path, required=True)
p.add_argument("--out", type=Path, required=True)
p.add_argument("--rooms", type=lambda s: [int(x) for x in s.split(",")])
p.add_argument("--title", default="Same 300 test rooms, same evaluator")
a = p.parse_args(argv)
config = json.loads(a.config.read_text())
a.out.mkdir(parents=True, exist_ok=True)
rows = [migrate_legacy_row(r) for r in jsonl(config["rows"])]
templates = json.loads(Path(config["templates"]).read_text())["templates"]
names = [m["name"] for m in config["methods"]]
results = [metrics(m, rows, templates) for m in config["methods"]]
ref = reference(rows, templates, json.loads(Path(config["methods"][0]["report"]).read_text()))
shuffled = lq.score_method(rows, [lq.shuffled(r, k) for k, r in enumerate(rows)], templates)["REL"]
names, results = names + [REFERENCE], results + [ref]
table(names, results, a.out)
tradeoff(names, results, shuffled, a.out, a.title)
bars(names, results, a.out, a.title)
(a.out / "results.json").write_text(json.dumps(dict(zip(names, results)), indent=1, default=float) + "\n")
print((a.out / "results_table.md").read_text())
rooms(config, a.out, a.rooms, templates)
if __name__ == "__main__":
main()