paper2assets — paper PDF → reusable assets
One paper PDF in, a single <outdir>/ of poster-agnostic assets out, ready for any downstream renderer.
Output Contract (the shared layout every paper2* skill follows)
paper2assets defines the on-disk shape of every deliverable bundle in the pipeline. paper2poster, html2pptx, paper2blog, paper2video, and paper2reel all read from and write to a bundle laid out this way — a teammate adding or changing a downstream skill conforms to this contract.
Rules
- The bundle directory is named after the paper.
- The bundle's top level holds ONLY that skill's deliverable FILES — no loose intermediates, and as few folders as possible.
- Every dependency and intermediate (figures, logos, qr, audio, fonts, captions, slides, the spec / json / txt) lives under one
assets/container.
Layout
<paper-name>/
|-- <deliverable files> # see the per-skill table below
|-- manifest.json # package index (root-relative paths); the one allowed top-level non-deliverable
`-- assets/
|-- figures/ logos/ qr/ audio/ fonts/ # runtime deps the deliverables reference
`-- meta/ # build intermediates
|-- paper_spec.md sections.json narration.json
`-- captions.json figures.json metadata.json text.txt
Deliverables reference assets with root-relative src paths -- assets/figures/..., assets/logos/..., assets/qr/..., assets/audio/... -- so the bundle is self-contained and movable (no absolute paths leak in). The path / file fields in figures.json, fetch_logos.py, and make_qr.py output already carry the assets/ prefix; downstream drops them into src verbatim.
Per-skill deliverables (top-level FILES):
| Skill | Top-level deliverable files |
|---|---|
| paper2assets | manifest.json (+ the whole assets/ package) |
| paper2poster | poster.html, poster.pdf, poster.png, poster.pptx |
| paper2blog | blog_zh.docx, blog_en.docx |
| paper2video | video.mp4, video_no_subtitles.mp4 |
| paper2reel | reel.html |
manifest.json's "files" map records every meta / figure path (root-relative) plus a "layout": "v2-assets" marker, so a consumer can locate inputs and tell a new bundle from a legacy flat one without re-walking the tree.
paper.pdf (+ arXiv id / provided image links)
│
▼ FIGURES — priority: source_figures.py (arXiv source / provided links) → clean assets/figures/*.png + figures.json
│ └─ per-figure fallback: extract_pdf.py crops only unresolved/composite figures from the rendered PDF
▼ scripts/extract_pdf.py → assets/meta/{text.txt, captions.json} (+ figures.json only on the crop fallback; use --no-figures on the source path)
│
▼ Step 3 (model-driven) → assets/meta/metadata.json
│
▼ Step 4 (model-driven) → assets/meta/paper_spec.md (9 canonical sections)
│
▼ scripts/crop_figure.py → cleaned assets/figures/*.png (CROP FALLBACK ONLY — skipped when source_figures.py supplied the originals)
│
▼ scripts/fetch_logos.py → assets/logos/<slug>.{png,svg}
▼ scripts/fetch_conf_logo.py → assets/logos/_venue.png (conference mark; best-effort, skips on miss)
│
▼ scripts/make_qr.py → assets/qr/{paper,code}.png
│
▼ scripts/build_package.py → assets/meta/{sections.json, narration.json} + manifest.json
│
└──→ <outdir>/ — every downstream renderer reads from here
Downstream renderers (paper2poster, paper2blog, paper2audio, ...) consume this outdir; none should re-derive any of these files.
Output contract
After paper2assets finishes, <outdir>/ MUST contain:
| File | Source | Purpose |
|---|---|---|
assets/meta/text.txt |
Step 2 | Full PDF text via pdftotext. Page breaks preserved as \f. Authoritative source of numbers/claims for any downstream prose. |
assets/meta/captions.json |
Step 2 | [{page, label, text}, ...] per "Figure N: ..." caption detected in the PDF text. |
assets/meta/figures.json |
Step 2 + 7 | Legacy-compatible per-raster list. Physical fields (file, width, height, page, column) remain unchanged; Step 7 adds caption provenance/quality, figure_id, semantic eligibility/role/confidence, semantic_roles[], negative section_exclusions[], and section_relevance[]. Every confidence is 0..1 and every semantic decision carries evidence. width/height are updated whenever crop_figure.py runs. |
assets/figures/<page>_figure<n>.png |
Step 2 + 5 | Cropped figure rasters @ zoom=6 (~432 dpi). Cleaned by Step 5's deterministic pipeline. |
assets/figures/_debug/<page>_figure<n>.png.bak |
Step 5 | One-shot backup of the raw extract before Step 5's first crop. Preserved across re-runs (never clobbered). Lives under _debug/ so the top-level figures/ listing stays clean — downstream renderers should only ever read from figures/*.png. |
assets/figures/_debug/<page>_figure<n>.marked-<NN>.png |
Step 5d | Per-iteration overlay showing the bbox each mark call proposed (-01, -02, ...). Audit trail of the bbox-decision history. Never touched by downstream. |
assets/meta/metadata.json |
Step 3 | {title, authors[], author_index_map{}, institutes[], venue, paper_url, code_url?}. |
assets/meta/paper_spec.md |
Step 4 | 9-section structured summary (Problem / Motivation / Contribution / Method / Dataset/Benchmark / Key Result / Ablation Study / Headline Numbers / Takeaway), each section with Necessary + Additional + Audio script subfields. Plus a YAML preamble with title/authors/institutes/venue and audio scripts. |
assets/logos/<slug>.{png,svg} |
Step 6 | One approved logo per canonical institute: Wikimedia Commons first, then a MANDATORY WebSearch/WebFetch fallback (--add-logo) for any institute in "missing". Every candidate is decoded and visually checked before approval; photos, covers, blank/low-contrast marks, corrupt responses, and duplicate visual encodings are rejected. |
assets/logos/logos.json |
Step 6 | Authoritative institution-resource allowlist. logos[] contains only approved selections with canonical identity, source, visual fingerprint, and quality metrics. rejected[] records failed candidates and reasons; missing[] records unresolved institutes. Downstream renderers must not discover arbitrary files by scanning assets/logos/. |
assets/qr/{paper,code}.png |
Step 6 | QR codes for the paper's links. make_qr.py classifies paper_url/project_url/code_url by destination (Paper/Code/Project), de-duplicates by URL, and writes up to two slots (slot 0 → paper.png, slot 1 → code.png) plus a qr manifest (path + label) into metadata.json. A one-link paper yields ONE QR (no code.png); the caption follows the URL, not the filename. |
assets/meta/sections.json |
Step 7 | paper_spec.md parsed to per-section JSON (stable ids + necessary / additional / audio_script) with conservative semantic figure refs. Each ref carries a stable figure_id; figure_ids[] is the simple join key. Motivation is allowed to have no figure and rejects Method/Result/Ablation figures. Consumed by paper2blog / paper2video. |
assets/meta/narration.json |
Step 7 | Audio script only — no mp3. TTS clip list {provider, voice, sections:[{id, heading, text}]} from the **Audio script:** markers (+ the title clip). Downstream renderers synthesize their own audio from this; paper2assets does NOT run TTS. |
manifest.json |
Step 7 | Package inventory (file paths + counts + source-PDF sha256) plus a final package_generation commit marker and hashes for generated JSON artifacts. |
Workflow
Step 0 — Cache check (do this FIRST, before any other work)
Re-extracting a paper costs ~5-10 min of Claude tokens (figure-cleanup
visual review + spec synthesis) and risks clobbering edits the user may
have made to paper_spec.md, metadata.json, or cropped figures.
Before starting, check whether the assets already exist under the
default outdir convention (outdir = <input_pdf_dir>/<pdf_stem>/) or
the caller-supplied outdir:
required=("$outdir/assets/meta/paper_spec.md" "$outdir/assets/meta/text.txt" \
"$outdir/assets/meta/captions.json" "$outdir/assets/meta/figures.json" \
"$outdir/assets/meta/metadata.json")
all_present=1
for f in "${required[@]}"; do [[ -f "$f" ]] || all_present=0; done
An empty figures.json is valid for a paper with no usable figures. If all
five canonical inputs are present, do not trust per-record schema strings as a
cache gate. Reuse the expensive text/rasters/spec, but always refresh Step 7
with the exact build_package.py --skip-extract --paper-spec ... command. It
validates the complete semantic contract, atomically replaces each canonical
JSON artifact, and writes manifest.json last with generation hashes.
Only after that command succeeds, REPORT the cached state in 1-2 lines and
STOP without re-extracting:
[paper2assets] CACHED in <outdir> — assets from prior run, reusing.
title: "<from metadata.json>"
figures: N PNGs
paper_spec.md: <line-count> lines, K sections
If the Step 7 refresh rejects a missing, corrupt, partial, or mixed-generation canonical document, do not report the bundle as ready. Repair the named input and rerun Step 7. Never send it to a manual/raw-model figure-selection fallback.
Re-extract ONLY when:
- one of the required artifacts is missing → resume from the missing step (Step 2 extract, Step 3 metadata, Step 4 spec, Step 5 cleanup, Step 6 logos/QR)
- the user explicitly requests it ("re-extract", "regenerate the spec",
"force", "fresh", "from scratch"). In that case, delete or back up
the existing
<outdir>/first so the cache check doesn't fire.
Step 1 — Validate the PDF path and pick an outdir
Required argument: path to a .pdf file. If the file doesn't exist or isn't a PDF, abort with a clear message.
Default outdir convention: outdir = <input_pdf_dir>/<pdf_stem>/ — a folder under papers/ named after the input PDF's basename (no extension). Example: /work/job/ResearchStudio-Reel.pdf → /work/job/ResearchStudio-Reel/. The caller may override with an explicit outdir argument.
Create <outdir>/ if missing.
Step 2 — Extract text + figures + captions
python ~/.claude/skills/paper2assets/scripts/extract_pdf.py <pdf> --outdir <outdir>
Writes:
assets/meta/text.txt— full text viapdftotext(page breaks preserved as\f).assets/meta/captions.json—[{page, label, text}, ...]per detected "Figure N: ..." caption.assets/meta/figures.json—[{file, width, height, page, layout}, ...]per extracted figure raster.assets/figures/<page>_figure<n>.png— raster crop at zoom=6 (~432 dpi). The extractor uses a column-aware boundary heuristic + 50 px symmetric padding around the detected figure region so subsequent Step 5 cleanup has room to work.
Figures — choose the source by priority (DO THIS; it is the biggest time/token saver). The paper's ORIGINAL figure graphics are already clean (no baked caption strips, no column-text bleed) and skip the whole Step 5 crop loop (~6 min + heavy tokens). scripts/source_figures.py fetches them and writes assets/figures/*.png + figures.json in seconds:
- Provided image links (FIRST): the user attached/linked figure images →
python ~/.claude/skills/paper2assets/scripts/source_figures.py --images <url|path> … --outdir <outdir> - arXiv (RECOMMENDED): the paper is on arXiv →
python ~/.claude/skills/paper2assets/scripts/source_figures.py --arxiv <id|url> --outdir <outdir>(downloadsarxiv.org/e-print/<id>, parses the.texfigure order + captions, rasterizes each graphic). - Backup (ONLY if 1 & 2 don't apply, or
source_figures.pyexits non-zero): the PDF crop path below — fullextract_pdf.py(with figures) + Step 5crop_figure.py.
If source_figures.py succeeded (exit 0): run extract_pdf.py <pdf> --outdir <outdir> --no-figures. It always writes text/captions; when source_figures.py left any source: "pdf-crop-pending" records, it also aligns their TeX captions to explicit printed Figure N labels and fills only those records from the rendered PDF. Then run Step 5 only for records whose final source is "pdf-crop"; skip original source graphics. If there are no such records, skip Step 5 entirely. Otherwise run the full extract_pdf.py <pdf> --outdir <outdir> (with figures) and do Step 5 for every extracted figure.
source_figures.py never selects only the first child of a multi-file TeX
figure. It writes that one figure as pdf-crop-pending while preserving clean
arXiv source assets for the other figures. extract_pdf.py --no-figures
replaces the pending record only after a unique TeX-caption → printed
Figure N alignment and an exact label join. Ambiguous or missing mappings
fail closed instead of falling back to positional order.
Appendix figures — skipped by DEFAULT. Process main-body figures only. From text.txt, find where the appendix / supplementary material begins (the first Appendix / Supplementary heading, or A. / B. / S1… content after References) and note its \f-delimited page. Drop every figure on or after that page — delete the PNGs from figures/ and their rows from figures.json before Step 5, so neither the cleaning loop nor any downstream renderer sees them. Override only when the user explicitly asks — e.g. "include the appendix figures" or naming a specific supplementary figure.
Step 3 — Parse paper metadata
Read text.txt's first page + (if the PDF is from arxiv) the arxiv abs page via WebFetch. Synthesize <outdir>/assets/meta/metadata.json:
{
"title": "...",
"authors": ["First Last", "Second Author"],
"author_index_map": {"First Last": [1, 2], "Second Author": [2]},
"institutes": ["First Institute", "Second Institute"],
"venue": "NeurIPS 2025",
"paper_url": "https://arxiv.org/abs/...",
"code_url": "https://github.com/..."
}
authors— display order from the PDF byline.author_index_map— author → list of 1-indexed institute indices (matches the superscripts in the PDF byline).institutes— semicolon-separated list, deduplicated, in the same order as the numeric indices used in the Authors line (so index1= first institute, etc.).venue— the real publication venue: conference / journal short name + year ("NeurIPS 2025", "ICLR 2026", "TPAMI 2026"). NEVER write "arXiv" (or "Preprint") as the venue — arXiv is a preprint host, not a publication venue. A paper on arXiv is almost always also published at a conference/journal; find that real venue: check the arXiv abs page's Comments and Journal ref fields (e.g. "Accepted at NeurIPS 2022"), the paper's first-page banner ("Published as a conference paper at ICLR 2024"), or an OpenReview / proceedings listing. For a workshop paper, use the parent conference (a NeurIPS 2022 workshop poster →"NeurIPS 2022"). Only when no real venue can be found anywhere, leave it an empty string (the header then shows no venue badge) — but still never "arXiv".paper_url— arxiv abs link (preferred) or publisher landing page.code_url— optional; omit field if no code is released.
Step 4 — Synthesize the 9-section paper_spec.md
Write <outdir>/assets/meta/paper_spec.md with the canonical 9 sections, each carrying three subfields (Necessary / Additional / Audio script) plus a YAML preamble:
---
title: <paper title>
authors: <First Last¹, Second Author¹², ...>
institutes: ¹First Institute; ²Second Institute
venue: <venue or empty>
paper_url: <url>
code_url: <url or empty>
title_audio_script: <one-paragraph spoken intro>
---
## Problem
**Necessary:** <≤40 words, the gap this paper addresses>
**Additional:** <≤40 words, supporting context>
**Audio script:** <one paragraph>
## Motivation
...
## Contribution
...
## Method
...
**Key equation:** `$<core formula(s) as clean LaTeX>$` <!-- 1–3 max: the objective / loss / governing equation that defines the method; transcribe symbols faithfully from text.txt, never fabricate; omit this subfield only if the paper genuinely has no formula -->
## Dataset / Benchmark
...
## Key Result
...
## Ablation Study
...
## Headline Numbers
...
## Takeaway
...
Section-by-section guidance:
- Problem — 1–2 sentences naming the gap.
Additionalfor context. - Motivation — 1–2 sentences for why now.
Additionalfor the failure pattern of prior work. - Contribution — 1–3 sentences listing what this paper actually contributes. May be omitted by downstream renderers; still write it here.
- Method — 2–4 sentences describing the proposed approach, in the paper's own vocabulary. Add a
**Key equation:**subfield transcribing the paper's 1–3 core formulas (objective, loss, or governing equation) as clean inline LaTeX ($…$) — this is the single biggest gap downstream posters have versus author ground-truth. Copy symbols faithfully fromtext.txt; never invent. Omit the subfield only for genuinely formula-free papers (pure systems / empirical). - Dataset / Benchmark — describes the data the paper introduces OR uses standard benchmarks (renderers decide whether to render this section).
- Key Result — the headline experimental finding in 1–3 sentences with the actual numbers.
- Ablation Study — 1–3 sentences naming the most informative ablation rows. Omit if the paper has no ablations.
- Headline Numbers — 1–4 metrics that summarize impact. Quantitative, traceable to text.txt — NEVER invented.
- Takeaway — 1–2 sentences a passerby could repeat after one read.
Audio script subfields are full-sentence spoken paragraphs (3–6 sentences each) suitable for TTS. They are part of the spec because audio narration of any downstream rendering should be derivable from this single source.
Step 5 — Clean every PDF-crop figure (original source figures are skipped)
Build the worklist from
figures.json: process every record with"source": "pdf-crop"and no record with"source": "original". In a hybrid bundle this may be only one composite figure; in a full crop fallback it is every figure. Skip the entire step only when the worklist is empty.
Step 2 produces raw figure rasters that often carry:
- A 1–10 px chrome residue at the top edge (the bottom of a page rule line / banner / running title that the extractor's column-aware boundary couldn't perfectly avoid).
- A baked-in caption strip at the bottom (rare —
extract_pdf.pyalready clamps tocap_full.y0 - 1, but a few papers have caption text fused into the figure raster). - A uniform white margin of arbitrary thickness around the cleaned content (a side effect of the 50 px symmetric pad in Step 2 + caption-clamped tight tops).
Downstream renderers should receive cleaned PDF crops, so run the deterministic cleanup pipeline on every source: "pdf-crop" record — independent of which figures a downstream renderer picks. Cost is sub-second per crop × 3 commands × N figures. Original source graphics carry none of the chrome / caption-strip / margin defects below and are skipped individually.
For each PDF-crop record's figures/<file>.png, run 5a → 5b → 5c in this exact order:
5a. top-check — strip top chrome residue.
python ~/.claude/skills/paper2assets/scripts/crop_figure.py top-check <outdir>/assets/figures/<file>.png
Pattern-matches the chrome signature (1–15 px non-clean prefix + ≥3 px clean gutter + sustained figure content below). On TOP-CHROME DETECTED — ... cut at y=Z, re-run with --apply to strip. On TOP clean — ..., skip to 5b.
5b. decaption — strip baked-in bottom caption strip (when present).
python ~/.claude/skills/paper2assets/scripts/crop_figure.py decaption <outdir>/assets/figures/<file>.png
Fires only when there's a 1–3 line text band at the bottom separated from the figure body by a clear horizontal whitespace gap. Most figures won't trigger. On DETECTED bottom caption band, re-run with --apply. Otherwise skip to 5c.
5c. autotrim — strip remaining uniform white margins (always last).
python ~/.claude/skills/paper2assets/scripts/crop_figure.py autotrim <outdir>/assets/figures/<file>.png
Strips border rows/cols that are 100% near-white, keeping a --pad 4 margin. Safe — never touches content pixels. Must come AFTER 5a/5b — autotrim stops at the first dark row, so any uncut chrome/caption traps a thick whitespace band that autotrim cannot reach.
All three modes write figures/_debug/<file>.png.bak (one-shot, never clobbered on re-runs) and update figures.json width/height. The top-level figures/ directory keeps only the in-progress clean PNG — backups and other debug artifacts live in the hidden _debug/ subdir so downstream consumers see a clean listing.
5d. Visual AI cropping review (mandatory for every PDF-crop figure). The deterministic chain in 5a-5c handles uniform white margins, the chrome-residue pattern at the top edge, and the baked-in caption-strip pattern at the bottom edge. It does NOT handle:
- Surrounding column body text leaked into the bbox — when
extract_pdf.py's figure-region detection over-reaches into the paper's prose (a vertical strip of column text running alongside the figure, or a few lines of body paragraph above the figure). This is the most common defect, and it's invisible to autotrim/decaption: that text isn't a uniform white margin and isn't a thin caption sliver — it's real ink that paints similarly to figure content. - Caption text not caught by
decaption's thin-strip pattern — captions that are tightly butted against the figure body, or captions that span 4+ lines (decaption refuses on caption blocks taller than ~15% of figure height to avoid amputating real figure content). - Adjacent-figure bleed on multi-figure pages — a vertical strip of the neighboring panel.
These defects need visual judgment to identify and cut, but eyeballing alone is too coarse — visual estimates routinely miss small panel titles ("mAP", "AP_50"), under-include axis labels by ~50 px, and over-trust gaps that turn out to be content boundaries. So 5d's workflow grounds the visual judgment in two deterministic tools that turn "where exactly is this figure?" into a falsifiable, pixel-level question.
paper2assets owns this responsibility because:
- Downstream renderers (paper2poster, paper2blog, paper2audio, paper2video) all want figures with only the figure's own visual content — surrounding paper text and captions are noise for every renderer.
- Doing it once here is cheaper than each downstream re-doing it on its picks.
- The MAIN "Figure N: …" caption is NEVER part of the figure raster. Its text is already in
<outdir>/assets/meta/captions.jsonas structured data; baking those pixels into the PNG duplicates content and visually collides when downstream HTML/blog renderers add their own<figcaption>. Panel sub-captions like "(a) Pipeline overview" / "(b) Loss curves" are different — they label individual sub-panels, are part of the figure's visual content, and they STAY INSIDE the raster. Rule of thumb: if cutting it would leave the panels unlabeled, KEEP IT; if cutting it just removes a "Figure N: …" prose sentence already incaptions.json, CUT IT.
Workflow per figure (after 5a-5c have run): the four steps below — analyze → judge → mark → verify → crop — exist as one loop so the bbox decision is committed only after a deterministic-grounded check AND a visual recheck both pass. Skipping the mark step (going straight from judge → crop) is the loop's most common failure mode: visual estimates from a downsampled Read are coarse, and a mis-estimated bbox commits a destructive crop in one shot. Mark first; commit only when the red box visibly encloses what you want.
Process figures STRICTLY sequentially, ONE figure at a time end-to-end (hard rule). Do not batch — do not run round-1 on all figures and then round-2 on all figures, do not invoke multiple sub-agent verifiers in parallel, do not Read figure A while reasoning about figure B's bbox. For each figure: complete every step (5d.i analyze → 5d.ii judge → 5d.iii mark → 5d.iv self-check + sub-agent verify ≥2 cycles → 5d.v commit), THEN move to the next figure. Cross-figure batching introduces two failure modes — sub-agent prompts can pick up the wrong figure's marked PNG (cross-context bleed), and your own per-edge reasoning can hallucinate elements from a sibling figure into the figure under judgment (cross-figure attention contamination). The cost of strict serial processing is real (no parallelism), but the verifier ambiguity it eliminates is worth it for correctness on every figure.
Step 5d.i — Analyze (deterministic, grounds the bbox decision in pixels):
python ~/.claude/skills/paper2assets/scripts/crop_figure.py blocks <outdir>/assets/figures/<file>.png
Prints the figure's ROW + COL ink-block structure (every dense band, every gap between bands, in pixel coordinates) plus heuristic hints flagging narrow blocks adjacent to the main body as "INCLUDE in bbox" (axis labels / legend / rotated y-title) versus blocks separated by a wide (>50 px) gap as "EXCLUDE unless visual recheck confirms it's figure content" (likely body-text column or adjacent figure). This output tells you exactly where each structural part of the figure sits in pixel space — no eyeballing dimensions.
Step 5d.ii — Judge a tight bbox (X0, Y0, X1, Y1) (PIL convention: top-left origin, x1/y1 exclusive). Apply these rules of inclusion ↔ exclusion:
Pattern in blocks output |
Decision |
|---|---|
| Narrow LEFT/RIGHT col block with ≤30 px gap to main body | KEEP — almost always axis labels, a rotated y-axis title ("Accuracy (%)"), or a legend |
| LEFT/RIGHT col block with >50 px gap to main body | CUT — paper text column (left page-margin) or an adjacent figure bleed |
| TOP row block separated from the main figure body by a clean gutter, ≤6 px thick | CUT — likely chrome residue (5a should have caught it; this is a backup) |
| TOP row block separated by a clean gutter, larger | INVESTIGATE — could be a banner ("Published as a conference paper at ICLR 2025") OR could be the figure's actual top legend / panel-title row |
| TOP small-text region with no clear gutter into the body | INVESTIGATE — could be body-text fragment from above the figure OR could be small panel titles ("mAP", "AP_50") that didn't hit the dense threshold |
| BOTTOM row block matching "Figure N: ..." pattern (1–3 short rows after a clear gutter) | CUT — main figure caption, already in captions.json |
| BOTTOM small row blocks matching "(a) " / "(b) " / "(c) " pattern positioned right below each subplot | KEEP — sub-captions label the panels and belong with the figure |
| Wide ROW gap (>200 px) at the top with no ink above it | The figure body starts where the first ink begins — pad y0 up by 20–30 px to catch small panel titles that fall below the 5% dense threshold |
| Any visual element straddling an edge (a column, row, panel, legend item, label cluster, sub-caption, bar in a chart) | NEVER a fractional capture. The element must be ENTIRELY inside or ENTIRELY outside the bbox. A half-included element is worse than full exclusion — it shows a confusing partial thing instead of a clean omission. If the rightmost column is "mostly" in but the right edge cuts the last 10% of it, extend the right edge to capture all of it OR pull the right edge in further to exclude it cleanly. |
The three single most-common mistakes are (a) cutting the axis-label / rotated-title col block on the LEFT because it looks like a separate structure, (b) setting y0 exactly at the first dense row, which lands inside the panel-title text instead of above it (fix: pad y0 up 20–30 px), and (c) capturing a fractional element at the right or bottom edge — usually a multi-column diagram's last column shows only its header but the right edge cuts the body, or a 4-row stat grid shows only the first 3 rows with the 4th half-visible.
Step 5d.iii — Mark + Preview (verification gate, never destructive):
python ~/.claude/skills/paper2assets/scripts/crop_figure.py mark <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
python ~/.claude/skills/paper2assets/scripts/crop_figure.py preview <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
Run BOTH commands per round — they produce a paired set:
figures/_debug/<stem>.marked-<NN>.png— the ORIGINAL raster with a red rectangle overlay showing the proposed bbox. Used by your own per-edge sanity check (Step 5d.iv) because the red line shows where the cut WOULD happen.figures/_debug/<stem>.preview-<NN>.png— the cropped image (i.e. what the final figure WOULD look like if this bbox is committed). Used by the sub-agent verifier (Step 5d.v) because there's no geometry to interpret — the preview IS the proposed result.
Both files rotate suffix (-01, -02, ...) in lockstep so round-N's mark and preview pair together. The original PNG is untouched by either command.
Step 5d.iv — Re-Read the marked image and verify per-edge. Look at the latest figures/_debug/<stem>.marked-<NN>.png and answer four explicit yes/no questions — one per edge. The red box is outside the bbox, so what's INSIDE the box (the content you'll keep) is unobstructed:
- TOP edge: is everything above the red line truly noise (banner, body-text, top chrome)? Are any panel titles ("mAP", legend) sitting just below the line that you mean to keep?
- BOTTOM edge: is everything below the line a "Figure N: …" caption or paper text? Are any sub-captions "(a) …" / "(b) …" / "(c) …" sitting below the line that should be inside the box?
- LEFT edge: is everything left of the line a body-text column from the paper? Are any axis labels / rotated y-axis title / legend column sitting outside the line that should be inside?
- RIGHT edge: same — body-text vs in-figure (legend on the right of a chart, color bar, etc.).
If any answer flips (something you meant to keep is outside the box, or something you meant to cut is inside), adjust the bbox and re-mark.
Conditional iteration: round 2+ only when round 1 verifier wasn't a clean PASS. Round 1 is always mandatory (mark + preview + sub-agent verify). For round 2+:
- If round 1's verifier returned a clean PASS (all of R1–R7 OK, each citing STEP A justification, no ambiguity), commit and stop — running a forced second round when the first attempt is already correct invites the agent to "fix" something not broken, which often introduces a regression (e.g. shifting the box to include a caption it had cleanly excluded).
- If round 1 returned FAIL OR had any rule answered ambiguously (e.g. "OK probably", missing STEP A citation, suggesting a correction even on a stated PASS), adjust the bbox per the verifier's suggestion (single-edge extend / single-edge contract / R7 translation shift), produce a fresh mark + preview pair (
-02), and re-invoke the sub-agent verifier on the new preview. Iterate until clean PASS.
Why conditional rather than always-≥2: forced second rounds on already-correct first attempts are net-negative (regression risk > confirmation value). Conditional iteration keeps quality on the table only when needed.
Cap at 5 cycles per figure for genuinely hard cases. Mark + preview are cheap (pure file I/O, no API cost); the sub-agent call is the actual cost driver.
MANDATORY independent verification by a sub-agent (hard rule, not a suggestion).
Your own per-edge check above is biased: you just decided the bbox seconds ago, so your verification is anchored on that decision. You can't reliably catch over-crops (cutting into figure content) or under-crops (leaving noise) by self-review alone — the verifier and the decider need to be different.
So after marked-01 and your own per-edge check, invoke an independent verification sub-agent via the Task tool to recheck the bbox. The sub-agent runs in a fresh context — no anchoring on your bbox decision, no prior beliefs about which edges "should" be where. It is the only check that can catch what your own re-Read systematically misses.
Task(
subagent_type="general-purpose",
description="Verify figure crop bbox",
prompt="""You are an independent figure-crop verifier. The caller has produced
a proposed crop of a paper figure raster. Your job is to judge whether the proposed
crop is correct — by direct visual comparison of two images.
IMAGE 1 (the ORIGINAL, uncropped extract):
<outdir>/assets/figures/<stem>.png
IMAGE 2 (the PREVIEW — what the figure WOULD look like if this bbox is committed;
this is just IMAGE 1 cropped to the proposed bbox, no annotations):
<outdir>/assets/figures/_debug/<stem>.preview-<NN>.png
Read BOTH images. The judgement is a direct comparison: 'is IMAGE 2 a clean,
complete version of the figure in IMAGE 1?' There is no red rectangle to
interpret, no inside/outside geometry to puzzle over. You see what the
caller proposes to keep (IMAGE 2) alongside the original (IMAGE 1), and you
judge the difference.
STEP 0 — Grounding (mandatory; never skip). Before any rule check, plainly state:
- IMAGE 1 dimensions: W₁ × H₁ px. What it contains overall (one sentence).
- IMAGE 2 dimensions: W₂ × H₂ px. What it contains overall (one sentence).
- The visible difference: what content does IMAGE 1 contain that IMAGE 2
has cut away? (e.g. 'IMAGE 1's top ~110 px (a page banner) is absent
from IMAGE 2', 'IMAGE 1's right ~430 px (an adjacent Figure 2 column)
is absent from IMAGE 2', 'IMAGE 1's bottom ~50 px ("Figure 1:" caption)
is absent from IMAGE 2'). Be quantitative — name approximate pixel
widths of each cut.
This grounding step anchors you on what the proposed crop actually changes
before any rule-based reasoning.
The figure SHOULD include (these elements MUST be present in IMAGE 2):
- all panels / sub-panels / chart bodies that visually compose the figure
- axis labels, legends, colorbars, in-figure annotations
- sub-captions like '(a) ...', '(b) ...' that sit BELOW each subplot and label it
- decorative borders / frames that are part of the figure's artwork
The figure should NOT include (these MUST be cut away — present in IMAGE 1,
absent from IMAGE 2):
- body-text paragraphs from the surrounding paper column
- the main 'Figure N: ...' caption sitting below the figure body
- page banner / running title / arxiv stamp at the top
- vertical strips of an ADJACENT figure (when two figures share a page row)
CRITICAL — no fractional captures: every visual element (column, row,
panel, legend item, label cluster, sub-caption, bar in a chart, axis tick
set, stat tile) must be either ENTIRELY in IMAGE 2 (kept whole) or
ENTIRELY absent from IMAGE 2 (cut whole). A half-included element at any
edge of IMAGE 2 (e.g. 'IMAGE 2's right edge shows the orange panel
background ending mid-element', 'the rightmost "G" in "118 GB" is
half-cut at IMAGE 2's right edge', 'only 3 of 4 stat tiles are fully in
IMAGE 2 with the 4th half-visible') is the most common failure mode —
flag it as FAIL even if everything else looks right.
STEP A — Per-edge forced description (mandatory; no verdict allowed
before completing this). For EACH of the four edges of IMAGE 2, describe
in concrete words:
(i) what content sits in the ~50 px strip just inside that edge of
IMAGE 2 (does it end cleanly, or is it cut?), AND
(ii) what content sits in the corresponding ~50 px strip of IMAGE 1
that IMAGE 2 has dropped (is it noise that should be dropped,
or figure content that should have been kept?).
TOP edge:
[IMAGE 2 top ~50 px]: ...
[IMAGE 1 strip dropped just above IMAGE 2's top]: ...
BOTTOM edge:
[IMAGE 2 bottom ~50 px]: ...
[IMAGE 1 strip dropped just below IMAGE 2's bottom]: ...
LEFT edge:
[IMAGE 2 left ~50 px]: ...
[IMAGE 1 strip dropped just left of IMAGE 2's left]: ...
RIGHT edge:
[IMAGE 2 right ~50 px]: ...
[IMAGE 1 strip dropped just right of IMAGE 2's right]: ...
Be physical and specific — name actual elements, give approximate pixel
distances, commit to whether each element ENDS within IMAGE 2 or CROSSES
the edge.
STEP B — Per-rule check (mandatory; one line per rule). For each rule
below, answer VIOLATED or OK, and if VIOLATED quote the exact line from
STEP A that shows the violation. Do NOT skip rules — answer all in order,
even when obvious:
R1 (no body-text paragraphs inside IMAGE 2): ___
R2 (no main 'Figure N: ...' caption inside IMAGE 2): ___
R3 (no page banner / running title / arxiv stamp inside IMAGE 2): ___
R4 (no vertical strip of an adjacent figure inside IMAGE 2): ___
R5 (no fractional capture at any edge of IMAGE 2 — every edge-adjacent
element ENTIRELY in or ENTIRELY out): ___
R6 (all figure-content elements present in IMAGE 2 — panels, axes,
legends, sub-captions, colorbars; nothing critical was dropped
when going from IMAGE 1 → IMAGE 2): ___
R7 (no TRANSLATION error — the bbox is positioned correctly, not
shifted off-center. Symptom: IMAGE 2 has significant white margin
on ONE side AND content clipped at the OPPOSITE side. If left
margin is wide and right edge cuts content, the box has the right
width but is shifted too far LEFT — same with top-vs-bottom): ___
The per-rule answers MUST cite the STEP A descriptions. 'R5 OK because
the figure looks complete' is invalid — only 'R5 OK because STEP A right
line says the orange panel background ends ~10 px before IMAGE 2's right
edge with clean white margin' is a valid answer. 'R6 OK because IMAGE 2
looks like a complete figure' is invalid — only 'R6 OK because IMAGE 1's
right-strip drop contains only adjacent-figure content per STEP A right
line, so nothing critical was dropped' is valid. 'R7 OK because the box
is centered' is invalid — only 'R7 OK because STEP A shows the left
~50 px contains the leftmost panel's edge with ≤10 px margin, and the
right ~50 px contains the rightmost panel's edge with ≤10 px margin,
so the box is positioned correctly, not shifted' is valid.
STEP C — Verdict. Based on STEP B:
1. PASS (every rule OK) or FAIL (at least one VIOLATED).
2. If FAIL, name the rule(s) violated and quote the relevant STEP A line(s).
3. If FAIL, suggest a corrected bbox as concrete pixel deltas relative to
the current one. Pick the right shape of correction:
- **Single-edge extend** when an element is missing only on one side:
'extend x1 by ~30 px to recover the rest of the orange panel
background per R5'.
- **Single-edge contract** when noise is leaking in on one side:
'reduce y1 by ~50 px to drop the "Figure 1:" caption text per R2'.
- **Translation shift** when R7 fired — when one side has slack
whitespace and the opposite side cuts content. Apply equal-magnitude
opposite-direction edits to BOTH edges of that axis, NOT a single-edge
edit. Example: 'shift box right by ~80 px: x0 += 80 AND x1 += 80
(per R7 — left has ~80 px white margin, right cuts the orange panel
mid-element)'. Same
…(truncated)