Skip to content

Commit 41b8bfe

Browse files
authored
Merge pull request #247 from llmsresearch/feat/polish-mode
feat: polish mode — refine an existing figure with style-guided suggestions
2 parents 5b36f66 + 0a2d542 commit 41b8bfe

9 files changed

Lines changed: 1043 additions & 1 deletion

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,28 @@ Scores on 4 dimensions (hierarchical aggregation per the paper):
515515
- **Primary**: Faithfulness, Readability
516516
- **Secondary**: Conciseness, Aesthetics
517517

518+
### `paperbanana polish` -- Refine an Existing Figure
519+
520+
Bring your own figure: a VLM audits it against the venue style guide and proposes up to 10 concrete, actionable improvements, then an image-edit capable provider applies them to the original figure (guided edit). Suggestions are printed to the console so you can see exactly what changed.
521+
522+
```bash
523+
paperbanana polish --input figure.png
524+
paperbanana polish --input figure.png --venue icml --iterations 2 --output polished.png
525+
```
526+
527+
| Flag | Short | Description |
528+
|------|-------|-------------|
529+
| `--input` | `-i` | Path to the existing figure image (required) |
530+
| `--output` | `-o` | Output path (default: `outputs/polish_<timestamp>/final_output.png`) |
531+
| `--venue` | | Venue style guide: `neurips` (default), `icml`, `acl`, `ieee`, `custom` |
532+
| `--iterations` | `-n` | Polish rounds; each round suggests and applies improvements on the previous result (default: 1) |
533+
| `--aspect-ratio` | `-ar` | Target aspect ratio (default: preserve the input figure's ratio) |
534+
| `--num-candidates` | `-k` | Apply each round's suggestions N times in parallel (1-8) |
535+
| `--budget` | | Budget cap in USD; polishing stops gracefully when exceeded |
536+
| `--seed` | | Random seed for reproducible edits |
537+
538+
Requires an image provider that supports guided image edits (Google Gemini image models). If the figure already conforms to the style guide, polish exits without making changes.
539+
518540
### `paperbanana studio` -- Local web UI
519541

520542
Requires `pip install 'paperbanana[studio]'` (Gradio).

paperbanana/agents/polish.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Polish Agent: improves an existing user-supplied figure via style-guided suggestions."""
2+
3+
from __future__ import annotations
4+
5+
import inspect
6+
import re
7+
from pathlib import Path
8+
from typing import Optional
9+
10+
import structlog
11+
from PIL import Image
12+
13+
from paperbanana.agents.base import BaseAgent
14+
from paperbanana.core.utils import load_image, save_image, truncate_text
15+
from paperbanana.providers.base import ImageGenProvider, VLMProvider
16+
17+
logger = structlog.get_logger()
18+
19+
MAX_SUGGESTIONS = 10
20+
21+
# Matches "1. text", "2) text", "- text", "* text", "• text"
22+
_LIST_ITEM_RE = re.compile(r"^\s*(?:\d+[.)]\s+|[-*•]\s+)(.+?)\s*$")
23+
_FENCE_RE = re.compile(r"^\s*```[a-zA-Z0-9_-]*\s*$")
24+
25+
26+
class PolishAgent(BaseAgent):
27+
"""Refines an existing figure in two steps.
28+
29+
1. ``suggest``: a VLM audits the figure against the venue style guide and
30+
returns at most :data:`MAX_SUGGESTIONS` concrete, actionable
31+
presentation improvements.
32+
2. ``apply``: the suggestions and the original figure are sent to an
33+
image-edit capable provider (guided edit) which renders the polished
34+
version while preserving the figure's content.
35+
36+
Prompt templates live in ``prompts/polish/{suggest,apply}.txt``.
37+
"""
38+
39+
def __init__(
40+
self,
41+
image_gen: ImageGenProvider,
42+
vlm_provider: VLMProvider,
43+
prompt_dir: str = "prompts",
44+
output_dir: str = "outputs",
45+
prompt_recorder=None,
46+
image_quality: str = "auto",
47+
):
48+
super().__init__(vlm_provider, prompt_dir, prompt_recorder=prompt_recorder)
49+
self.image_gen = image_gen
50+
self.output_dir = Path(output_dir)
51+
self.image_quality = image_quality
52+
53+
@property
54+
def agent_name(self) -> str:
55+
return "polish"
56+
57+
@staticmethod
58+
def supports_guided_edit(image_gen: ImageGenProvider) -> bool:
59+
"""Whether the provider accepts input images for guided editing.
60+
61+
Image-edit capable providers declare an ``images`` keyword on
62+
``generate`` (see ``GoogleImagenGen``); the base text-to-image
63+
contract does not.
64+
"""
65+
try:
66+
return "images" in inspect.signature(image_gen.generate).parameters
67+
except (TypeError, ValueError):
68+
return False
69+
70+
def _load_polish_prompt(self, step: str) -> str:
71+
"""Load a polish prompt template (``suggest`` or ``apply``)."""
72+
path = self.prompt_dir / "polish" / f"{step}.txt"
73+
if not path.exists():
74+
raise FileNotFoundError(f"Prompt template not found: {path}")
75+
return path.read_text(encoding="utf-8")
76+
77+
async def suggest(
78+
self,
79+
image: Image.Image,
80+
style_guide: str,
81+
max_suggestions: int = MAX_SUGGESTIONS,
82+
iteration: int = 1,
83+
) -> list[str]:
84+
"""Audit *image* against *style_guide* and return actionable suggestions.
85+
86+
Returns an empty list when the figure already conforms (the VLM
87+
answers ``NO_SUGGESTIONS``) or when no list items can be parsed.
88+
"""
89+
template = self._load_polish_prompt("suggest")
90+
prompt = self.format_prompt(
91+
template,
92+
prompt_label=f"polish_suggest_iter_{iteration}",
93+
style_guide=style_guide,
94+
max_suggestions=max_suggestions,
95+
)
96+
97+
logger.info("Running polish suggest step", iteration=iteration)
98+
response = await self.vlm.generate(
99+
prompt=prompt,
100+
images=[image],
101+
temperature=0.3,
102+
max_tokens=2048,
103+
)
104+
suggestions = self._parse_suggestions(response, max_suggestions=max_suggestions)
105+
logger.info("Polish suggestions ready", count=len(suggestions), iteration=iteration)
106+
return suggestions
107+
108+
async def apply(
109+
self,
110+
image: Image.Image,
111+
suggestions: list[str],
112+
output_path: str,
113+
iteration: int = 1,
114+
aspect_ratio: Optional[str] = None,
115+
seed: Optional[int] = None,
116+
) -> str:
117+
"""Apply *suggestions* to *image* via a guided edit and save the result.
118+
119+
The original figure and the numbered suggestions both go to the image
120+
provider, so the model edits the existing figure instead of
121+
regenerating from scratch.
122+
"""
123+
if not self.supports_guided_edit(self.image_gen):
124+
raise RuntimeError(
125+
f"Image provider '{getattr(self.image_gen, 'name', 'unknown')}' does not "
126+
"support guided image editing (no 'images' parameter on generate()). "
127+
"Polish mode requires an image-edit capable provider such as 'google'."
128+
)
129+
130+
template = self._load_polish_prompt("apply")
131+
numbered = "\n".join(f"{i}. {s}" for i, s in enumerate(suggestions, start=1))
132+
prompt = self.format_prompt(
133+
template,
134+
prompt_label=f"polish_apply_iter_{iteration}",
135+
suggestions=numbered,
136+
)
137+
138+
logger.info("Running polish apply step", iteration=iteration, suggestions=len(suggestions))
139+
polished = await self.image_gen.generate(
140+
prompt=prompt,
141+
images=[image],
142+
width=image.width,
143+
height=image.height,
144+
seed=seed,
145+
aspect_ratio=aspect_ratio,
146+
quality=self.image_quality,
147+
)
148+
save_image(polished, output_path)
149+
logger.info("Polished figure saved", path=output_path, iteration=iteration)
150+
return output_path
151+
152+
async def run(
153+
self,
154+
image_path: str,
155+
style_guide: str,
156+
output_path: Optional[str] = None,
157+
iteration: int = 1,
158+
aspect_ratio: Optional[str] = None,
159+
seed: Optional[int] = None,
160+
) -> tuple[str, list[str]]:
161+
"""One polish round: suggest improvements, then apply them.
162+
163+
Returns:
164+
``(result_path, suggestions)``. When the VLM finds nothing to
165+
improve, the original ``image_path`` is returned unchanged with
166+
an empty suggestion list and the apply step is skipped.
167+
"""
168+
image = load_image(image_path)
169+
suggestions = await self.suggest(image, style_guide, iteration=iteration)
170+
if not suggestions:
171+
logger.info("No polish suggestions; figure left unchanged", iteration=iteration)
172+
return image_path, []
173+
174+
if output_path is None:
175+
output_path = str(self.output_dir / f"polished_iter_{iteration}.png")
176+
polished_path = await self.apply(
177+
image,
178+
suggestions,
179+
output_path=output_path,
180+
iteration=iteration,
181+
aspect_ratio=aspect_ratio,
182+
seed=seed,
183+
)
184+
return polished_path, suggestions
185+
186+
@staticmethod
187+
def _parse_suggestions(
188+
response: str | None, max_suggestions: int = MAX_SUGGESTIONS
189+
) -> list[str]:
190+
"""Parse a VLM response into a list of suggestion strings.
191+
192+
Handles numbered lists (``1.`` / ``2)``), bulleted lists
193+
(``-`` / ``*`` / ``•``), and fenced output (code fences are
194+
stripped). Non-list lines (preamble, prose) are ignored.
195+
``NO_SUGGESTIONS`` yields an empty list.
196+
"""
197+
if not response:
198+
return []
199+
if "NO_SUGGESTIONS" in response:
200+
return []
201+
202+
suggestions: list[str] = []
203+
for line in response.splitlines():
204+
if _FENCE_RE.match(line):
205+
continue
206+
match = _LIST_ITEM_RE.match(line)
207+
if not match:
208+
continue
209+
text = match.group(1).replace("**", "").strip()
210+
if text:
211+
suggestions.append(text)
212+
213+
if not suggestions:
214+
logger.warning(
215+
"Could not parse any suggestions from VLM response",
216+
raw_response=truncate_text(response, 500),
217+
)
218+
return suggestions[:max_suggestions]

0 commit comments

Comments
 (0)