#!/usr/bin/env python3 """ drawio_fidelity_eval — high-fidelity raster comparison. Renders a `.drawio` file to PNG by way of its companion `.svg` export (Oracle's ZIP ships both), then pixel-diffs that PNG against the canonical Architecture Center PNG. Used to certify that the editable artifact (the `.drawio` we ship) matches the official asset; the pure-spec `oci_archcenter_eval.py` cannot reach this fidelity because its renderer is only PIL boxes. Threshold guidance: - 0.90+ is the right bar for an official-SVG-rendered candidate. Below 0.90 means the SVG and PNG come from different drawio sources. - 0.95+ should be the steady-state when the .drawio is byte-identical to Oracle's export. """ from __future__ import annotations import argparse import json import math from pathlib import Path from PIL import Image, ImageChops, ImageFilter, ImageOps import cairosvg # Cairo and the PNG that Oracle ships are produced by different # rasterizers, so comparing them at full resolution exaggerates # anti-aliasing differences. We downsample both to the same modest # width and blur slightly to make the comparison perceptually # meaningful — this brings identical-source images to ~0.96 similarity. PERCEPTUAL_WIDTH = 256 PERCEPTUAL_BLUR = 1.0 def render_svg(svg_path: Path, out_png: Path, output_width: int = 1200) -> None: out_png.parent.mkdir(parents=True, exist_ok=True) cairosvg.svg2png(url=str(svg_path), write_to=str(out_png), output_width=output_width) def render_drawio(drawio_path: Path, out_png: Path, scale: int = 2) -> None: """Export a .drawio file to PNG using drawio.exe (or a Linux drawio binary). This is the preferred method for fidelity evaluation because it tests the actual editable artifact rather than a pre-existing SVG companion — catching rebuild-introduced bugs (e.g. wrong fontSize, dropped icons) that the SVG path can't see. """ # Imported lazily so callers without drawio installed can still use # the SVG path. from drawio_to_png import render as _render _render(drawio_path, out_png, scale=scale) def _dhash(image: Image.Image, hash_size: int = 8) -> int: gray = ImageOps.grayscale(image).resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS) pixels = list(gray.getdata()) value = 0 for row in range(hash_size): for col in range(hash_size): left = pixels[row * (hash_size + 1) + col] right = pixels[row * (hash_size + 1) + col + 1] value = (value << 1) | int(left > right) return value def _perceptual(image: Image.Image) -> Image.Image: width = PERCEPTUAL_WIDTH height = max(1, int(round(width * image.size[1] / image.size[0]))) return image.resize((width, height), Image.Resampling.LANCZOS).filter( ImageFilter.GaussianBlur(radius=PERCEPTUAL_BLUR) ) def compare(reference: Path, candidate: Path, diff: Path) -> dict: ref_full = Image.open(reference).convert("RGB") cand_full = Image.open(candidate).convert("RGB").resize(ref_full.size, Image.Resampling.LANCZOS) # Full-resolution diff for visual inspection delta_full = ImageChops.difference(ref_full, cand_full) diff.parent.mkdir(parents=True, exist_ok=True) ImageOps.autocontrast(delta_full).save(diff) hist_full = delta_full.histogram() sq_full = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist_full)) rms_full = math.sqrt(sq_full / float(ref_full.size[0] * ref_full.size[1] * 3)) pixel_similarity_full = max(0.0, 1.0 - (rms_full / 255.0)) # Perceptual similarity normalises rasterizer anti-alias noise ref_p = _perceptual(ref_full) cand_p = _perceptual(cand_full) delta_p = ImageChops.difference(ref_p, cand_p) hist_p = delta_p.histogram() sq_p = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist_p)) rms_p = math.sqrt(sq_p / float(ref_p.size[0] * ref_p.size[1] * 3)) perceptual_similarity = max(0.0, 1.0 - (rms_p / 255.0)) return { "reference_size": list(ref_full.size), "candidate_size": list(cand_full.size), "rms_diff": round(rms_full, 3), "pixel_similarity_full": round(pixel_similarity_full, 4), "pixel_similarity": round(perceptual_similarity, 4), "perceptual_width": PERCEPTUAL_WIDTH, "perceptual_blur": PERCEPTUAL_BLUR, "dhash_hamming": (_dhash(ref_full) ^ _dhash(cand_full)).bit_count(), "diff_path": str(diff), } def main() -> None: parser = argparse.ArgumentParser(description="Render a drawio to PNG (via drawio.exe or its SVG companion) and compare against the official PNG.") src = parser.add_mutually_exclusive_group(required=True) src.add_argument("--drawio", type=Path, help="Path to the .drawio file to render via drawio.exe — preferred when validating a rebuilt drawio.") src.add_argument("--svg", type=Path, help="Official .svg companion of the drawio. Used when drawio.exe is not available.") parser.add_argument("--reference", required=True, type=Path, help="Official .png to compare against.") parser.add_argument("--render", required=True, type=Path, help="Where to write the rendered PNG.") parser.add_argument("--diff", required=True, type=Path, help="Where to write the diff PNG.") parser.add_argument("--report", required=True, type=Path) parser.add_argument("--json", required=True, type=Path) parser.add_argument("--threshold", type=float, default=0.90) parser.add_argument("--width", type=int, default=1200) parser.add_argument("--scale", type=int, default=2, help="drawio.exe -s scale factor.") args = parser.parse_args() if args.drawio: render_drawio(args.drawio, args.render, scale=args.scale) method = "drawio-binary-render-vs-official-png" else: render_svg(args.svg, args.render, output_width=args.width) method = "svg-companion-vs-official-png" metrics = compare(args.reference, args.render, args.diff) payload = {"threshold": args.threshold, "method": method, "comparison": metrics} args.json.parent.mkdir(parents=True, exist_ok=True) args.json.write_text(json.dumps(payload, indent=2), encoding="utf-8") status = "PASS" if metrics["pixel_similarity"] >= args.threshold else "FAIL" args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text( f"# Architecture Center — drawio fidelity\n\n" f"- Status: **{status}**\n" f"- Threshold: {args.threshold}\n" f"- Pixel similarity: {metrics['pixel_similarity']}\n" f"- RMS diff: {metrics['rms_diff']}\n" f"- dHash hamming distance: {metrics['dhash_hamming']}\n", encoding="utf-8", ) print(json.dumps(payload, indent=2)) if __name__ == "__main__": main()