
Plotting Agent
- 46 installs
- 628 repo stars
- Updated July 9, 2026
- ar9av/paperorchestra
plotting-agent is a Claude skill that renders a paper's figures and diagrams from experiment data and writes their captions.
About
Step 2 of the PaperOrchestra pipeline that executes the visualization plan from the outline, rendering plots and conceptual diagrams from the experimental log and idea files. It optionally refines each figure through a vision-model critique loop and produces context-aware captions. A developer runs it to generate the figures a paper needs, in parallel with the literature-review agent.
- Step 2 of PaperOrchestra: render figures from the outline
- Generates matplotlib plots and conceptual diagrams at 300 DPI
- Optional VLM-critique loop redraws figures until clean
Plotting Agent by the numbers
- 46 all-time installs (skills.sh)
- Ranked #7,629 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
plotting-agent capabilities & compatibility
- Capabilities
- image generation · data analysis · research
- Use cases
- image generation · data analysis · research
What plotting-agent says it does
Execute the visualization plan from outline.json — render plots and conceptual diagrams from experimental_log.md and idea.md, optionally refine via VLM critique loop, and produce context-aware caption
**300 DPI** for every figure. Lower DPI gets rejected at the LaTeX compile step on conference templates.
npx skills add https://github.com/ar9av/paperorchestra --skill plotting-agentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 628 |
| Last updated | July 9, 2026 |
| Repository | ar9av/paperorchestra ↗ |
What it does
Render a paper's plots and conceptual diagrams from experiment data with an optional vision-critique loop.
Who is it for?
Generating publication-quality figures and captions for a paper from experiment data
Skip if: Writing prose sections or verifying citations
When should I use this skill?
The orchestrator delegates Step 2, or the user asks to generate the figures or render the plots from an experiment log
What you get
Produces one 300 DPI PNG per planned figure plus a captions.json map
- workspace/figures/<figure_id>.png
- workspace/figures/captions.json
By the numbers
- ~20-30 LLM calls per run
- figures rendered at 300 DPI
- VLM critique capped at 3 iterations per figure
Files
Plotting Agent (Step 2)
Faithful implementation of the Plotting Agent from PaperOrchestra (Song et al., 2026, arXiv:2604.05018, §4 Step 2 and App. F.1 p.45).
Cost: ~20–30 LLM calls. The paper uses PaperBanana (Zhu et al., 2026) as the default backbone with a closed-loop VLM-critique refinement. This skill expresses that loop in host-agent terms: you (the host agent) generate matplotlib code with your own LLM, render via your Bash/Python tool, optionally critique the rendered PNG with your vision model, redraw, and finally caption.
Inputs
workspace/outline.json— specifically theplotting_planarrayworkspace/inputs/idea.mdandworkspace/inputs/experimental_log.md—
the source data
workspace/inputs/figures/— optional pre-existing figures (PlotOnmode)
Outputs
workspace/figures/<figure_id>.png— one PNG perplotting_planentry
(300 DPI, sized to the requested aspect ratio)
workspace/figures/captions.json—{figure_id: caption_text}map
Workflow
Per figure (executed independently per figure_id)
1. Read the figure spec from outline.json:
{
"figure_id": "fig_main_results",
"title": "Main Results on Dataset X",
"plot_type": "plot",
"data_source": "experimental_log.md",
"objective": "Visual summary (Grouped Bar Chart) demonstrating ...",
"aspect_ratio": "5:4"
}2. Few-shot retrieval (visual planning): pick the matching pattern from references/chart-patterns.md (for plot_type=="plot") or references/diagram-patterns.md (for plot_type=="diagram").
3. Extract data: parse idea.md and/or experimental_log.md (data_source field tells you which) to obtain the numeric values or conceptual entities the figure needs. For experimental_log.md, the ## 2. Raw Numeric Data section contains markdown tables.
4. Render:
If `PAPERBANANA_PATH` is set — use the PaperBanana backbone (Zhu et al., 2026). It runs a Retriever → Planner → Stylist → Visualizer → Critic loop and is especially good for plot_type == "diagram". See references/paperbanana-cookbook.md for setup (needs a Gemini API key).
python skills/plotting-agent/scripts/paperbanana_render.py \
--figure-id <figure_id> \
--caption "<objective from figure spec>" \
--content-file workspace/inputs/idea.md \
--task <diagram|plot> \
--aspect-ratio <aspect_ratio> \
--out workspace/figures/<figure_id>.pngOtherwise — write a matplotlib script and run it via your Bash tool, or use the bundled helper:
python skills/plotting-agent/scripts/render_matplotlib.py \
--spec spec.json \
--out workspace/figures/<figure_id>.pngThe script must apply the academic style from chart-patterns.md, use the correct pixel size from aspect-ratios.md, save at 300 DPI, and call plt.close() after savefig.
5. VLM critique loop (optional, only if your host has vision):
- Reload the rendered PNG as a multimodal input to your LLM.
- Critique it against the figure's
objectivefrom the outline. Look for:
visual artifacts, mislabeled axes, illegible text, color clashes, misleading scaling, missing legend, overlapping labels.
- If problems are found, regenerate the matplotlib script with corrections
and re-render. Cap at 3 critique iterations per figure.
- This is the closed-loop refinement step the paper inherits from
PaperBanana. See references/plotting-pipeline.md for the full loop description.
- If your host has no vision input, skip this step entirely. The
figure will still render correctly, just without iterative refinement.
6. Generate the caption using the verbatim Caption Generation prompt at references/caption-prompt.md. Inputs to the caption prompt:
task_name— the section the figure belongs to (e.g., "Methodology",
"Experiments")
raw_content— the surrounding section text (or content_bullets from
the section_plan if the section isn't drafted yet)
description— theobjectivefield from the figure specfigure_desc— a 1-sentence description of what the rendered figure
actually shows (from your VLM critique pass, or from the script's plan if no vision)
Write the caption to workspace/figures/captions.json keyed by figure_id. Captions must NOT contain `Figure N:` or `Caption N:` prefixes — the LaTeX template handles numbering. Plain text only, no markdown.
Conceptual diagrams
For plot_type == "diagram", prefer PaperBanana when available — its Retriever grounds the Planner in real published paper diagrams. If PAPERBANANA_PATH is unset, follow references/diagram-patterns.md. Patterns include block diagrams, system overviews, flowcharts, and algorithm-as-graph. The bundled helper:
python skills/plotting-agent/scripts/render_diagram.py \
--spec diagram_spec.json \
--out workspace/figures/<figure_id>.pnghandles the simple cases (boxes-and-arrows). For complex Fig-1-style overview diagrams, write matplotlib patches code yourself.
Hard rules
- 300 DPI for every figure. Lower DPI gets rejected at the LaTeX compile
step on conference templates.
- Aspect ratio is exact. The figure spec's
aspect_ratiois one of 12
enumerated strings. Use the pixel targets in references/aspect-ratios.md.
- Hide top and right spines for plots. (Diagrams: no spines at all.)
- Muted academic colors only. The palette is in
chart-patterns.md.
Never use matplotlib defaults (too saturated for print).
- No 3D, no pie charts, no decorative visuals. The paper's evaluators
penalize these.
- Every figure MUST have a caption in
captions.json. The Section
Writing Agent will fail-stop if a caption is missing for any figure referenced from the outline.
- No `Figure N:` prefix in captions — LaTeX adds it.
- Never describe data you didn't plot. The Plotting Agent must not
hallucinate axes, baselines, or trends. Source-of-truth is experimental_log.md or idea.md.
Pre-existing figures (PlotOn mode)
If workspace/inputs/figures/ is non-empty, check whether any pre-existing file matches a figure_id in the outline (by filename prefix). If so, copy it into workspace/figures/ as-is and still generate a caption using the caption prompt. Only generate from scratch the figure_ids that have no pre-existing counterpart.
Resources
references/caption-prompt.md— verbatim Caption Generation prompt from App. F.1references/plotting-pipeline.md— the full few-shot → render → critique → caption loopreferences/chart-patterns.md— matplotlib style + chart type recipesreferences/diagram-patterns.md— conceptual diagram recipesreferences/aspect-ratios.md— pixel targets for each of the 12 allowed ratios at 300 DPIreferences/paperbanana-cookbook.md— NEW PaperBanana setup, usage, cost notes, attributionscripts/render_matplotlib.py— render a JSON plot spec → PNG (matplotlib fallback)scripts/render_diagram.py— render a JSON diagram spec → PNG (matplotlib fallback)scripts/paperbanana_render.py— NEW PaperBanana backbone wrapper (readsPAPERBANANA_PATHfrom env)
Aspect Ratios
The Outline Agent's prompt enumerates exactly 12 allowed aspect ratios (App. F.1, page 41). Each one maps to a specific figure size in inches at 300 DPI. The plotting agent MUST use these — anything else fails the outline schema validator.
| Ratio | Inches (W × H) | Pixels @ 300 DPI | Use case |
|---|---|---|---|
1:1 | 3.4 × 3.4 | 1020 × 1020 | Square radar chart, 1×1 ablation grid |
1:4 | 1.8 × 7.2 | 540 × 2160 | Tall vertical strip (rare; e.g., per-class bars) |
2:3 | 3.4 × 5.1 | 1020 × 1530 | Portrait single-column figure |
3:2 | 5.1 × 3.4 | 1530 × 1020 | Landscape single-column figure |
3:4 | 3.0 × 4.0 | 900 × 1200 | Tall stacked subplots |
4:1 | 7.0 × 1.75 | 2100 × 525 | Banner / timeline / strip-plot |
4:3 | 4.0 × 3.0 | 1200 × 900 | Standard single-column rectangle |
4:5 | 3.2 × 4.0 | 960 × 1200 | Slight portrait, 5-row heatmap |
5:4 | 4.5 × 3.6 | 1350 × 1080 | Wide single-column |
9:16 | 2.8 × 4.97 | 840 × 1491 | Mobile / tall portrait |
16:9 | 5.5 × 3.09 | 1650 × 927 | Cross-column wide chart |
21:9 | 7.0 × 3.0 | 2100 × 900 | Full-page-width banner |
Width budget
The widths are chosen to slot into the most common LaTeX layouts:
- 2-column conference template (CVPR, ICCV, NeurIPS):
single-column = 3.3in, double-column = 7.0in. Use widths ≤ 3.5in for \begin{figure} and ≤ 7.0in for \begin{figure*}.
- 1-column template (ICLR, plain article): page text width is ~6.5in.
The fig_size_for(ratio) helper in chart-patterns.md returns the matching (width, height) tuple. The bundled render_matplotlib.py script uses the same table.
DPI
Always 300. Conference templates reject ≤150 DPI raster figures.
Strict mapping in JSON spec
The render_matplotlib.py and render_diagram.py helpers accept a JSON spec with "aspect_ratio" set to one of the 12 strings above. They look up the size, set figsize, set dpi=300, and call tight_layout() + bbox_inches='tight' automatically.
Plotting Agent — Caption Generation prompt
Source: arXiv:2604.05018, Appendix F.1, page 45 (verbatim).
The paper uses PaperBanana (Zhu et al., 2026) for the entire visual generation pipeline; it then appends a single caption regeneration step at the end using the prompt below. We reproduce only this final caption step verbatim because it is the part the paper authors actually wrote (PaperBanana's internal prompts are external and not reproduced here).
---
Input Data
- Task Type: {task_name}
- Contextual Section: {raw_content}
- Overall Figure Intent: {description}
- Detailed Figure Description: {figure_desc}
Please provide the final caption for this figure based on the system
instructions.
Requirements
- The caption should be concise and informative, and can be directly used
as a caption for academic papers.
- The caption MUST NOT contain a "Figure X:" or "Caption X:" prefix, as
the latex template will add it automatically.
- The caption MUST NOT contain any markdown formatting (like bold,
italics, etc), it should be plain text.
Respond with the plain text caption only.---
Field substitution guide
| Field | Source |
|---|---|
{task_name} | The section the figure belongs to. Look it up in outline.json — find which section's content_bullets (or surrounding text in drafts/paper.tex if Step 4 has run) reference this figure_id. Common values: "Methodology", "Experiments", "Ablation Studies". |
{raw_content} | The text of the section the figure appears in. If Step 4 has run, paste the relevant paragraph from drafts/paper.tex. If not, paste the joined content_bullets from the corresponding subsection in outline.json. |
{description} | The objective field from the figure spec in outline.json. |
{figure_desc} | A 1-2 sentence factual description of what the rendered PNG actually contains. If your host has vision, generate this by inspecting the rendered PNG. If not, derive it from the matplotlib script you wrote (e.g., "Grouped bar chart comparing methods A, B, C across metrics M1 and M2."). |
Output
Plain text. One caption. No prefix, no markdown, no quotes around it. Save the result into workspace/figures/captions.json keyed by figure_id:
{
"fig_main_results": "Comparison of three temporal-attention variants on the Ref-AVS Seen split. Bars show Jaccard index; error bars are 95% confidence intervals over five seeds.",
"fig_framework_overview": "Overview of the proposed pipeline. Raw video frames flow into the frozen SAM encoder; aligned audio cues are projected through the temporal modality fusion layer before being injected into the mask decoder."
}Chart Patterns
Matplotlib recipes for academic-paper figures. Adapted from ~/.all-skills/academic-paper/references/chart-patterns.md and tuned for the PaperOrchestra plotting agent's aspect-ratio constraints.
Global style config
Apply this at the top of every plotting script.
import matplotlib
matplotlib.use('Agg') # headless
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams.update({
'font.family': 'serif',
'font.serif': ['Times New Roman', 'DejaVu Serif'],
'font.size': 8,
'axes.titlesize': 9,
'axes.titleweight': 'bold',
'axes.labelsize': 8,
'axes.linewidth': 0.6,
'legend.fontsize': 7,
'legend.framealpha': 0.95,
'legend.edgecolor': '#cccccc',
'xtick.labelsize': 7,
'ytick.labelsize': 7,
'figure.dpi': 300,
'savefig.dpi': 300,
'savefig.bbox': 'tight',
'savefig.pad_inches': 0.08,
'grid.alpha': 0.15,
'grid.linewidth': 0.5,
'lines.linewidth': 1.3,
})
# Muted academic palette (print-safe)
BLUE = '#2060cc'
RED = '#cc3030'
GREEN = '#208040'
ORANGE = '#cc7020'
PURPLE = '#8040cc'
GOLD = '#b08020'
GRAY = '#666666'
PALETTE = [BLUE, RED, GREEN, ORANGE, PURPLE, GOLD, GRAY]Per-pattern recipes
Line chart (training curves, scaling laws)
fig, ax = plt.subplots(figsize=fig_size_for("16:9"))
for i, (name, ys) in enumerate(series.items()):
ax.plot(xs, ys, color=PALETTE[i], label=name)
ax.set_xlabel('Training step')
ax.set_ylabel('Validation loss')
ax.legend(loc='upper right', frameon=True)
ax.grid(True)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.tight_layout()
fig.savefig(out_path)
plt.close()For noisy time-series, smooth with a rolling mean before plotting:
def rolling(y, w=7):
return np.convolve(y, np.ones(w)/w, mode='valid')Grouped bar chart (method comparison across metrics)
methods = ['Baseline', 'Ours-S', 'Ours-L']
metrics = ['Acc', 'F1', 'AUC']
data = np.array([[78, 79, 0.83],
[82, 84, 0.87],
[85, 87, 0.91]]) # rows=methods, cols=metrics
fig, ax = plt.subplots(figsize=fig_size_for("5:4"))
x = np.arange(len(metrics))
w = 0.25
for i, m in enumerate(methods):
ax.bar(x + (i-1)*w, data[i], w, color=PALETTE[i], label=m,
edgecolor='white', linewidth=0.4)
ax.set_xticks(x)
ax.set_xticklabels(metrics)
ax.set_ylabel('Score')
ax.legend(loc='upper left')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.grid(axis='y', alpha=0.2)
fig.tight_layout()
fig.savefig(out_path)
plt.close()Radar chart (multi-axis SOTA comparison — paper's Fig 2 pattern)
labels = ['Originality', 'Quality', 'Clarity', 'Significance', 'Soundness']
ours = [3.0, 3.4, 3.6, 3.0, 3.4]
baseline = [2.5, 2.7, 3.0, 2.3, 2.8]
angles = np.linspace(0, 2*np.pi, len(labels), endpoint=False).tolist()
angles += angles[:1]
ours = ours + ours[:1]
baseline = baseline + baseline[:1]
fig, ax = plt.subplots(figsize=fig_size_for("1:1"), subplot_kw=dict(polar=True))
ax.plot(angles, ours, color=BLUE, linewidth=1.5, label='Ours')
ax.fill(angles, ours, color=BLUE, alpha=0.15)
ax.plot(angles, baseline, color=RED, linewidth=1.5, label='Baseline')
ax.fill(angles, baseline, color=RED, alpha=0.10)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(labels, fontsize=7)
ax.set_yticks([1, 2, 3, 4])
ax.set_ylim(0, 4)
ax.legend(loc='upper right', bbox_to_anchor=(1.2, 1.05))
fig.tight_layout()
fig.savefig(out_path)
plt.close()Stacked bar / win-rate plot (paper's Fig 2/3 SxS pattern)
methods = ['Single Agent', 'AI-Sci-v2', 'PaperOrchestra']
wins = [33, 22, 65]
ties = [22, 18, 25]
losses = [45, 60, 10]
fig, ax = plt.subplots(figsize=fig_size_for("4:3"))
x = np.arange(len(methods))
ax.bar(x, losses, color=RED, label='Baseline win', edgecolor='white', linewidth=0.4)
ax.bar(x, ties, bottom=losses, color=GRAY, label='Tie', edgecolor='white', linewidth=0.4)
ax.bar(x, wins, bottom=np.array(losses)+np.array(ties),
color=BLUE, label='Our win', edgecolor='white', linewidth=0.4)
ax.set_xticks(x)
ax.set_xticklabels(methods)
ax.set_ylabel('Percentage (%)')
ax.set_ylim(0, 100)
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.tight_layout()
fig.savefig(out_path)
plt.close()Heatmap (ablation grid)
import matplotlib.colors as mcolors
data = np.random.rand(5, 6) * 0.4 + 0.5 # placeholder
xticks = ['M1', 'M2', 'M3', 'M4', 'M5', 'M6']
yticks = ['A=0.1', 'A=0.2', 'A=0.5', 'A=1.0', 'A=2.0']
fig, ax = plt.subplots(figsize=fig_size_for("4:3"))
im = ax.imshow(data, cmap='Blues', vmin=0.4, vmax=1.0, aspect='auto')
ax.set_xticks(range(len(xticks)))
ax.set_xticklabels(xticks)
ax.set_yticks(range(len(yticks)))
ax.set_yticklabels(yticks)
for i in range(data.shape[0]):
for j in range(data.shape[1]):
ax.text(j, i, f'{data[i,j]:.2f}', ha='center', va='center',
color='white' if data[i,j] > 0.7 else 'black', fontsize=6)
fig.colorbar(im, ax=ax, fraction=0.04, pad=0.02)
fig.tight_layout()
fig.savefig(out_path)
plt.close()Multi-panel (side-by-side ablation/case study)
fig, axes = plt.subplots(1, 3, figsize=fig_size_for("21:9"))
for ax, (label, data) in zip(axes, panels.items()):
ax.plot(data['x'], data['y'], color=BLUE)
ax.set_title(label, fontsize=8, fontweight='bold')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.tight_layout()
fig.savefig(out_path)
plt.close()Aspect ratio helper
def fig_size_for(ratio: str) -> tuple[float, float]:
"""Return (width_inches, height_inches) at a fixed width target.
Width target: 5.5in for single-column, 7.0in for full-page wide."""
w_to_h = {
"1:1": (3.4, 3.4),
"1:4": (1.8, 7.2),
"2:3": (3.4, 5.1),
"3:2": (5.1, 3.4),
"3:4": (3.0, 4.0),
"4:1": (7.0, 1.75),
"4:3": (4.0, 3.0),
"4:5": (3.2, 4.0),
"5:4": (4.5, 3.6),
"9:16": (2.8, 4.97),
"16:9": (5.5, 3.09),
"21:9": (7.0, 3.0),
}
return w_to_h[ratio]Anti-patterns
- Never use 3D charts, pie charts, or decorative visuals.
- Never use default matplotlib colors (too saturated for print).
- Never skip axis labels or units.
- Never place legend outside the axes area without
bbox_to_anchor(causes overflow). - Always
plt.close()aftersavefig()to prevent memory leaks across many figures. - Never put a
Figure N:text into the chart itself — captions handle that.
Conceptual Diagram Patterns
For plot_type == "diagram" figures: framework overviews, system pipelines, algorithmic flows, architectural block diagrams.
The paper's Fig. 1 (PaperOrchestra overview) is a canonical example: boxes representing agents, arrows showing data flow, grouped sub-systems with labeled inputs and outputs.
Tools
- matplotlib patches — best for hand-controlled layouts (boxes-and-arrows
with exact positioning, labels, and group rectangles). No external deps.
- graphviz — best for DAGs where layout doesn't matter; requires the
graphviz Python binding and the dot system binary. Use only if the diagram is purely topological.
This skill defaults to matplotlib because it ships in requirements.txt and needs no system binary.
Block diagram pattern (boxes + arrows)
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
from matplotlib.lines import Line2D
# 16:9 frame
fig, ax = plt.subplots(figsize=(7.0, 3.94))
ax.set_xlim(0, 10); ax.set_ylim(0, 6)
ax.axis('off')
PALETTE = {
'input': '#cfe2f3',
'agent': '#9fc5e8',
'output': '#b6d7a8',
'control': '#ead1dc',
'border': '#2060cc',
}
def box(x, y, w, h, text, color):
bb = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.06,rounding_size=0.15",
ec=PALETTE['border'], fc=color, lw=0.8)
ax.add_patch(bb)
ax.text(x + w/2, y + h/2, text, ha='center', va='center',
fontsize=8, fontweight='bold')
def arrow(x1, y1, x2, y2):
a = FancyArrowPatch((x1, y1), (x2, y2),
arrowstyle='->', mutation_scale=10,
color=PALETTE['border'], lw=0.9)
ax.add_patch(a)
# Inputs (left column)
box(0.2, 4.8, 1.6, 0.7, "Idea (I)", PALETTE['input'])
box(0.2, 3.6, 1.6, 0.7, "Exp Log (E)", PALETTE['input'])
box(0.2, 2.4, 1.6, 0.7, "Template (T)", PALETTE['input'])
box(0.2, 1.2, 1.6, 0.7, "Guidelines (G)", PALETTE['input'])
# Agents (middle)
box(2.6, 3.6, 1.8, 1.0, "Outline\nAgent", PALETTE['agent'])
box(5.0, 4.8, 1.8, 0.9, "Plotting\nAgent", PALETTE['agent'])
box(5.0, 3.0, 1.8, 0.9, "Lit Review\nAgent", PALETTE['agent'])
box(5.0, 1.2, 1.8, 0.9, "Section\nWriter", PALETTE['agent'])
box(7.5, 2.4, 2.0, 1.6, "Refinement\nAgent", PALETTE['control'])
# Output
box(7.5, 0.4, 2.0, 0.9, "paper.tex\n+ paper.pdf", PALETTE['output'])
# Arrows
for y_in in (5.15, 3.95, 2.75, 1.55):
arrow(1.8, y_in, 2.6, 4.1)
arrow(4.4, 4.1, 5.0, 5.25)
arrow(4.4, 4.1, 5.0, 3.45)
arrow(4.4, 4.1, 5.0, 1.65)
for y in (5.25, 3.45, 1.65):
arrow(6.8, y, 7.5, 3.2)
arrow(8.5, 2.4, 8.5, 1.3)
fig.savefig(out_path, dpi=300, bbox_inches='tight', pad_inches=0.05)
plt.close()Pipeline / flowchart pattern (linear with branches)
Same primitives, just arranged left-to-right with arrow(x1, y1, x2, y2) between successive boxes. For parallel branches (Steps 2 ∥ 3 in PaperOrchestra), draw two parallel rows with a fork-and-join.
Algorithm-as-graph pattern
For diagrams that are just a topological graph (no labels of importance, just nodes-and-edges), use graphviz:
import graphviz
g = graphviz.Digraph(format='png')
g.attr(dpi='300', rankdir='LR', fontname='Times-Roman', fontsize='8')
g.attr('node', shape='box', style='rounded,filled',
fillcolor='#cfe2f3', color='#2060cc', fontname='Times-Roman', fontsize='8')
g.attr('edge', color='#2060cc', fontname='Times-Roman', fontsize='7')
g.edge('Input', 'Encoder')
g.edge('Encoder', 'Decoder')
g.edge('Decoder', 'Output')
g.render(out_path.replace('.png', ''), cleanup=True)Anti-patterns
- Never use clip-art icons or emoji.
- Never use color as the only signal (always pair with shape or label).
- Never let arrows cross labels — re-route around.
- Never use a bitmap background.
- Always
axis('off')for diagrams; no spines, no ticks, no grid.
PaperBanana Cookbook
Optional backbone for the plotting-agent's render step.
PaperBanana (Zhu et al., 2026 — <https://github.com/dwzhu-pku/PaperBanana>) is the default figure-generation backbone described in the PaperOrchestra paper (arXiv:2604.05018, §4 Step 2). It runs a Retriever → Planner → Stylist → Visualizer → Critic multi-agent loop, producing publication-quality diagrams and plots that are grounded in a curated reference collection.
The plotting-agent works fine without PaperBanana — it falls back to the bundled matplotlib renderer (render_matplotlib.py / render_diagram.py). PaperBanana is best for:
| Use case | Recommendation |
|---|---|
Complex architectural / framework overview diagrams (plot_type == "diagram") | PaperBanana (Retriever grounds style in real paper examples) |
Plots backed by numeric data in experimental_log.md | Either; matplotlib gives more deterministic axis values |
| Hosts with no vision capability | Matplotlib (PaperBanana's Critic loop needs a VLM) |
| Batch / non-interactive runs | Either; PaperBanana adds API cost per figure |
---
1. One-time setup
# Clone PaperBanana
git clone https://github.com/dwzhu-pku/PaperBanana /path/to/PaperBanana
# Install dependencies (Python 3.12 required)
cd /path/to/PaperBanana
uv pip install -r requirements.txt # or: pip install -r requirements.txt
# Configure API key — fill at least one, you do not need both
cp configs/model_config.template.yaml configs/model_config.yamlOpen configs/model_config.yaml and paste your key:
| Provider | Where to get a key | Field to fill |
|---|---|---|
| Google (Gemini) | aistudio.google.com (free) | api_keys.google_api_key |
| OpenRouter | openrouter.ai | api_keys.openrouter_api_key |
If both are set, OpenRouter is preferred. For OpenRouter, set the model names to "openrouter/<model>" (e.g. "openrouter/google/gemini-pro-1.5").
# Point paper-orchestra at your clone
export PAPERBANANA_PATH="/path/to/PaperBanana"Optional model overrides (take precedence over model_config.yaml):
export PAPERBANANA_MAIN_MODEL="gemini-3.1-pro-preview"
export PAPERBANANA_IMAGE_MODEL="gemini-3.1-flash-image-preview"Verify the setup before running the pipeline:
python skills/plotting-agent/scripts/paperbanana_render.py --check-backend
# Expected output:
# PaperBanana found at: /path/to/PaperBanana
# Backend is ready.---
2. Rendering a single figure
# Diagram (e.g. Figure 1 overview)
python skills/plotting-agent/scripts/paperbanana_render.py \
--figure-id fig_overview \
--caption "Figure 1: Overview of our proposed PaperOrchestra framework." \
--content-file workspace/inputs/idea.md \
--task diagram \
--aspect-ratio 16:9 \
--out workspace/figures/fig_overview.png
# Plot (e.g. main results bar chart)
python skills/plotting-agent/scripts/paperbanana_render.py \
--figure-id fig_main_results \
--caption "Figure 3: Comparison of our method against baselines." \
--content-file workspace/inputs/experimental_log.md \
--task plot \
--aspect-ratio 5:4 \
--max-critic-rounds 2 \
--out workspace/figures/fig_main_results.pngExit codes:
0— success, image saved at--out1— PaperBanana pipeline error (see stderr)2—PAPERBANANA_PATHnot set or invalid → fall back to matplotlib
---
3. Input format (PaperBanana)
The wrapper converts the plotting-agent's figure spec to PaperBanana's input dict:
| plotting-agent field | PaperBanana field | Notes |
|---|---|---|
objective | caption + visual_intent | Used as generation prompt |
idea.md or experimental_log.md | content | Method/data context |
aspect_ratio | additional_info.rounded_ratio | Same string format |
plot_type | task_name | diagram or plot |
---
4. Output format (PaperBanana)
PaperBanana stores all intermediate and final images as base64-encoded JPEG strings in the result dictionary. The wrapper selects the best image using this priority:
1. Latest critic round: target_{task}_critic_descN_base64_jpg (N = 0…3) 2. eval_image_field pointer 3. Stylist output: target_{task}_stylist_desc0_base64_jpg 4. Planner output: target_{task}_desc0_base64_jpg 5. Vanilla baseline: vanilla_{task}_base64_jpg
The selected image is decoded and saved as a 300-DPI PNG to --out.
---
5. Pipeline modes
PaperBanana's exp_mode controls which agents run. The wrapper uses demo_full (full pipeline, no benchmark evaluation):
| Mode | Pipeline |
|---|---|
demo_full (default) | Retriever → Planner → Stylist → Visualizer → Critic (3×) |
demo_planner_critic | Retriever → Planner → Visualizer → Critic (3×) |
To override, set --exp-mode (future flag — hardcoded to demo_full today).
---
6. Cost and rate limits
- PaperBanana makes multiple LLM calls per figure (~5–10 in
demo_fullmode). - API cost depends on the model chosen; Gemini Flash is cheapest for image gen.
- No S2 / Exa calls — PaperBanana uses its own reference collection for retrieval.
- Runs one figure at a time when invoked via
paperbanana_render.py; parallel
batch processing is available via PaperBanana's native main.py if you have many figures.
---
7. Security notes
PAPERBANANA_PATH,PAPERBANANA_MAIN_MODEL, andPAPERBANANA_IMAGE_MODEL
are read from the environment only. This repo never commits these values.
- API keys for PaperBanana live in
{PAPERBANANA_PATH}/configs/model_config.yaml,
which is .gitignore'd in the PaperBanana repo. Never commit that file.
---
8. Attribution
If you use PaperBanana as the plotting backbone, cite:
@article{zhu2026paperbanana,
title={PaperBanana: A Reference-Driven Multi-Agent Framework for
Automated Academic Illustration Generation},
author={Zhu, Dawei and others},
year={2026},
url={https://github.com/dwzhu-pku/PaperBanana}
}Plotting Pipeline (per-figure loop)
Source: arXiv:2604.05018, §4 Step 2 ("Plotting Agent"), App. B (cost: ~20-30 LLM calls including few-shot retrieval, visual planning, image generation, VLM-guided critique-and-redraw cycles, and context-aware captioning).
The paper outsources visual generation to PaperBanana (Zhu et al., 2026) internally. We can't redistribute PaperBanana, so this skill expresses the loop structure in terms a host coding agent can execute with its own LLM and matplotlib.
The 5-stage per-figure loop
┌─────────────────────────────────────┐
│ outline.json → figure spec │
│ { figure_id, plot_type, objective, │
│ aspect_ratio, data_source } │
└─────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ STAGE 1 — Few-shot retrieval │
│ Pick the matching pattern from chart-patterns.md │
│ (plot) or diagram-patterns.md (diagram). Identify│
│ variables you'll need from the data sources. │
└─────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ STAGE 2 — Visual planning │
│ Sketch (in your head / in JSON) the chart layout:│
│ axes, series, colors, legend placement, title. │
│ Resolve aspect_ratio → pixel dimensions │
│ via aspect-ratios.md. │
└─────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ STAGE 3 — Image generation │
│ Write matplotlib code. Apply the global style │
│ from chart-patterns.md. Save to │
│ workspace/figures/<figure_id>.png at 300 DPI. │
│ Run via your Bash tool, OR call │
│ scripts/render_matplotlib.py with a spec JSON. │
└─────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ STAGE 4 — VLM critique loop (skip if no vision) │
│ for iter in 1..3: │
│ load PNG as multimodal input to your LLM │
│ critique against `objective` field │
│ if no issues: break │
│ else: regenerate matplotlib code, re-render │
└─────────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ STAGE 5 — Caption │
│ Use the verbatim caption-prompt.md. │
│ Save into figures/captions.json. │
└──────────────────────────────────────────────────┘What to look for in the VLM critique
When you (the host agent) inspect a rendered figure with vision, score it against this checklist (derived from the paper's plotting failure modes and the academic-paper skill's review-guide.md):
| Issue | Signal |
|---|---|
| Mislabeled or missing axes | Numeric tick marks with no axis label |
| Illegible text | Font size <6pt; tick labels overlapping |
| Color clash | Two adjacent series indistinguishable in print |
| Missing legend | More than one series, no legend |
| Overlapping legend | Legend covers a data point |
| Misleading scaling | Bar chart Y-axis doesn't start at 0; log scale unannounced |
| Cropped content | Title or labels cut off at edge |
| Missing units | Numeric axes without units in label |
| Decorative noise | Drop shadows, gradients, 3D effects |
| Unsupported claim | The plot shows a trend not present in the source data |
If any of these fire, regenerate the matplotlib script with the fix and re-render. Cap at 3 iterations.
Wall-time budget
The paper allocates ~20-30 LLM calls per Plotting Agent invocation. With ~5-10 figures per typical paper, that's ~3 calls per figure: roughly 1 (planning) + 1 (initial generation) + 1 (critique pass) + 0-1 (redraw) + 1 (caption). Stay within this budget by using deterministic helpers for the mechanical parts (render_matplotlib.py does pixel sizing, style, save) and reserving LLM calls for actual visual judgment.
#!/usr/bin/env python3
"""
paperbanana_render.py — Optional PaperBanana backbone for the plotting-agent's
render step.
PaperBanana (Zhu et al., 2026 — https://github.com/dwzhu-pku/PaperBanana) is the
default backbone used by the PaperOrchestra paper (arXiv:2604.05018, §4 Step 2)
for generating publication-quality diagrams and plots. This wrapper bridges the
plotting-agent's figure spec format to PaperBanana's
Retriever → Planner → Stylist → Visualizer → Critic pipeline.
SETUP (one-time):
1. Clone PaperBanana:
git clone https://github.com/dwzhu-pku/PaperBanana
2. Install its dependencies:
cd PaperBanana && uv pip install -r requirements.txt
3. Copy & fill in the config:
cp configs/model_config.template.yaml configs/model_config.yaml
# add google_api_key or openrouter_api_key
4. Export the path:
export PAPERBANANA_PATH="/path/to/PaperBanana"
OPTIONAL model overrides (read from environment):
PAPERBANANA_MAIN_MODEL (default: value in model_config.yaml)
PAPERBANANA_IMAGE_MODEL (default: value in model_config.yaml)
Usage:
# preflight check — prints backend status and exits
python paperbanana_render.py --check-backend
# render a diagram figure
python paperbanana_render.py \\
--figure-id fig_overview \\
--caption "Figure 1: Overview of our framework." \\
--content-file workspace/inputs/idea.md \\
--task diagram \\
--aspect-ratio "16:9" \\
--out workspace/figures/fig_overview.png
# render a plot figure with fewer critic rounds
python paperbanana_render.py \\
--figure-id fig_results \\
--caption "Figure 2: Comparison of baselines." \\
--content-file workspace/inputs/experimental_log.md \\
--task plot \\
--aspect-ratio "5:4" \\
--max-critic-rounds 1 \\
--out workspace/figures/fig_results.png
Exit codes:
0 image saved successfully
1 PaperBanana pipeline error
2 PAPERBANANA_PATH not set or invalid — caller should fall back to matplotlib
"""
import argparse
import asyncio
import base64
import os
import sys
from io import BytesIO
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_ASPECT_RATIOS = {
"1:1", "1:4", "2:3", "3:2", "3:4", "4:1", "4:3",
"4:5", "5:4", "9:16", "16:9", "21:9",
}
def _paperbanana_path() -> Path | None:
raw = os.environ.get("PAPERBANANA_PATH", "").strip()
if not raw:
return None
p = Path(raw)
# Sanity-check that it looks like a PaperBanana clone
if not (p / "utils" / "paperviz_processor.py").exists():
return None
return p
def check_backend() -> None:
pb = _paperbanana_path()
if pb is None:
env_val = os.environ.get("PAPERBANANA_PATH", "")
if env_val:
print(
f"PAPERBANANA_PATH={env_val!r} is set but does not point to a valid "
"PaperBanana clone (utils/paperviz_processor.py not found).\n"
"Clone PaperBanana and set PAPERBANANA_PATH to its root directory."
)
else:
print(
"PAPERBANANA_PATH is NOT set. PaperBanana backbone is unavailable.\n"
"The plotting-agent will use the matplotlib fallback (render_matplotlib.py\n"
"/ render_diagram.py).\n\n"
"To enable PaperBanana:\n"
" 1. git clone https://github.com/dwzhu-pku/PaperBanana\n"
" 2. cd PaperBanana && uv pip install -r requirements.txt\n"
" 3. cp configs/model_config.template.yaml configs/model_config.yaml\n"
" # fill in google_api_key or openrouter_api_key\n"
" 4. export PAPERBANANA_PATH=/path/to/PaperBanana"
)
sys.exit(2)
print(f"PaperBanana found at: {pb}")
main_model = os.environ.get("PAPERBANANA_MAIN_MODEL", "(from model_config.yaml)")
img_model = os.environ.get("PAPERBANANA_IMAGE_MODEL", "(from model_config.yaml)")
print(f" PAPERBANANA_MAIN_MODEL = {main_model}")
print(f" PAPERBANANA_IMAGE_MODEL = {img_model}")
print("Backend is ready.")
sys.exit(0)
# ---------------------------------------------------------------------------
# PaperBanana invocation
# ---------------------------------------------------------------------------
async def _run_pipeline(
pb_path: Path,
input_data: dict,
task_name: str,
max_critic_rounds: int,
main_model: str,
image_gen_model: str,
) -> dict | None:
"""Import PaperBanana from pb_path and run the full pipeline on a single item."""
sys.path.insert(0, str(pb_path))
try:
from utils import config as pb_config # type: ignore
from utils import paperviz_processor # type: ignore
from agents.vanilla_agent import VanillaAgent # type: ignore
from agents.planner_agent import PlannerAgent # type: ignore
from agents.visualizer_agent import VisualizerAgent # type: ignore
from agents.stylist_agent import StylistAgent # type: ignore
from agents.critic_agent import CriticAgent # type: ignore
from agents.retriever_agent import RetrieverAgent # type: ignore
from agents.polish_agent import PolishAgent # type: ignore
except ImportError as exc:
print(
f"ERROR: Could not import PaperBanana from {pb_path}: {exc}\n"
"Make sure you have run: uv pip install -r requirements.txt",
file=sys.stderr,
)
sys.exit(1)
exp_config = pb_config.ExpConfig(
dataset_name="paper-orchestra",
task_name=task_name,
split_name="single",
exp_mode="demo_full", # Retriever → Planner → Stylist → Visualizer → Critic
retrieval_setting="auto",
max_critic_rounds=max_critic_rounds,
main_model_name=main_model,
image_gen_model_name=image_gen_model,
work_dir=pb_path,
)
processor = paperviz_processor.PaperVizProcessor(
exp_config=exp_config,
vanilla_agent=VanillaAgent(exp_config=exp_config),
planner_agent=PlannerAgent(exp_config=exp_config),
visualizer_agent=VisualizerAgent(exp_config=exp_config),
stylist_agent=StylistAgent(exp_config=exp_config),
critic_agent=CriticAgent(exp_config=exp_config),
retriever_agent=RetrieverAgent(exp_config=exp_config),
polish_agent=PolishAgent(exp_config=exp_config),
)
results = []
async for result in processor.process_queries_batch(
[input_data], max_concurrent=1, do_eval=False
):
results.append(result)
return results[0] if results else None
def _extract_best_image_b64(result: dict, task_name: str) -> str | None:
"""
Find the best (latest critic round) base64 JPEG image in the result dict.
PaperBanana stores images as base64 JPEG strings keyed by:
target_{task_name}_critic_descN_base64_jpg (critic rounds, latest wins)
target_{task_name}_stylist_desc0_base64_jpg (stylist fallback)
target_{task_name}_desc0_base64_jpg (planner fallback)
vanilla_{task_name}_base64_jpg (last resort)
"""
# Try critic rounds from high to low
for round_idx in range(9, -1, -1):
key = f"target_{task_name}_critic_desc{round_idx}_base64_jpg"
if result.get(key):
return result[key]
# Try eval_image_field pointer
eval_key = result.get("eval_image_field")
if eval_key and result.get(eval_key):
return result[eval_key]
for key in [
f"target_{task_name}_stylist_desc0_base64_jpg",
f"target_{task_name}_desc0_base64_jpg",
f"vanilla_{task_name}_base64_jpg",
]:
if result.get(key):
return result[key]
return None
def _save_png(b64_jpeg: str, out_path: Path, dpi: int = 300) -> None:
"""Decode a base64 JPEG and save as a 300-DPI PNG."""
try:
from PIL import Image # type: ignore
except ImportError:
print(
"ERROR: Pillow is not installed. Run: pip install pillow",
file=sys.stderr,
)
sys.exit(1)
img_bytes = base64.b64decode(b64_jpeg)
img = Image.open(BytesIO(img_bytes))
# Preserve pixel dimensions; embed 300 DPI metadata so LaTeX sees it
out_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(out_path), format="PNG", dpi=(dpi, dpi))
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"--check-backend", action="store_true",
help="Print PaperBanana availability and exit (no figure generated)",
)
p.add_argument("--figure-id", help="Figure ID from the plotting plan (e.g. fig_overview)")
p.add_argument(
"--caption", required=False,
help="Figure caption / objective from the figure spec (used as visual intent)",
)
p.add_argument(
"--content-file", type=Path, required=False,
help="Path to idea.md or experimental_log.md — used as method context",
)
p.add_argument(
"--task", choices=["diagram", "plot"], default="diagram",
help="Figure task type (default: diagram)",
)
p.add_argument(
"--aspect-ratio", default="16:9",
help=f"Aspect ratio string (default: 16:9). "
f"Allowed: {', '.join(sorted(_VALID_ASPECT_RATIOS))}",
)
p.add_argument(
"--max-critic-rounds", type=int, default=3,
help="Number of Critic refinement rounds (default: 3, range: 0–5)",
)
p.add_argument(
"--out", type=Path, required=False,
help="Output PNG path (e.g. workspace/figures/fig_overview.png)",
)
args = p.parse_args()
if args.check_backend:
check_backend() # exits internally
# --- validate required args for actual rendering ---
missing = [f for f, v in [("--caption", args.caption), ("--out", args.out)] if v is None]
if missing:
p.error(f"The following arguments are required for rendering: {', '.join(missing)}")
if args.content_file is None or not args.content_file.exists():
p.error(
f"--content-file is required and must exist. "
f"Got: {args.content_file}"
)
aspect_ratio = args.aspect_ratio
if aspect_ratio not in _VALID_ASPECT_RATIOS:
print(
f"WARN: aspect ratio {aspect_ratio!r} is not in the standard set "
f"({', '.join(sorted(_VALID_ASPECT_RATIOS))}). Passing as-is to PaperBanana.",
file=sys.stderr,
)
# --- check backend availability ---
pb_path = _paperbanana_path()
if pb_path is None:
print(
"INFO: PAPERBANANA_PATH not set or invalid. "
"Falling back to matplotlib renderer.",
file=sys.stderr,
)
sys.exit(2) # caller uses exit code 2 to trigger fallback
content = args.content_file.read_text(encoding="utf-8")
figure_id = args.figure_id or args.out.stem
input_data = {
"filename": f"{figure_id}_candidate_0",
"candidate_id": 0,
"caption": args.caption,
"content": content,
"visual_intent": args.caption,
"additional_info": {"rounded_ratio": aspect_ratio},
"max_critic_rounds": max(0, min(5, args.max_critic_rounds)),
}
main_model = os.environ.get("PAPERBANANA_MAIN_MODEL", "")
image_gen_model = os.environ.get("PAPERBANANA_IMAGE_MODEL", "")
print(
f"PaperBanana: generating {args.task} figure '{figure_id}' "
f"({aspect_ratio}, {args.max_critic_rounds} critic rounds) …",
file=sys.stderr,
)
result = asyncio.run(
_run_pipeline(
pb_path=pb_path,
input_data=input_data,
task_name=args.task,
max_critic_rounds=args.max_critic_rounds,
main_model=main_model,
image_gen_model=image_gen_model,
)
)
if result is None:
print("ERROR: PaperBanana returned no result.", file=sys.stderr)
return 1
b64 = _extract_best_image_b64(result, args.task)
if not b64:
print(
f"ERROR: No image found in PaperBanana result. "
f"Available keys: {[k for k in result if 'base64' in k]}",
file=sys.stderr,
)
return 1
_save_png(b64, args.out)
print(f"Saved: {args.out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
render_diagram.py — Render a simple boxes-and-arrows conceptual diagram from
a JSON spec using matplotlib patches. 300 DPI PNG output.
For complex Fig-1-style overview diagrams (multi-row branches, grouped
sub-systems), the host agent should write matplotlib patches code directly
following references/diagram-patterns.md. This script handles the common
case: nodes positioned on a grid, edges between named nodes.
Spec format (JSON):
{
"aspect_ratio": "16:9",
"nodes": [
{"id": "input", "x": 0.5, "y": 3.0, "w": 1.5, "h": 0.7,
"label": "Idea (I)", "kind": "input"},
{"id": "outline","x": 3.0, "y": 3.0, "w": 1.6, "h": 0.9,
"label": "Outline\\nAgent", "kind": "agent"}
],
"edges": [
{"from": "input", "to": "outline"}
]
}
Coordinates are in arbitrary units; the script auto-scales to fill the
figure. `kind` ∈ {"input", "agent", "output", "control"} drives the color.
Usage:
python render_diagram.py --spec diagram.json --out figure.png
"""
import argparse
import json
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
ASPECT_TO_SIZE = {
"1:1": (3.4, 3.4), "1:4": (1.8, 7.2), "2:3": (3.4, 5.1),
"3:2": (5.1, 3.4), "3:4": (3.0, 4.0), "4:1": (7.0, 1.75),
"4:3": (4.0, 3.0), "4:5": (3.2, 4.0), "5:4": (4.5, 3.6),
"9:16": (2.8, 4.97), "16:9": (5.5, 3.09), "21:9": (7.0, 3.0),
}
KIND_COLORS = {
"input": "#cfe2f3",
"agent": "#9fc5e8",
"output": "#b6d7a8",
"control": "#ead1dc",
"default": "#e8e8f0",
}
BORDER = "#2060cc"
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--spec", required=True, help="path to JSON spec")
p.add_argument("--out", required=True, help="path to output PNG")
args = p.parse_args()
with open(args.spec) as f:
spec = json.load(f)
size = ASPECT_TO_SIZE.get(spec.get("aspect_ratio", "16:9"))
if size is None:
print(f"ERROR: unknown aspect_ratio {spec.get('aspect_ratio')}", file=sys.stderr)
return 1
nodes = {n["id"]: n for n in spec["nodes"]}
if not nodes:
print("ERROR: spec has no nodes", file=sys.stderr)
return 1
# auto-bounds
xs = [n["x"] for n in nodes.values()] + [n["x"] + n.get("w", 1) for n in nodes.values()]
ys = [n["y"] for n in nodes.values()] + [n["y"] + n.get("h", 1) for n in nodes.values()]
pad = 0.4
xmin, xmax = min(xs) - pad, max(xs) + pad
ymin, ymax = min(ys) - pad, max(ys) + pad
fig, ax = plt.subplots(figsize=size)
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
ax.set_aspect("auto")
ax.axis("off")
# nodes
for n in nodes.values():
color = KIND_COLORS.get(n.get("kind", "default"), KIND_COLORS["default"])
bb = FancyBboxPatch(
(n["x"], n["y"]), n.get("w", 1.5), n.get("h", 0.8),
boxstyle="round,pad=0.06,rounding_size=0.12",
ec=BORDER, fc=color, lw=0.8,
)
ax.add_patch(bb)
cx = n["x"] + n.get("w", 1.5) / 2
cy = n["y"] + n.get("h", 0.8) / 2
ax.text(cx, cy, n["label"], ha="center", va="center",
fontsize=8, fontweight="bold",
fontfamily="serif")
# edges
for e in spec.get("edges", []):
n1, n2 = nodes[e["from"]], nodes[e["to"]]
x1 = n1["x"] + n1.get("w", 1.5)
y1 = n1["y"] + n1.get("h", 0.8) / 2
x2 = n2["x"]
y2 = n2["y"] + n2.get("h", 0.8) / 2
a = FancyArrowPatch((x1, y1), (x2, y2),
arrowstyle="->", mutation_scale=10,
color=BORDER, lw=0.9)
ax.add_patch(a)
if spec.get("title"):
fig.suptitle(spec["title"], fontsize=9, fontweight="bold", y=0.98)
fig.tight_layout()
fig.savefig(args.out, dpi=300, bbox_inches="tight", pad_inches=0.05)
plt.close(fig)
print(f"OK: rendered diagram {args.out} ({len(nodes)} nodes, "
f"{len(spec.get('edges', []))} edges)")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
render_matplotlib.py — Render a JSON plot spec to a 300-DPI PNG using the
academic-paper matplotlib style.
This is a deterministic helper for the plotting agent. It handles the
mechanical parts (figsize from aspect ratio, style application, save) so the
host agent can focus on high-level visual decisions in the LLM.
Spec format (JSON):
{
"type": "line" | "bar" | "grouped_bar" | "stacked_bar" | "radar" | "scatter" | "heatmap",
"aspect_ratio": "16:9",
"title": "Optional title",
"xlabel": "...",
"ylabel": "...",
"series": [
{"name": "Method A", "x": [...], "y": [...]},
{"name": "Method B", "x": [...], "y": [...]}
],
"x_labels": [...], // for bar/grouped_bar/stacked_bar
"legend_loc": "upper right"
}
For chart types not covered here, write the matplotlib code yourself directly.
Usage:
python render_matplotlib.py --spec spec.json --out figure.png
"""
import argparse
import json
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
# ── style ──
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["Times New Roman", "DejaVu Serif"],
"font.size": 8,
"axes.titlesize": 9,
"axes.titleweight": "bold",
"axes.labelsize": 8,
"axes.linewidth": 0.6,
"legend.fontsize": 7,
"legend.framealpha": 0.95,
"legend.edgecolor": "#cccccc",
"xtick.labelsize": 7,
"ytick.labelsize": 7,
"figure.dpi": 300,
"savefig.dpi": 300,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.08,
"grid.alpha": 0.15,
"grid.linewidth": 0.5,
"lines.linewidth": 1.3,
})
PALETTE = ["#2060cc", "#cc3030", "#208040", "#cc7020", "#8040cc", "#b08020", "#666666"]
ASPECT_TO_SIZE = {
"1:1": (3.4, 3.4),
"1:4": (1.8, 7.2),
"2:3": (3.4, 5.1),
"3:2": (5.1, 3.4),
"3:4": (3.0, 4.0),
"4:1": (7.0, 1.75),
"4:3": (4.0, 3.0),
"4:5": (3.2, 4.0),
"5:4": (4.5, 3.6),
"9:16": (2.8, 4.97),
"16:9": (5.5, 3.09),
"21:9": (7.0, 3.0),
}
def make_axes(spec):
size = ASPECT_TO_SIZE.get(spec.get("aspect_ratio", "16:9"))
if size is None:
raise SystemExit(f"unknown aspect_ratio: {spec.get('aspect_ratio')}")
is_polar = spec.get("type") == "radar"
fig, ax = plt.subplots(figsize=size, subplot_kw=dict(polar=True) if is_polar else {})
return fig, ax
def render_line(ax, spec):
for i, s in enumerate(spec["series"]):
ax.plot(s["x"], s["y"], color=PALETTE[i % len(PALETTE)], label=s.get("name"))
if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
if any("name" in s for s in spec["series"]):
ax.legend(loc=spec.get("legend_loc", "best"))
ax.grid(True)
def render_bar(ax, spec):
s = spec["series"][0]
x = np.arange(len(s["y"]))
ax.bar(x, s["y"], color=PALETTE[0], edgecolor="white", linewidth=0.4)
if spec.get("x_labels"):
ax.set_xticks(x)
ax.set_xticklabels(spec["x_labels"])
if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
ax.grid(axis="y", alpha=0.2)
def render_grouped_bar(ax, spec):
n_groups = len(spec["series"][0]["y"])
n_series = len(spec["series"])
x = np.arange(n_groups)
width = 0.8 / n_series
for i, s in enumerate(spec["series"]):
offset = (i - (n_series - 1) / 2) * width
ax.bar(x + offset, s["y"], width, color=PALETTE[i % len(PALETTE)],
label=s.get("name"), edgecolor="white", linewidth=0.4)
if spec.get("x_labels"):
ax.set_xticks(x)
ax.set_xticklabels(spec["x_labels"])
if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
ax.legend(loc=spec.get("legend_loc", "best"))
ax.grid(axis="y", alpha=0.2)
def render_stacked_bar(ax, spec):
x = np.arange(len(spec["x_labels"]))
bottom = np.zeros(len(spec["x_labels"]))
for i, s in enumerate(spec["series"]):
y = np.array(s["y"])
ax.bar(x, y, bottom=bottom, color=PALETTE[i % len(PALETTE)],
label=s.get("name"), edgecolor="white", linewidth=0.4)
bottom += y
ax.set_xticks(x)
ax.set_xticklabels(spec["x_labels"])
if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
ax.legend(loc=spec.get("legend_loc", "best"))
def render_radar(ax, spec):
labels = spec["x_labels"]
angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
angles += angles[:1]
for i, s in enumerate(spec["series"]):
vals = list(s["y"]) + [s["y"][0]]
ax.plot(angles, vals, color=PALETTE[i % len(PALETTE)],
linewidth=1.5, label=s.get("name"))
ax.fill(angles, vals, color=PALETTE[i % len(PALETTE)], alpha=0.12)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(labels, fontsize=7)
ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.05))
def render_scatter(ax, spec):
for i, s in enumerate(spec["series"]):
ax.scatter(s["x"], s["y"], color=PALETTE[i % len(PALETTE)],
s=20, alpha=0.7, label=s.get("name"))
if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
if any("name" in s for s in spec["series"]):
ax.legend(loc=spec.get("legend_loc", "best"))
ax.grid(True)
def render_heatmap(ax, spec):
data = np.array(spec["matrix"])
im = ax.imshow(data, cmap="Blues", aspect="auto")
if spec.get("x_labels"):
ax.set_xticks(range(len(spec["x_labels"])))
ax.set_xticklabels(spec["x_labels"])
if spec.get("y_labels"):
ax.set_yticks(range(len(spec["y_labels"])))
ax.set_yticklabels(spec["y_labels"])
plt.colorbar(im, ax=ax, fraction=0.04, pad=0.02)
RENDERERS = {
"line": render_line,
"bar": render_bar,
"grouped_bar": render_grouped_bar,
"stacked_bar": render_stacked_bar,
"radar": render_radar,
"scatter": render_scatter,
"heatmap": render_heatmap,
}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--spec", required=True, help="path to JSON spec")
p.add_argument("--out", required=True, help="path to output PNG")
args = p.parse_args()
with open(args.spec) as f:
spec = json.load(f)
chart_type = spec.get("type")
renderer = RENDERERS.get(chart_type)
if renderer is None:
print(f"ERROR: unknown chart type {chart_type!r}. "
f"Allowed: {sorted(RENDERERS)}", file=sys.stderr)
return 1
fig, ax = make_axes(spec)
renderer(ax, spec)
if spec.get("title"):
ax.set_title(spec["title"])
if chart_type != "radar":
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.tight_layout()
fig.savefig(args.out)
plt.close(fig)
print(f"OK: rendered {args.out} ({spec.get('aspect_ratio')}, {chart_type})")
return 0
if __name__ == "__main__":
sys.exit(main())