163 lines
7.9 KiB
Python
163 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure fixed output-local PNG regions and hash every retained raw proof."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw
|
|
|
|
ROIS = {
|
|
"left": {"interior": (300, 100, 700, 500),
|
|
"boundary": (720, 50, 800, 550)},
|
|
"right": {"boundary": (0, 50, 80, 550),
|
|
"reference": (250, 250, 550, 500)},
|
|
}
|
|
PHASES = ("FX1-shadow", "FX2-blur", "FX3-optimized-candidate",
|
|
"FX4-combined", "T4-hidden-1", "T5-return-1", "T4-hidden-2",
|
|
"T5-return-2", "T4-hidden-3", "T5-return-3",
|
|
"T7-standard-return", "T6-disabled", "T6-restored", "T6-closed")
|
|
|
|
|
|
def sha(path):
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for block in iter(lambda: source.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def native_events(log):
|
|
commits = [{"output": m[0], "commit_seq": int(m[1]), "ns": int(m[2])}
|
|
for m in re.findall(r"PI_OUTPUT_COMMIT output=(\S+) commit_seq=(\d+) ns=(\d+)", log)]
|
|
presents = [{"output": m[0], "commit_seq": int(m[1]), "ns": int(m[2]),
|
|
"presented": bool(int(m[3]))}
|
|
for m in re.findall(r"PI_OUTPUT_PRESENT output=(\S+) commit_seq=(\d+) ns=(\d+).*?presented=(\d+)", log)]
|
|
identities = {(p["output"], p["commit_seq"]) for p in presents}
|
|
return {"instrumented": bool(commits), "commits": commits, "presentations": presents,
|
|
"unpresented": [c for c in commits if (c["output"], c["commit_seq"]) not in identities]}
|
|
|
|
|
|
def difference(first, second, box=None):
|
|
if box:
|
|
first, second = first.crop(box), second.crop(box)
|
|
delta = ImageChops.difference(first, second)
|
|
bbox = delta.getbbox()
|
|
channels = list(delta.getdata())
|
|
pixels = sum(channel != (0, 0, 0) for channel in channels)
|
|
return {"pixels": pixels, "bbox": bbox,
|
|
"mean_rgb_distance": round(sum(sum(channel) for channel in channels) /
|
|
(3 * delta.width * delta.height), 4)}
|
|
|
|
|
|
def analyze_run(run):
|
|
result = {"run": run.name, "captures": [], "comparisons": [],
|
|
"damage": {"events": 0, "outputs": {}}, "failure": None}
|
|
status = run / "exit-status.json"
|
|
if status.exists():
|
|
result["exit_status"] = json.loads(status.read_text())
|
|
events = []
|
|
event_file = run / "events.jsonl"
|
|
if event_file.exists():
|
|
events = [json.loads(line) for line in event_file.read_text().splitlines()]
|
|
captures = {(item["output"], item["tag"]): item for item in events
|
|
if item.get("kind") == "capture"}
|
|
damage_events = []
|
|
log = run / "compositor.log"
|
|
if log.exists():
|
|
log_text = log.read_text(errors="replace")
|
|
result["native_events"] = native_events(log_text)
|
|
result["renderer"] = {key: re.findall(pattern, log_text)[:1]
|
|
for key, pattern in {
|
|
"gles": r"Using (OpenGL ES[^\n]+)",
|
|
"gpu": r"GL renderer: ([^\n]+)",
|
|
"wlroots": r"wlroots version ([^\n]+)",
|
|
"scenefx": r"scenefx version ([^\n]+)",
|
|
}.items()}
|
|
damage = re.compile(r"TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)")
|
|
for match in damage.finditer(log.read_text(errors="replace")):
|
|
damage_events.append((int(match.group(1)), match.group(2),
|
|
tuple(int(match.group(i)) for i in range(3, 7))))
|
|
result["damage"]["events"] += 1
|
|
output = match.group(2)
|
|
result["damage"]["outputs"][output] = result["damage"]["outputs"].get(output, 0) + 1
|
|
for side in ROIS:
|
|
baseline = run / f"{side}-FX0-off.png"
|
|
if not baseline.exists():
|
|
continue
|
|
with Image.open(baseline) as img:
|
|
base = img.convert("RGB")
|
|
previous = None
|
|
ordered = [(item["after_ns"], run / item["path"], item)
|
|
for item in captures.values() if item["output"] ==
|
|
("HEADLESS-1" if side == "left" else "HEADLESS-2")]
|
|
for _, path, capture in sorted(ordered):
|
|
with Image.open(path) as img:
|
|
image = img.convert("RGB")
|
|
if image.size != (800, 600):
|
|
result["failure"] = f"unexpected dimensions: {path.name} {image.size}"
|
|
result["captures"].append({"file": path.name, "side": side,
|
|
"phase": path.stem[len(side)+1:], "dimensions": image.size,
|
|
"sha256": sha(path), "before_ns": capture["before_ns"],
|
|
"after_ns": capture["after_ns"]})
|
|
if previous is not None:
|
|
older, old_image = previous
|
|
output = capture["output"]
|
|
interval = [box for ns, name, box in damage_events
|
|
if name == output and older["after_ns"] < ns <= capture["after_ns"]]
|
|
mask = Image.new("1", image.size, 0)
|
|
painter = ImageDraw.Draw(mask)
|
|
for box in interval:
|
|
painter.rectangle((box[0], box[1], box[2]-1, box[3]-1), fill=1)
|
|
changed = ImageChops.difference(old_image, image).convert("RGB")
|
|
outside = sum(px != (0, 0, 0) and not cover
|
|
for px, cover in zip(changed.getdata(), mask.getdata()))
|
|
result.setdefault("transitions", []).append({"side": side,
|
|
"from": older["tag"], "to": capture["tag"],
|
|
"changed_pixels": difference(old_image, image)["pixels"],
|
|
"damage_events": len(interval) if damage_events else None,
|
|
"changed_outside_damage": outside if damage_events else None})
|
|
previous = capture, image
|
|
if path != baseline:
|
|
result["comparisons"].append({"side": side,
|
|
"phase": path.stem[len(side)+1:],
|
|
"baseline": baseline.name,
|
|
"whole": difference(base, image),
|
|
"rois": {name: difference(base, image, box)
|
|
for name, box in ROIS[side].items()}})
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("lab", type=Path)
|
|
parser.add_argument("runs", nargs="+", type=Path)
|
|
parser.add_argument("--include-all-raw-runs", action="store_true",
|
|
help="manifest all retained runs, including excluded fixture variants")
|
|
args = parser.parse_args()
|
|
lab = args.lab.resolve()
|
|
out = lab / "effects-damage-results"
|
|
out.mkdir(mode=0o700, exist_ok=True)
|
|
results = [analyze_run(run.resolve()) for run in args.runs]
|
|
(out / "pixel-analysis.json").write_text(json.dumps(results, indent=2) + "\n")
|
|
raw_runs = list((lab / "runs").iterdir()) if args.include_all_raw_runs else args.runs
|
|
proofs = [path for run in raw_runs for path in run.resolve().rglob("*") if path.is_file()]
|
|
proofs += [lab / "fx-checker.png"]
|
|
proofs += [path for path in (lab / "effects-damage-run-generated.py",
|
|
lab / "effects-build-log.txt",
|
|
lab / "effects-source-sha256.txt",
|
|
out / "metadata.json",
|
|
out / "pixel-analysis.json") if path.exists()]
|
|
proofs += [path for path in (lab / "effects-preparation-logs").rglob("*") if path.is_file()]
|
|
manifest = out / "raw-evidence-sha256.txt"
|
|
manifest.write_text("".join(f"{sha(path)} {path.relative_to(lab)}\n"
|
|
for path in sorted(set(proofs))))
|
|
print(json.dumps({"runs": len(results), "captures": sum(len(r["captures"]) for r in results),
|
|
"raw_proofs": len(proofs), "manifest_sha256": sha(manifest),
|
|
"results": str(out)}, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|