"""Generate slide-ready SVG visuals for the BridgeLink ASL presentation.""" from __future__ import annotations import json import math from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MANIFEST_PATH = ROOT / "data" / "vlm_eval_wlasl25_cnn" / "wlasl25_cnn_hybrid_eval.jsonl" OUTPUT_CANDIDATES = ( ROOT / "outputs" / "vlm_compare_local_fixed.jsonl", ROOT / "outputs" / "vlm_compare_local_retry.jsonl", ROOT / "outputs" / "vlm_compare_local.jsonl", ) OUTPUT_DIR = ROOT / "presentation" / "visuals" DISPLAY_FONT = "Fraunces, Georgia, Times New Roman, serif" BODY_FONT = "Aptos, Segoe UI, Helvetica, Arial, sans-serif" MONO_FONT = "IBM Plex Mono, Cascadia Code, Consolas, monospace" @dataclass(frozen=True) class ComparisonMetrics: samples: int classes: int cnn_top1: float cnn_top5: float vlm_accuracy: float | None vlm_failures: int | None output_path: str | None def main() -> None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) metrics = load_metrics() write_summary_json(metrics) write_pipeline_svg(metrics) write_scope_svg(metrics) write_comparison_svg(metrics) write_readme(metrics) print(f"Generated presentation visuals in: {OUTPUT_DIR}") def load_metrics() -> ComparisonMetrics: manifest_rows = load_jsonl(MANIFEST_PATH) true_labels = [normalize(row.get("true_label")) for row in manifest_rows] top1_labels = [normalize(row.get("cnn_top1") or row.get("model_top1")) for row in manifest_rows] top5_hits = [] for row, actual in zip(manifest_rows, true_labels): labels = [ normalize(item.get("label") if isinstance(item, dict) else item) for item in (row.get("cnn_top5") or row.get("model_top5") or []) ] top5_hits.append(actual in labels) cnn_top1 = round(sum(a == b for a, b in zip(true_labels, top1_labels)) / len(true_labels), 4) cnn_top5 = round(sum(top5_hits) / len(top5_hits), 4) classes = len({label for label in true_labels if label}) vlm_accuracy: float | None = None vlm_failures: int | None = None chosen_output: Path | None = None for candidate in OUTPUT_CANDIDATES: if candidate.exists(): chosen_output = candidate break if chosen_output is not None: rows = load_jsonl(chosen_output) if rows: vlm_correct = 0 vlm_failures = 0 for row in rows: expected = normalize(row.get("expected_text")) prediction = extract_vlm_label(row) if prediction is not None and normalize(prediction) == expected: vlm_correct += 1 if row.get("failure_notes"): vlm_failures += 1 vlm_accuracy = round(vlm_correct / len(rows), 4) return ComparisonMetrics( samples=len(manifest_rows), classes=classes, cnn_top1=cnn_top1, cnn_top5=cnn_top5, vlm_accuracy=vlm_accuracy, vlm_failures=vlm_failures, output_path=str(chosen_output) if chosen_output else None, ) def load_jsonl(path: Path) -> list[dict[str, object]]: rows: list[dict[str, object]] = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue row = json.loads(line) if isinstance(row, dict): rows.append(row) return rows def normalize(value: object) -> str: return str(value or "").strip().lower().replace("_", " ") def extract_vlm_label(row: dict[str, object]) -> str | None: prediction = row.get("vlm_prediction") if isinstance(prediction, dict): gloss = prediction.get("gloss") if isinstance(gloss, list) and gloss: return str(gloss[0]) sentence = prediction.get("sentence") if sentence: return str(sentence) if prediction: return str(prediction) return None def write_summary_json(metrics: ComparisonMetrics) -> None: payload = { "samples": metrics.samples, "classes": metrics.classes, "cnn_top1": metrics.cnn_top1, "cnn_top5": metrics.cnn_top5, "vlm_accuracy": metrics.vlm_accuracy, "vlm_failures": metrics.vlm_failures, "output_path": metrics.output_path, } (OUTPUT_DIR / "metrics-summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") def write_pipeline_svg(metrics: ComparisonMetrics) -> None: cards = [ (90, 250, 250, 180, "Input", "Live webcam\nor 2-5 second clip", "#FF8A5B"), (390, 180, 300, 250, "Per-frame perception", "MediaPipe Holistic\n33 pose + 21 left hand + 21 right hand\n225-D landmark vector per frame", "#F2C94C"), (740, 160, 320, 270, "Temporal modeling", "Rolling 32-frame buffer\nTemporal landmark CNN\nWLASL-25 for demo\nWLASL-100 for report experiment", "#2DCEB1"), (1110, 180, 320, 250, "Output + comparison", "Top-1 sign caption\nTop-5 candidates\nOptional Qwen2.5-VL reranker\nSpeech + Hugging Face Space UI", "#6AA6FF"), ] svg = [ svg_header(1600, 900), dark_background(), glow(180, 120, 260, "#FF8A5B", 0.22), glow(1320, 170, 300, "#6AA6FF", 0.18), title_block( "BridgeLink ASL Pipeline", "The deployed demo uses MediaPipe landmarks plus a trained temporal CNN. " "The VLM stays off the live path and acts only as a comparison reranker.", ), stat_pill(110, 105, "Evaluation set", f"{metrics.samples} clips / {metrics.classes} classes"), stat_pill(470, 105, "Live demo model", "WLASL-25 landmark CNN"), stat_pill(855, 105, "Comparison model", "Local Qwen2.5-VL reranker"), ] for x, y, w, h, heading, body, color in cards: svg.append(card(x, y, w, h, heading, body, color)) svg.extend( [ arrow(340, 340, 390, 305, "#F7F1E8"), arrow(690, 305, 740, 295, "#F7F1E8"), arrow(1060, 295, 1110, 305, "#F7F1E8"), note_callout( 1060, 560, 430, 170, "Presentation talking point", "Train the CNN ourselves. Use the local VLM only to rerank the CNN's top-5 labels. " "That keeps the live demo fast and makes the comparison academically honest.", "#111827", "#F7F1E8", ), ] ) svg.append(svg_footer()) (OUTPUT_DIR / "bridgelink_pipeline.svg").write_text("".join(svg), encoding="utf-8") def write_scope_svg(metrics: ComparisonMetrics) -> None: svg = [ svg_header(1600, 900), light_background(), editorial_grid(), f'What We Trained vs What We Reused', f'A slide-ready scope board for explaining the final project story clearly.', panel(105, 220, 650, 560, "Trained by our team", "#FFFBF5", "#D97B37"), panel(845, 220, 650, 560, "Used without training", "#F7FAFF", "#457BFF"), ] trained_items = [ ("WLASL-100 landmark CNN", "Primary report-scale experiment. Trained on MediaPipe landmark sequences."), ("WLASL-25 landmark CNN", "Smaller vocabulary for the live webcam demo and HF Space stability."), ("Landmark Transformer", "Attention-based extension. Kept as extra-credit / modern-method evidence."), ] reused_items = [ ("MediaPipe Holistic", "Frozen feature extractor that turns each frame into 225 landmark coordinates."), ("Qwen2.5-VL local model", "Pretrained VLM used only as a zero-shot reranker over the CNN top-5."), ("Gradio + Hugging Face Space", "Presentation UI and deployment layer for the class demo."), ] for idx, (title, desc) in enumerate(trained_items): svg.append(scope_row(145, 280 + idx * 165, title, desc, "#D97B37", "TRAINED")) for idx, (title, desc) in enumerate(reused_items): svg.append(scope_row(885, 280 + idx * 165, title, desc, "#457BFF", "REUSED")) svg.append( note_callout( 110, 815, 1385, 55, "One-sentence takeaway", "We trained the recognition models ourselves, but we compared them against a frozen local VLM instead of fine-tuning the VLM. " "That still satisfies the project scope because the trained CNN is the main model and the VLM is an evaluated comparison method.", "#11203C", "#F7F1E8", ) ) svg.append(svg_footer()) (OUTPUT_DIR / "project_scope_board.svg").write_text("".join(svg), encoding="utf-8") def write_comparison_svg(metrics: ComparisonMetrics) -> None: vlm_value = metrics.vlm_accuracy if metrics.vlm_accuracy is not None else 0.0 bars = [ ("CNN top-1", metrics.cnn_top1, "#E86D3D"), ("CNN top-5", metrics.cnn_top5, "#3FB7A8"), ("Qwen rerank", vlm_value, "#4D7CFE"), ] max_width = 620 start_x = 210 start_y = 280 row_gap = 150 svg = [ svg_header(1600, 900), dark_background(), glow(1240, 160, 280, "#4D7CFE", 0.22), f'CNN vs VLM: What Actually Happened', f'Held-out WLASL-25 reranking set: {metrics.samples} clips across {metrics.classes} classes.', stat_pill_dark(111, 205, "Key message", "Qwen matched CNN top-1, but did not beat it."), stat_pill_dark(540, 205, "Failures", f"{metrics.vlm_failures if metrics.vlm_failures is not None else 0} wrapper failures"), ] for index, (label, value, color) in enumerate(bars): y = start_y + index * row_gap width = max(18, int(max_width * value)) svg.extend( [ f'{escape(label)}', f'', f'', f'{value * 100:.1f}%', ] ) svg.append( note_callout( 960, 325, 500, 250, "Interpretation", "The CNN learned enough to surface the right sign in its candidate list more than half the time. " "The local VLM successfully ran end to end, but it did not improve accuracy over the trained CNN baseline on this small evaluation set.", "#0F172A", "#F8F3EA", ) ) svg.append( mini_table( 210, 640, [ ("Metric", "Value"), ("Evaluation clips", str(metrics.samples)), ("Unique classes", str(metrics.classes)), ("CNN top-1", f"{metrics.cnn_top1 * 100:.1f}%"), ("CNN top-5", f"{metrics.cnn_top5 * 100:.1f}%"), ("Qwen rerank", f"{vlm_value * 100:.1f}%"), ], ) ) svg.append(svg_footer()) (OUTPUT_DIR / "cnn_vs_vlm_comparison.svg").write_text("".join(svg), encoding="utf-8") def write_readme(metrics: ComparisonMetrics) -> None: text = f"""# Presentation Visuals Generated slide-ready visuals for BridgeLink ASL. ## Files - `bridgelink_pipeline.svg`: methodology / system diagram - `project_scope_board.svg`: what the team trained versus what was reused - `cnn_vs_vlm_comparison.svg`: final comparison numbers for the presentation - `metrics-summary.json`: source values used to render the comparison slide ## Current values - Evaluation set: {metrics.samples} clips - Unique classes: {metrics.classes} - CNN top-1: {metrics.cnn_top1 * 100:.1f}% - CNN top-5: {metrics.cnn_top5 * 100:.1f}% - Qwen rerank: {(metrics.vlm_accuracy or 0.0) * 100:.1f}% - VLM wrapper failures: {metrics.vlm_failures if metrics.vlm_failures is not None else "unknown"} ## Regenerate ```powershell python scripts\\generate_presentation_visuals.py ``` """ (OUTPUT_DIR / "README.md").write_text(text, encoding="utf-8") def svg_header(width: int, height: int) -> str: return ( f'' ) def svg_footer() -> str: return "" def dark_background() -> str: return """ """ def light_background() -> str: return """ """ def editorial_grid() -> str: lines = [] for x in range(80, 1600, 140): lines.append(f'') for y in range(120, 900, 120): lines.append(f'') return "".join(lines) def glow(cx: int, cy: int, radius: int, color: str, opacity: float) -> str: return ( f'' ) def title_block(title: str, subtitle: str) -> str: return ( f'{escape(title)}' f'{escape(subtitle)}' ) def stat_pill(x: int, y: int, label: str, value: str) -> str: return ( f'' f'{escape(label.upper())}' f'{escape(value)}' ) def stat_pill_dark(x: int, y: int, label: str, value: str) -> str: return ( f'' f'{escape(label.upper())}' f'{escape(value)}' ) def card(x: int, y: int, w: int, h: int, heading: str, body: str, accent: str) -> str: body_lines = body.split("\n") text = [ f'', f'', f'{escape(heading)}', ] for idx, line in enumerate(body_lines): text.append( f'{escape(line)}' ) return "".join(text) def arrow(x1: int, y1: int, x2: int, y2: int, color: str) -> str: angle = math.atan2(y2 - y1, x2 - x1) arrow_x = x2 - 18 * math.cos(angle) arrow_y = y2 - 18 * math.sin(angle) wing = 12 left_x = arrow_x - wing * math.cos(angle - math.pi / 2) left_y = arrow_y - wing * math.sin(angle - math.pi / 2) right_x = arrow_x - wing * math.cos(angle + math.pi / 2) right_y = arrow_y - wing * math.sin(angle + math.pi / 2) return ( f'' f'' ) def note_callout(x: int, y: int, w: int, h: int, title: str, text: str, bg: str, fg: str) -> str: lines = wrap_lines(text, 55) nodes = [ f'', f'{escape(title.upper())}', ] for idx, line in enumerate(lines[:4]): nodes.append( f'{escape(line)}' ) return "".join(nodes) def panel(x: int, y: int, w: int, h: int, title: str, fill: str, accent: str) -> str: return ( f'' f'{escape(title)}' f'' ) def scope_row(x: int, y: int, title: str, desc: str, accent: str, badge: str) -> str: lines = wrap_lines(desc, 42) parts = [ f'', f'', f'{escape(badge)}', f'{escape(title)}', ] for idx, line in enumerate(lines[:2]): parts.append( f'{escape(line)}' ) return "".join(parts) def mini_table(x: int, y: int, rows: list[tuple[str, str]]) -> str: row_height = 42 width = 530 height = row_height * len(rows) + 24 out = [ f'' ] for idx, (left, right) in enumerate(rows): row_y = y + 28 + idx * row_height if idx == 0: out.append(f'') out.append( f'{escape(left)}' ) out.append( f'{escape(right)}' ) return "".join(out) def wrap_lines(text: str, width: int) -> list[str]: words = text.split() lines: list[str] = [] current: list[str] = [] for word in words: tentative = " ".join(current + [word]) if current and len(tentative) > width: lines.append(" ".join(current)) current = [word] else: current.append(word) if current: lines.append(" ".join(current)) return lines def escape(text: object) -> str: return ( str(text) .replace("&", "&") .replace("<", "<") .replace(">", ">") ) if __name__ == "__main__": main()