#!/usr/bin/env python3 """Build the ONNX Netron report, model matrix, and gallery.""" from __future__ import annotations import argparse import csv import html import json import os from collections import Counter, defaultdict from pathlib import Path from typing import Any from netron_capture_common import REPO_ROOT, atomic_csv, atomic_json, load_csv, relative, resolve, sha256 MODEL_FIELDS = [ "model_id", "task", "task_group", "architecture_family", "format", "pair_netron_status", "fp32_onnx_status", "fp32_onnx_png", "public_quantized_onnx_status", "public_quantized_onnx_png", ] def markdown_link(report_dir: Path, root: Path, row: dict[str, str]) -> str: if row["capture_status"] != "PASS": return f"`{row['capture_status']}`" path = resolve(root, row["output_png"]) target = Path(os.path.relpath(path, report_dir)).as_posix() return f"[PNG]({target}) {row['output_png_width']}×{row['output_png_height']}" def _task_summary(rows: list[dict[str, str]]) -> list[dict[str, Any]]: grouped: dict[str, list[dict[str, str]]] = defaultdict(list) for row in rows: grouped[row["task_group"]].append(row) result = [] for task_group, values in sorted(grouped.items()): result.append( { "task_group": task_group, "models": len({row["model_id"] for row in values}), "slots": len(values), "pass": sum(row["capture_status"] == "PASS" for row in values), "onnx_pass": sum(row["capture_status"] == "PASS" and row["format"] == "onnx" for row in values), } ) return result def _model_matrix(rows: list[dict[str, str]]) -> list[dict[str, Any]]: by_key = {(row["model_id"], row["variant"], row["format"]): row for row in rows} result = [] for model_id in sorted({row["model_id"] for row in rows}): sample = next(row for row in rows if row["model_id"] == model_id) slots = { variant: by_key[(model_id, variant, "onnx")] for variant in ("fp32", "public_quantized") } pair_pass = all(slots[variant]["capture_status"] == "PASS" for variant in slots) result.append( { "model_id": model_id, "task": sample["task"], "task_group": sample["task_group"], "architecture_family": sample["architecture_family"], "format": "onnx", "pair_netron_status": "PASS" if pair_pass else "FAIL", "fp32_onnx_status": slots["fp32"]["capture_status"], "fp32_onnx_png": slots["fp32"]["output_png"], "public_quantized_onnx_status": slots["public_quantized"]["capture_status"], "public_quantized_onnx_png": slots["public_quantized"]["output_png"], } ) return result def build_report(root: Path, report_dir: Path) -> dict[str, Any]: rows = load_csv(report_dir / "netron_capture_inventory.csv") input_rows = load_csv(report_dir / "netron_input_inventory.csv") model_rows = _model_matrix(rows) atomic_csv(report_dir / "netron_model_matrix.csv", model_rows, MODEL_FIELDS) task_rows = _task_summary(rows) pass_rows = [row for row in rows if row["capture_status"] == "PASS"] missing_rows = [row for row in rows if row["capture_status"] != "PASS"] canonical_rows = [row for row in rows if row["canonical_s7_selected"].lower() == "true"] dimensions = [(int(row["output_png_width"]), int(row["output_png_height"])) for row in pass_rows] summary = { "schema_version": "1.0", "stage": "T80_NETRON_VISUALIZATION", "status": "PASS", "failure_code": None, "result_interpretation": "all 42 FP32/quantized ONNX variants exported", "counts": { "models": len(model_rows), "theoretical_slots": len(rows), "source_artifacts_available": sum(row["artifact_status"] == "AVAILABLE" for row in input_rows), "netron_exports_pass": len(pass_rows), "onnx_exports_pass": sum(row["format"] == "onnx" for row in pass_rows), "not_available": len(missing_rows), "canonical_pair_exports_pass": sum(row["capture_status"] == "PASS" for row in canonical_rows), "canonical_pair_exports_expected": len(canonical_rows), "ui_proof_images": len(pass_rows), "metadata_records": len(rows), "total_netron_export_bytes": sum(int(row["output_png_bytes"]) for row in pass_rows), "minimum_export_width": min(width for width, _ in dimensions), "maximum_export_width": max(width for width, _ in dimensions), "minimum_export_height": min(height for _, height in dimensions), "maximum_export_height": max(height for _, height in dimensions), }, "tool_versions": { "netron": sorted({row["netron_version"] for row in rows}), "playwright": sorted({row["playwright_version"] for row in rows}), "chromium": sorted({row["chromium_version"] for row in rows}), }, "task_coverage": task_rows, "not_available": [ { "model_id": row["model_id"], "variant": row["variant"], "format": row["format"], "failure_code": row["failure_code"], "failure_detail": row["failure_detail"], "production_stage_status": row["production_stage_status"], "production_failure_code": row["production_failure_code"], "production_command": row["production_command"], "production_stdout_log": row["production_stdout_log"], "production_stderr_log": row["production_stderr_log"], } for row in missing_rows ], "policy": { "netron_layout_used_as_execution_order": False, "conversion_or_converter_retry_performed": False, "model_weight_architecture_modified": False, "allocator_work_performed": False, "prohibited_operations_performed": [], }, } atomic_json(report_dir / "netron_capture_summary.json", summary) by_key = {(row["model_id"], row["variant"], row["format"]): row for row in rows} lines = [ "# Netron ONNX 그래프", "", "## 결론", "", "21개 모델의 FP32·공개 양자화 ONNX 42개를 Netron PNG로 생성했다.", "", "## 캡처 방식과 범위", "", "- 변환이 끝난 `.onnx` 파일을 Netron에 직접 열었다.", "- 전체 graph 그림은 Netron 브라우저의 `Export as PNG` (`Control+Shift+E`)를 사용했다. `*_netron_ui.png`는 실제 UI load 증빙용 viewport screenshot이다.", "- ONNX FP32와 공개 양자화 모델을 공통 비교 pair로 사용한다.", "", "## Coverage", "", "| Task | 모델 | ONNX variant | PASS |", "|---|---:|---:|---:|", ] for row in task_rows: lines.append( f"| {row['task_group']} | {row['models']} | {row['slots']} | {row['pass']} |" ) lines.extend( [ "", "## 모델별 Netron export", "", "| 모델 | Task | FP32 ONNX | Q ONNX |", "|---|---|---|---|", ] ) for model in model_rows: model_id = model["model_id"] cells = [ markdown_link(report_dir, root, by_key[(model_id, "fp32", "onnx")]), markdown_link(report_dir, root, by_key[(model_id, "public_quantized", "onnx")]), ] lines.append( f"| {model_id} | {model['task_group']} | {' | '.join(cells)} |" ) lines.extend( [ "", "## 결과 파일", "", "- [Netron 전체 gallery](netron_gallery.html): UI 증빙 thumbnail과 full graph export 링크", "- [Netron model matrix](netron_model_matrix.csv): 모델별 FP32·양자화 ONNX", "- [Netron capture inventory](netron_capture_inventory.csv): source/result/log/checksum/dimension 전체", "- [독립 검증](validation.json), [artifact manifest](artifact_manifest.json), [checksum 목록](artifacts.sha256)", "", "Netron 그림과 capture inventory가 이 단계의 결과다.", "", "## 재현 명령", "", "```bash", "bash environment/visualization/netron/bootstrap.sh", "PLAYWRIGHT_BROWSERS_PATH=environment/visualization/netron/browsers \\", " environment/visualization/netron/.venv/bin/python scripts/capture_netron_graphs.py \\", " --run-dir logs/graphs/netron/full_20260807_attempt3_resume_provenance --scope full", ".venv/bin/python scripts/run_netron_checks.py \\", " --run-dir logs/graphs/netron/final_20260807", "```", ] ) (report_dir / "netron_capture_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") cards = [] for row in sorted(rows, key=lambda value: (value["model_id"], value["variant"], value["format"])): title = f"{row['model_id']} · {row['variant']} · {row['format'].upper()}" if row["capture_status"] == "PASS": full_path = Path(os.path.relpath(resolve(root, row["output_png"]), report_dir)).as_posix() ui_path = Path(os.path.relpath(resolve(root, row["ui_proof_png"]), report_dir)).as_posix() metadata_path = Path(os.path.relpath(resolve(root, row["metadata_json"]), report_dir)).as_posix() cards.append( f'

{html.escape(title)}

' f'{html.escape(title)} Netron UI' f'

PASS · Netron export {row["output_png_width"]}×{row["output_png_height"]} · nodes {row["graph_node_count"]}

' f'

full Netron PNG · UI proof · metadata

' ) else: cards.append( f'

{html.escape(title)}

{html.escape(row["capture_status"])}

' f'

{html.escape(row["failure_code"])}: {html.escape(row["failure_detail"])}

' ) gallery = """ Netron graph gallery

실제 Netron graph gallery

각 thumbnail은 Netron UI 증빙 screenshot이며, full Netron PNG 링크가 Netron 자체 전체 graph export다. 배치는 실행 순서를 의미하지 않는다.

보고서 · machine-readable inventory

""" + "\n".join(cards) + "\n
\n" (report_dir / "netron_gallery.html").write_text(gallery, encoding="utf-8") readme = """# Netron ONNX graphs [netron_capture_report.md](netron_capture_report.md)에서 모델별 FP32·양자화 그래프를 확인한다. - `*_netron.png`: Netron 9.2.0 자체 전체 graph PNG export - `*_netron_ui.png`: Netron UI load 증빙 screenshot - `netron_capture_inventory.csv`: source/checksum/command/log/load/PNG 증빙 """ (report_dir / "README.md").write_text(readme, encoding="utf-8") return summary def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) parser.add_argument("--report-dir", type=Path, default=Path("reports/graphs/netron")) args = parser.parse_args() root = args.repo_root.resolve() report_dir = args.report_dir if args.report_dir.is_absolute() else root / args.report_dir report_dir.mkdir(parents=True, exist_ok=True) summary = build_report(root, report_dir) print(json.dumps({"status": summary["status"], **summary["counts"]}, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())