First Commit: research-paper-presenter skill for hermes!

This commit is contained in:
2026-06-19 21:47:51 +01:00
commit 661ad32394
5 changed files with 770 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
# Research Paper → LibreOffice Impress Explainer
## Purpose
Turn a research-paper PDF into a **detailed, image-rich `.odp` presentation** that
explains the work clearly enough for a learner to understand everything — the
problem, the core idea, the method, the math intuition, the results, and why it
matters. Output is a **native LibreOffice Impress file (`.odp`)** — never `.pptx`,
never PowerPoint.
## When to use
The user gives you a paper (a `.pdf`, or an arXiv/URL they want explained) and
wants to *learn it* via slides. Phrases like "make slides from this paper",
"explain this paper", "turn this into a presentation", "deck I can study from".
## What "good" looks like
- **Teaches, doesn't summarize.** Every claim from the paper is unpacked into
plain language with intuition first, formalism second. Assume the learner is
smart but new to the subfield.
- **Image-rich.** Most content slides carry a visual. Two image sources:
1. **The paper's own figures**, extracted automatically (highest fidelity —
use these for the real architecture diagrams, result plots, tables).
2. **Generated explainer visuals** from `gpt-image-2` — schematics, analogies,
step-by-step diagrams, intuition pictures that the paper *doesn't* contain.
- **Detailed.** A typical paper becomes **1832 slides**, not 8. Break the method
into multiple slides. One idea per slide.
- **Coherent look.** Generated images share a style so the deck feels designed.
---
## Pipeline (run in order)
All scripts live in `scripts/`. Work inside a scratch dir, e.g. `workdir/`.
### 1 — Extract the paper
```bash
python3 scripts/extract_paper.py PAPER.pdf --out workdir
```
Produces `workdir/paper_text.md` (page-delimited text with detected section
headers), `workdir/figures/*.png` (the paper's real figures), `figures.json`
(manifest with page + dimensions), and `meta.json` (title, page/word counts).
### 2 — Read and understand
Read `paper_text.md` end to end. Build a mental model: What problem? What was
broken before? What's the key insight? How does the method work mechanically?
What do the experiments show? What are the limits? **Do not start slides until
you can explain the paper to a beginner without looking.**
Inspect the extracted figures (`workdir/figures/`). Decide which are worth putting
on slides directly (architecture diagrams, key result plots, tables).
### 3 — Plan the deck + write image prompts
Draft two files:
- `workdir/prompts.json` — visuals to generate (see schema below). Write a prompt
for each concept that benefits from a picture the paper lacks: the core
analogy, a simplified mechanism diagram, before/after comparisons, a "how data
flows" schematic, an intuition pump for the math. Aim for **roughly one
generated image per 12 content slides**, on top of reused paper figures.
- `workdir/deck.json` — the full deck spec (schema below). Reference generated
images as `<id>.png` and reused paper figures by their path
(`figures/fig03.png`).
### 4 — Generate images
```bash
export OPENAI_API_KEY=... # Codex/Hermes usually has this in env
python3 scripts/generate_images.py workdir/prompts.json --assets workdir/assets
```
Writes one PNG per prompt as `workdir/assets/<id>.png`. Also copy any reused
paper figures into the assets dir so everything resolves from one place:
```bash
cp workdir/figures/*.png workdir/assets/ # optional, keeps paths simple
```
> If the Hermes runtime has **native gpt-image-2** generation (Codex does), you
> may generate images directly and just save them as `workdir/assets/<id>.png`.
> The script is the portable fallback.
### 5 — Build the .odp
```bash
python3 scripts/build_odp.py workdir/deck.json workdir/output.odp --assets workdir/assets
```
That's the deliverable. It opens directly in LibreOffice Impress on Ubuntu.
(Optional sanity check / PDF preview:
`libreoffice --headless --convert-to pdf workdir/output.odp`.)
---
## Deck spec schema (`deck.json`)
```jsonc
{
"theme": "midnight", // midnight | paper | forest
"slides": [ /* slide objects, in order */ ]
}
```
Slide objects by `type`:
```jsonc
// Opening slide
{"type":"title","title":"...","subtitle":"...","eyebrow":"RESEARCH WALKTHROUGH",
"meta":"Authors, year • one line", "notes":"speaker notes (optional)"}
// Section divider between major parts
{"type":"section","number":"02","title":"The Method","subtitle":"optional"}
// Workhorse slide: bullets, with an optional image on the right
{"type":"content","kicker":"motivation","title":"...",
"bullets":["point","another point",{"text":"sub-point","level":1}],
"image":"diagram_attention.png", // omit for full-width text
"caption":"Fig 2 — ...", "notes":"..."}
// Full-bleed image with a caption — use for big architecture diagrams / plots
{"type":"bigimage","kicker":"architecture","title":"...",
"image":"figures/fig03.png","caption":"...","notes":"..."}
// Side-by-side comparison (before/after, baseline/proposed, RNN/Transformer)
{"type":"comparison","title":"...",
"left":{"heading":"Baseline","bullets":["...","..."]},
"right":{"heading":"Proposed","bullets":["...","..."]}}
// Pull-quote / key takeaway
{"type":"quote","text":"...","attribution":"paper abstract (paraphrased)"}
```
Notes:
- `bullets` items are strings, or `{"text": "...", "level": 1}` for one indent.
- `image` is resolved against `--assets`. A missing image renders a labelled
placeholder (the deck still builds), so a failed generation never blocks you.
- Put the deeper explanation a learner can read later into `notes` (speaker
notes) — keep on-slide bullets tight.
## Image prompts schema (`prompts.json`)
```jsonc
[
{"id":"attention_schematic",
"prompt":"Technical diagram: scaled dot-product attention. Show Q, K, V as "
"labelled boxes, a matrix multiply, a softmax, and a weighted sum. "
"Clear arrows and labels.",
"shape":"landscape"}, // landscape | portrait | square
{"id":"rnn_bottleneck","prompt":"...","shape":"portrait",
"transparent": false}
]
```
- `id` becomes the filename (`<id>.png`) → reference it in `deck.json`.
- gpt-image-2 renders **text inside images** well, so labelled diagrams,
flowcharts and infographics are fair game — lean into them.
- A shared style suffix is auto-appended for visual coherence; override per-run
with `--style-suffix`.
---
## Pedagogy checklist (the part that makes it a *learning* deck)
- Open with the **problem and stakes** before any method.
- For every mechanism: **intuition / analogy first**, then the precise version.
- Turn each equation into a sentence ("this just measures how similar two
vectors are, normalized so big dimensions don't blow up the scale").
- Use `comparison` slides for "old way vs new way".
- Reuse the paper's real result figures; explain *what to look at* in the caption.
- End with: what's genuinely new, what it enables, and stated limitations.
- Prefer more slides over crowded ones. One idea per slide.
## Theme choice
`midnight` (dark, technical — default), `paper` (warm light, academic),
`forest` (dark green). Pick one that fits the subject; keep it consistent.
@@ -0,0 +1,7 @@
# Python deps for the research-paper-presenter skill
odfpy>=1.4.1 # native .odp generation (LibreOffice Impress)
PyMuPDF>=1.24 # PDF text + figure extraction (imported as: fitz)
openai>=1.40 # gpt-image-2 image generation (API fallback path)
# System (Ubuntu): LibreOffice is only needed for the optional PDF preview:
# sudo apt-get install -y libreoffice-impress
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
build_odp.py — Render a deck spec (JSON) into a native LibreOffice Impress (.odp) file.
This produces a TRUE OpenDocument Presentation (not a .pptx). No PowerPoint, no
conversion step. Images are embedded directly into the .odp container.
Usage:
python3 build_odp.py deck.json out.odp [--assets ASSETS_DIR]
The deck spec schema is documented in SKILL.md. Image fields are filenames that are
resolved relative to --assets (default: directory of deck.json).
Slide types: title | section | content | bigimage | comparison | quote
"""
import argparse
import json
import os
import sys
from odf.opendocument import OpenDocumentPresentation
from odf.style import (
Style, MasterPage, PageLayout, PageLayoutProperties,
GraphicProperties, ParagraphProperties, TextProperties,
DrawingPageProperties,
)
from odf.text import P, Span
from odf.draw import Page, Frame, TextBox, Image, Rect
from odf.presentation import Notes
# ---- 16:9 geometry (centimetres) -------------------------------------------
PW, PH = 33.867, 19.05 # page width / height (PowerPoint-widescreen equiv.)
MARGIN = 1.7
# ---- palette ---------------------------------------------------------------
THEMES = {
"midnight": dict(bg="#0f172a", band="#1e293b", accent="#38bdf8",
accent2="#a78bfa", title="#f8fafc", body="#cbd5e1",
muted="#64748b", chip="#0ea5e9"),
"paper": dict(bg="#fbfaf7", band="#efe9dd", accent="#b4541f",
accent2="#1f6f6b", title="#1c1917", body="#3f3a35",
muted="#8a8178", chip="#b4541f"),
"forest": dict(bg="#0c1f1a", band="#13302a", accent="#34d399",
accent2="#fbbf24", title="#ecfdf5", body="#bbf7d0",
muted="#5e8b7e", chip="#10b981"),
}
def cm(v):
return f"{v:.3f}cm"
class Deck:
def __init__(self, doc, theme):
self.doc = doc
self.t = theme
self._n = 0
self._styles = {}
# -- style helpers -------------------------------------------------------
def _key(self, prefix):
self._n += 1
return f"{prefix}{self._n}"
def gstyle(self, fill=None, stroke="none"):
st = Style(name=self._key("gr"), family="graphic")
props = {}
if fill:
props.update(fill="solid", fillcolor=fill)
else:
props.update(fill="none")
props["stroke"] = stroke
st.addElement(GraphicProperties(**props))
self.doc.automaticstyles.addElement(st)
return st
def pstyle(self, color, size, bold=False, align="left", spacing=None,
font="Inter"):
st = Style(name=self._key("pp"), family="paragraph")
para = {"textalign": align}
if spacing:
para["marginbottom"] = cm(spacing)
st.addElement(ParagraphProperties(**para))
st.addElement(TextProperties(
color=color, fontsize=f"{size}pt",
fontweight="bold" if bold else "normal",
fontfamily=font,
))
self.doc.automaticstyles.addElement(st)
return st
def dpstyle(self, bg):
st = Style(name=self._key("dp"), family="drawing-page")
st.addElement(DrawingPageProperties(fill="solid", fillcolor=bg,
displayfooter="true",
displaypagenumber="false"))
self.doc.automaticstyles.addElement(st)
return st
# -- primitives ----------------------------------------------------------
def page(self, bg=None):
dp = self.dpstyle(bg or self.t["bg"])
pg = Page(stylename=dp, masterpagename=self.master)
self.doc.presentation.addElement(pg)
return pg
def rect(self, pg, x, y, w, h, fill):
f = Rect(stylename=self.gstyle(fill=fill),
x=cm(x), y=cm(y), width=cm(w), height=cm(h))
pg.addElement(f)
def text(self, pg, x, y, w, h, lines, color=None, size=None, bold=False,
align="left", line_spacing=0.0, font="Inter"):
"""lines: list of strings OR list of (text, size, color, bold) tuples."""
fr = Frame(stylename=self.gstyle(), x=cm(x), y=cm(y),
width=cm(w), height=cm(h))
tb = TextBox()
fr.addElement(tb)
for ln in lines:
if isinstance(ln, tuple):
txt, s, c, b = ln
ps = self.pstyle(c, s, b, align, line_spacing, font)
else:
txt, ps = ln, self.pstyle(color or "#ffffff", size or 14,
bold, align, line_spacing, font)
tb.addElement(P(stylename=ps, text=txt))
pg.addElement(fr)
return fr
def bullets(self, pg, x, y, w, h, items, color, size, accent,
line_spacing=0.55):
fr = Frame(stylename=self.gstyle(), x=cm(x), y=cm(y),
width=cm(w), height=cm(h))
tb = TextBox()
fr.addElement(tb)
for it in items:
sub = isinstance(it, dict)
txt = it["text"] if sub else it
indent = it.get("level", 0) if sub else 0
ps = Style(name=self._key("pp"), family="paragraph")
ps.addElement(ParagraphProperties(
marginbottom=cm(line_spacing),
marginleft=cm(0.7 + indent * 0.9),
textindent=cm(-0.7),
))
ps.addElement(TextProperties(color=color, fontsize=f"{size}pt",
fontfamily="Inter"))
self.doc.automaticstyles.addElement(ps)
bullet = "" if indent == 0 else " "
p = P(stylename=ps)
p.addElement(Span(text=bullet,
stylename=self.tspan(accent, size, bold=True)))
p.addElement(Span(text=txt))
tb.addElement(p)
pg.addElement(fr)
return fr
def tspan(self, color, size, bold=False):
st = Style(name=self._key("ts"), family="text")
st.addElement(TextProperties(color=color, fontsize=f"{size}pt",
fontweight="bold" if bold else "normal",
fontfamily="Inter"))
self.doc.automaticstyles.addElement(st)
return st
def picture(self, pg, path, x, y, w, h, fit=True):
if not os.path.exists(path):
# draw a placeholder so the deck still builds
self.rect(pg, x, y, w, h, self.t["band"])
self.text(pg, x, y + h / 2 - 0.6, w, 1.2,
[f"[missing image: {os.path.basename(path)}]"],
self.t["muted"], 12, align="center")
return
iw, ih = _image_dims(path)
if fit and iw and ih:
scale = min(w / iw, h / ih)
dw, dh = iw * scale, ih * scale
dx, dy = x + (w - dw) / 2, y + (h - dh) / 2
else:
dw, dh, dx, dy = w, h, x, y
href = self.doc.addPicture(path)
fr = Frame(x=cm(dx), y=cm(dy), width=cm(dw), height=cm(dh))
fr.addElement(Image(href=href, type="simple", show="embed",
actuate="onLoad"))
pg.addElement(fr)
def notes(self, pg, text):
if not text:
return
n = Notes()
fr = Frame(x=cm(2), y=cm(12), width=cm(18), height=cm(6))
tb = TextBox()
fr.addElement(tb)
for para in text.split("\n"):
tb.addElement(P(text=para))
n.addElement(fr)
pg.addElement(n)
# -- slide renderers -----------------------------------------------------
def slide_title(self, s):
pg = self.page()
self.rect(pg, 0, PH * 0.42, PW, 0.12, self.t["accent"])
self.rect(pg, 0, PH * 0.42 + 0.12, PW * 0.33, 0.12, self.t["accent2"])
self.text(pg, MARGIN, PH * 0.18, PW - 2 * MARGIN, 3,
[(s.get("eyebrow", "RESEARCH WALKTHROUGH"), 14,
self.t["accent"], True)])
self.text(pg, MARGIN, PH * 0.24, PW - 2 * MARGIN, 6,
[(s["title"], 40, self.t["title"], True)])
if s.get("subtitle"):
self.text(pg, MARGIN, PH * 0.55, PW - 2 * MARGIN, 4,
[(s["subtitle"], 19, self.t["body"], False)])
meta = s.get("meta")
if meta:
self.text(pg, MARGIN, PH - 2.4, PW - 2 * MARGIN, 1.5,
[(meta, 13, self.t["muted"], False)])
self.notes(pg, s.get("notes"))
def slide_section(self, s):
pg = self.page(bg=self.t["band"])
num = s.get("number", "")
self.text(pg, MARGIN, PH * 0.28, 6, 4,
[(str(num), 80, self.t["accent"], True)])
self.rect(pg, MARGIN, PH * 0.52, 4.5, 0.1, self.t["accent2"])
self.text(pg, MARGIN, PH * 0.55, PW - 2 * MARGIN, 4,
[(s["title"], 34, self.t["title"], True)])
if s.get("subtitle"):
self.text(pg, MARGIN, PH * 0.72, PW - 2 * MARGIN, 3,
[(s["subtitle"], 17, self.t["body"], False)])
self.notes(pg, s.get("notes"))
def _header(self, pg, title, kicker=None):
self.rect(pg, 0, 0, 0.45, PH, self.t["accent"])
if kicker:
self.text(pg, MARGIN, 1.0, PW - 2 * MARGIN, 1,
[(kicker.upper(), 12, self.t["accent"], True)])
self.text(pg, MARGIN, 1.5, PW - 2 * MARGIN, 2.2,
[(title, 26, self.t["title"], True)])
self.rect(pg, MARGIN, 3.5, 3.2, 0.08, self.t["accent2"])
def slide_content(self, s):
pg = self.page()
self._header(pg, s["title"], s.get("kicker"))
has_img = bool(s.get("image"))
bx, bw = MARGIN, (PW * 0.52 if has_img else PW - 2 * MARGIN)
self.bullets(pg, bx, 4.4, bw, PH - 5.5, s.get("bullets", []),
self.t["body"], 16, self.t["accent"])
if has_img:
ix = PW * 0.56
iw = PW - ix - MARGIN
self.picture(pg, s["_imgpath"], ix, 4.4, iw, PH - 5.8)
if s.get("caption"):
self.text(pg, ix, PH - 1.4, iw, 1,
[(s["caption"], 11, self.t["muted"], False)],
self.t["muted"], 11, align="center")
self.notes(pg, s.get("notes"))
def slide_bigimage(self, s):
pg = self.page()
self._header(pg, s["title"], s.get("kicker"))
self.picture(pg, s["_imgpath"], MARGIN, 4.3, PW - 2 * MARGIN, PH - 6.2)
if s.get("caption"):
self.text(pg, MARGIN, PH - 1.6, PW - 2 * MARGIN, 1,
[(s["caption"], 12, self.t["muted"], False)],
self.t["muted"], 12, align="center")
self.notes(pg, s.get("notes"))
def slide_comparison(self, s):
pg = self.page()
self._header(pg, s["title"], s.get("kicker"))
colw = (PW - 2 * MARGIN - 1.2) / 2
left = s.get("left", {})
right = s.get("right", {})
for i, col in enumerate((left, right)):
x = MARGIN + i * (colw + 1.2)
self.rect(pg, x, 4.3, colw, 1.0,
self.t["accent"] if i == 0 else self.t["accent2"])
self.text(pg, x + 0.3, 4.5, colw - 0.6, 0.9,
[(col.get("heading", ""), 15, self.t["bg"], True)])
self.bullets(pg, x, 5.7, colw, PH - 6.8,
col.get("bullets", []), self.t["body"], 15,
self.t["accent"] if i == 0 else self.t["accent2"])
self.notes(pg, s.get("notes"))
def slide_quote(self, s):
pg = self.page(bg=self.t["band"])
self.text(pg, MARGIN, PH * 0.2, 4, 4,
[("\u201C", 90, self.t["accent"], True)])
self.text(pg, MARGIN + 0.3, PH * 0.34, PW - 2 * MARGIN - 1, 6,
[(s["text"], 24, self.t["title"], False)])
if s.get("attribution"):
self.text(pg, MARGIN + 0.3, PH * 0.74, PW - 2 * MARGIN, 2,
[("" + s["attribution"], 15, self.t["accent"], True)])
self.notes(pg, s.get("notes"))
RENDER = {
"title": slide_title, "section": slide_section,
"content": slide_content, "bigimage": slide_bigimage,
"comparison": slide_comparison, "quote": slide_quote,
}
def build(self, spec):
# master page + layout
pl = PageLayout(name="DeckPL")
pl.addElement(PageLayoutProperties(
pagewidth=cm(PW), pageheight=cm(PH),
printorientation="landscape"))
self.doc.automaticstyles.addElement(pl)
self.master = MasterPage(name="DeckMaster", pagelayoutname=pl)
self.doc.masterstyles.addElement(self.master)
for s in spec["slides"]:
renderer = self.RENDER.get(s["type"])
if renderer is None:
print(f" ! unknown slide type: {s['type']}", file=sys.stderr)
continue
renderer(self, s)
def _image_dims(path):
try:
from struct import unpack
with open(path, "rb") as fh:
head = fh.read(26)
if head[:8] == b"\x89PNG\r\n\x1a\n":
w, h = unpack(">II", head[16:24])
return w, h
if head[:2] == b"\xff\xd8": # jpeg — walk markers
with open(path, "rb") as fh:
fh.read(2)
while True:
b = fh.read(1)
while b and b != b"\xff":
b = fh.read(1)
marker = fh.read(1)
if marker in (b"\xc0", b"\xc1", b"\xc2", b"\xc3"):
fh.read(3)
h, w = unpack(">HH", fh.read(4))
return w, h
size = unpack(">H", fh.read(2))[0]
fh.read(size - 2)
except Exception:
pass
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("spec")
ap.add_argument("out")
ap.add_argument("--assets", default=None)
args = ap.parse_args()
with open(args.spec) as fh:
spec = json.load(fh)
assets = args.assets or os.path.dirname(os.path.abspath(args.spec))
for s in spec["slides"]:
if s.get("image"):
s["_imgpath"] = os.path.join(assets, s["image"])
theme = THEMES.get(spec.get("theme", "midnight"), THEMES["midnight"])
doc = OpenDocumentPresentation()
Deck(doc, theme).build(spec)
doc.save(args.out)
n = len(spec["slides"])
print(f"✓ wrote {args.out} ({n} slides, theme={spec.get('theme','midnight')})")
if __name__ == "__main__":
main()
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
extract_paper.py Turn a research-paper PDF into structured material the agent
can reason over, and rip out the paper's own figures so they can be reused.
Usage:
python3 extract_paper.py paper.pdf --out workdir/
Outputs into --out:
paper_text.md full text, page-delimited, with detected section headers
figures/figNN.png every embedded raster image (the paper's real figures)
figures.json manifest: {file, page, width, height} for each figure
meta.json title guess, page count, word count
The agent reads paper_text.md to understand the work, decides which extracted
figures to drop straight into slides, and writes image prompts for the rest.
"""
import argparse
import json
import os
import re
import fitz # PyMuPDF
HEADING_RE = re.compile(
r"^\s*((\d+(\.\d+)*)\s+[A-Z][\w\- ]{2,60}"
r"|(abstract|introduction|related work|background|method(s|ology)?|"
r"approach|architecture|experiments?|results?|evaluation|discussion|"
r"conclusions?|limitations|references)\b.*)$",
re.IGNORECASE,
)
def extract_text(doc):
parts = []
for i, page in enumerate(doc):
parts.append(f"\n\n<!-- page {i + 1} -->\n")
for line in page.get_text("text").splitlines():
s = line.strip()
if not s:
continue
if HEADING_RE.match(s) and len(s) < 80:
parts.append(f"\n## {s}\n")
else:
parts.append(s)
return "\n".join(parts)
def extract_figures(doc, outdir):
figdir = os.path.join(outdir, "figures")
os.makedirs(figdir, exist_ok=True)
manifest, seen, n = [], set(), 0
for pno, page in enumerate(doc):
for img in page.get_images(full=True):
xref = img[0]
if xref in seen:
continue
seen.add(xref)
try:
pix = fitz.Pixmap(doc, xref)
if pix.n - pix.alpha >= 4: # CMYK → RGB
pix = fitz.Pixmap(fitz.csRGB, pix)
if pix.width < 120 or pix.height < 120:
continue # skip logos / icons
n += 1
fn = f"fig{n:02d}.png"
pix.save(os.path.join(figdir, fn))
manifest.append(dict(file=f"figures/{fn}", page=pno + 1,
width=pix.width, height=pix.height))
except Exception as e:
print(f" ! fig on page {pno+1}: {e}")
return manifest
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pdf")
ap.add_argument("--out", default="paper_work")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
doc = fitz.open(args.pdf)
text = extract_text(doc)
with open(os.path.join(args.out, "paper_text.md"), "w") as fh:
fh.write(text)
figs = extract_figures(doc, args.out)
with open(os.path.join(args.out, "figures.json"), "w") as fh:
json.dump(figs, fh, indent=2)
title = (doc.metadata.get("title") or "").strip()
if not title:
first = next((l.strip() for l in doc[0].get_text("text").splitlines()
if len(l.strip()) > 15), "Untitled")
title = first
meta = dict(title=title, pages=doc.page_count,
words=len(text.split()), figures=len(figs))
with open(os.path.join(args.out, "meta.json"), "w") as fh:
json.dump(meta, fh, indent=2)
print(f"{args.pdf}")
print(f" pages={meta['pages']} words={meta['words']} "
f"figures_extracted={len(figs)}")
print(f"{args.out}/paper_text.md, figures/, figures.json, meta.json")
if __name__ == "__main__":
main()
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
generate_images.py Generate slide visuals with OpenAI's gpt-image-2.
Reads a prompts file (JSON list) and writes one PNG per entry into --assets.
gpt-image-2 has reasoning + strong in-image text rendering, so it is well suited
to diagrams, labelled schematics and infographics not just decorative art.
Prompts file format (JSON):
[
{"id": "attention_schematic",
"prompt": "Clean technical diagram of scaled dot-product attention ...",
"shape": "landscape"},
{"id": "rnn_vs_transformer", "prompt": "...", "shape": "portrait"}
]
Usage:
export OPENAI_API_KEY=...
python3 generate_images.py prompts.json --assets workdir/assets
# options: --model gpt-image-2 --quality high --style-suffix "..."
Each PNG is saved as <id>.png reference that exact filename in the deck spec's
"image" field.
NOTE for Codex/Hermes: if your agent runtime already has native gpt-image-2 image
generation, you may instead generate images directly and just save them as
<id>.png into the assets dir this script is the portable API fallback.
"""
import argparse
import base64
import json
import os
import sys
import time
SIZES = { # gpt-image-2 supported sizes
"landscape": "1536x1024",
"portrait": "1024x1536",
"square": "1024x1024",
}
# Appended to every prompt so generated visuals share a coherent look that sits
# well on the deck background. Override with --style-suffix.
DEFAULT_STYLE = (
" Modern flat editorial illustration / technical diagram style. "
"Crisp vector-like shapes, generous whitespace, high contrast, "
"legible labels, restrained palette of deep navy, cyan and violet on a "
"near-black background. No watermark, no signature, no stock-photo look."
)
def make_client():
try:
from openai import OpenAI
except ImportError:
sys.exit("openai SDK not installed — run: pip install openai")
key = os.environ.get("OPENAI_API_KEY")
if not key:
sys.exit("OPENAI_API_KEY is not set.")
return OpenAI()
def generate(client, model, prompt, size, quality, transparent):
kwargs = dict(model=model, prompt=prompt, size=size, n=1)
# gpt-image models support quality + optional transparent background
if quality:
kwargs["quality"] = quality
if transparent:
kwargs["background"] = "transparent"
resp = client.images.generate(**kwargs)
return base64.b64decode(resp.data[0].b64_json)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("prompts")
ap.add_argument("--assets", default="assets")
ap.add_argument("--model", default="gpt-image-2")
ap.add_argument("--quality", default="high",
help="low | medium | high (gpt-image-2)")
ap.add_argument("--style-suffix", default=DEFAULT_STYLE)
ap.add_argument("--overwrite", action="store_true")
args = ap.parse_args()
os.makedirs(args.assets, exist_ok=True)
with open(args.prompts) as fh:
prompts = json.load(fh)
client = make_client()
ok = 0
for item in prompts:
iid = item["id"]
out = os.path.join(args.assets, f"{iid}.png")
if os.path.exists(out) and not args.overwrite:
print(f"· skip {iid} (exists)")
ok += 1
continue
size = SIZES.get(item.get("shape", "landscape"), SIZES["landscape"])
prompt = item["prompt"].strip() + args.style_suffix
for attempt in range(1, 4):
try:
data = generate(client, args.model, prompt, size,
args.quality, item.get("transparent", False))
with open(out, "wb") as fh:
fh.write(data)
print(f"{iid} ({size})")
ok += 1
break
except Exception as e:
wait = 3 * attempt
print(f" ! {iid} attempt {attempt} failed: {e} "
f"(retry in {wait}s)" if attempt < 3
else f"{iid} gave up: {e}")
if attempt < 3:
time.sleep(wait)
print(f"\n{ok}/{len(prompts)} images ready in {args.assets}/")
if __name__ == "__main__":
main()