Skip to content

Commit 5b36f66

Browse files
authored
Merge pull request #248 from llmsresearch/feat/image-input
feat: user-provided reference/sketch images guide diagram generation
2 parents ce1d2a2 + 9285335 commit 5b36f66

13 files changed

Lines changed: 544 additions & 1 deletion

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,19 @@ paperbanana generate \
255255
--input paper.pdf \
256256
--caption "Overview of our method" \
257257
--pdf-pages "3-8"
258+
259+
# Guide generation with a reference/sketch image (repeatable)
260+
paperbanana generate \
261+
--input method.txt \
262+
--caption "Overview of our framework" \
263+
--image sketch.png --image prior_figure.png
258264
```
259265

260266
| Flag | Short | Description |
261267
|------|-------|-------------|
262268
| `--input` | `-i` | Path to methodology text file or PDF (required for new runs) |
263269
| `--caption` | `-c` | Figure caption / communicative intent (required for new runs) |
270+
| `--image` | | Reference/sketch image (hand-drawn sketch, whiteboard photo, prior figure) that guides the Planner. Repeatable for multiple images |
264271
| `--output` | `-o` | Output image path (default: auto-generated in `outputs/`) |
265272
| `--iterations` | `-n` | Number of Visualizer-Critic refinement rounds (default: 3) |
266273
| `--num-candidates` | `-k` | Generate N candidate images in parallel, 1-8 (default: 1). Planning runs once; refinement fans out per candidate with seed offsets. Outputs land in `candidates/cand_<i>/`; the run-root `final_output` is candidate 1. Cost estimates and `--budget` account for the fan-out |

mcp_server/server.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,28 @@ def _embed_caption(image_path: str, caption: str) -> None:
172172
mcp = FastMCP("PaperBanana")
173173

174174

175+
def _validate_input_images(input_images: list[str] | None) -> list[str]:
176+
"""Validate user-provided reference/sketch image paths before the pipeline starts.
177+
178+
Each path must exist and be a PIL-openable raster image.
179+
180+
Raises:
181+
ValueError: If a path is missing or not a valid raster image.
182+
"""
183+
validated: list[str] = []
184+
for image_path in input_images or []:
185+
path = Path(image_path)
186+
if not path.is_file():
187+
raise ValueError(f"Image file not found: {image_path}")
188+
try:
189+
with PILImage.open(path) as im:
190+
im.verify()
191+
except Exception:
192+
raise ValueError(f"Not a valid raster image (e.g. PNG, JPEG, WebP): {image_path}")
193+
validated.append(str(path))
194+
return validated
195+
196+
175197
@mcp.tool
176198
async def generate_diagram(
177199
source_context: str,
@@ -181,6 +203,7 @@ async def generate_diagram(
181203
optimize: bool = False,
182204
auto_refine: bool = False,
183205
generate_caption: bool = False,
206+
input_images: list[str] | None = None,
184207
) -> Image:
185208
"""Generate a publication-quality methodology diagram from text.
186209
@@ -197,10 +220,15 @@ async def generate_diagram(
197220
generate_caption: Auto-generate a publication-ready figure caption
198221
after generation. When True, the caption is embedded in the
199222
image metadata (PNG tEXt chunk, key "Caption") and logged.
223+
input_images: Optional file paths to user-provided reference/sketch
224+
images (hand-drawn sketch, whiteboard photo, prior figure) that
225+
guide the layout and content of the generated diagram.
200226
201227
Returns:
202228
The generated diagram as a PNG image.
203229
"""
230+
validated_images = _validate_input_images(input_images)
231+
204232
settings = Settings(
205233
refinement_iterations=iterations,
206234
optimize_inputs=optimize,
@@ -223,6 +251,7 @@ def _on_progress(event: str, payload: dict) -> None:
223251
communicative_intent=caption,
224252
diagram_type=DiagramType.METHODOLOGY,
225253
aspect_ratio=aspect_ratio,
254+
input_images=validated_images,
226255
)
227256

228257
result = await pipeline.generate(gen_input)

paperbanana/agents/planner.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def run(
4646
examples: list[ReferenceExample],
4747
diagram_type: DiagramType = DiagramType.METHODOLOGY,
4848
supported_ratios: list[str] | None = None,
49+
input_images: list[str] | None = None,
4950
) -> tuple[str, str | None]:
5051
"""Generate a detailed textual description of the target diagram.
5152
@@ -55,6 +56,8 @@ async def run(
5556
examples: Retrieved reference examples for in-context learning.
5657
diagram_type: Type of diagram being generated.
5758
supported_ratios: Aspect ratios the image provider supports.
59+
input_images: Paths to user-provided reference/sketch images that
60+
guide the plan alongside the retrieved exemplars.
5861
5962
Returns:
6063
Tuple of (description, recommended_ratio).
@@ -66,8 +69,18 @@ async def run(
6669
# Load reference images for visual in-context learning
6770
example_images = await asyncio.to_thread(self._load_example_images, examples)
6871

72+
# Load user-provided reference/sketch images (attached after the
73+
# exemplar images so "reference image N" indexing stays valid).
74+
user_images: list = []
75+
if input_images:
76+
user_images = await asyncio.to_thread(self._load_input_images, input_images)
77+
6978
prompt_type = "diagram" if diagram_type == DiagramType.METHODOLOGY else "plot"
7079
template = self.load_prompt(prompt_type)
80+
if user_images:
81+
# Appended pre-format so the prompt recorder captures it; the note
82+
# is brace-free, keeping str.format() on the template intact.
83+
template += "\n\n" + self._format_user_image_note(len(user_images))
7184
# Inject supported ratios into the prompt template
7285
ratios_str = ", ".join(supported_ratios) if supported_ratios else "1:1, 16:9"
7386
prompt = self.format_prompt(
@@ -83,12 +96,14 @@ async def run(
8396
"Running planner agent",
8497
num_examples=len(examples),
8598
num_images=len(example_images),
99+
num_user_images=len(user_images),
86100
context_length=len(source_context),
87101
)
88102

103+
all_images = example_images + user_images
89104
raw_output = await self.vlm.generate(
90105
prompt=prompt,
91-
images=example_images if example_images else None,
106+
images=all_images if all_images else None,
92107
temperature=0.7,
93108
max_tokens=4096,
94109
)
@@ -242,6 +257,36 @@ def _load_example_images(self, examples: list[ReferenceExample]) -> list:
242257
)
243258
return images
244259

260+
@staticmethod
261+
def _format_user_image_note(count: int) -> str:
262+
"""Label for user-provided reference/sketch images attached to the prompt."""
263+
return (
264+
"## User-Provided Reference/Sketch\n"
265+
f"The final {count} attached image(s), after the reference example images, "
266+
"are user-provided reference/sketch images (e.g. a hand-drawn sketch, "
267+
"whiteboard photo, or a prior version of the figure). Use them as guidance "
268+
"for the layout and content of the target diagram while staying faithful "
269+
"to the source text."
270+
)
271+
272+
def _load_input_images(self, paths: list[str]) -> list:
273+
"""Load user-provided reference/sketch images from local paths.
274+
275+
Returns PIL Image objects; unreadable files are skipped with a warning
276+
(the CLI/MCP entry points validate them before the pipeline starts).
277+
"""
278+
images = []
279+
for path in paths:
280+
try:
281+
images.append(load_image(path))
282+
except Exception as e:
283+
logger.warning(
284+
"Failed to load user-provided reference image",
285+
image_path=path,
286+
error=str(e),
287+
)
288+
return images
289+
245290
_VALID_RATIOS = {"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}
246291

247292
@classmethod

paperbanana/agents/visualizer.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ async def run(
6161
seed: Optional[int] = None,
6262
aspect_ratio: Optional[str] = None,
6363
vector_formats: Optional[list[str]] = None,
64+
sketch_guided: bool = False,
6465
) -> str:
6566
"""Generate an image from a description.
6667
@@ -74,6 +75,8 @@ async def run(
7475
aspect_ratio: Target aspect ratio (e.g., '16:9', '1:1').
7576
vector_formats: Vector formats to export alongside raster (e.g., ['svg', 'pdf']).
7677
Only applies to statistical plots; ignored for methodology diagrams.
78+
sketch_guided: When True, the diagram prompt notes that a
79+
user-provided reference sketch guided the plan.
7780
7881
Returns:
7982
Path to the generated raster image.
@@ -90,18 +93,27 @@ async def run(
9093
iteration,
9194
seed,
9295
aspect_ratio,
96+
sketch_guided=sketch_guided,
9397
)
9498

99+
_SKETCH_GUIDED_NOTE = (
100+
"Note: this plan was guided by a user-provided reference sketch; "
101+
"follow the description above faithfully."
102+
)
103+
95104
async def _generate_diagram(
96105
self,
97106
description: str,
98107
output_path: Optional[str],
99108
iteration: int,
100109
seed: Optional[int],
101110
aspect_ratio: Optional[str] = None,
111+
sketch_guided: bool = False,
102112
) -> str:
103113
"""Generate a methodology diagram using the image generation model."""
104114
template = self.load_prompt("diagram")
115+
if sketch_guided:
116+
template += "\n\n" + self._SKETCH_GUIDED_NOTE
105117
prompt = self.format_prompt(
106118
template,
107119
prompt_label=f"visualizer_diagram_iter_{iteration}",

paperbanana/cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ def generate(
245245
caption: Optional[str] = typer.Option(
246246
None, "--caption", "-c", help="Figure caption / communicative intent"
247247
),
248+
image: Optional[list[str]] = typer.Option(
249+
None,
250+
"--image",
251+
help=(
252+
"Path to a reference/sketch image (hand-drawn sketch, whiteboard photo, "
253+
"prior figure) that guides generation. Repeatable for multiple images."
254+
),
255+
),
248256
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output image path"),
249257
output_dir: Optional[str] = typer.Option(
250258
None,
@@ -455,6 +463,31 @@ def generate(
455463
"[red]Error: --pdf-pages cannot be used with --continue or --continue-run[/red]"
456464
)
457465
raise typer.Exit(1)
466+
if image and (continue_last or continue_run):
467+
console.print("[red]Error: --image cannot be used with --continue or --continue-run[/red]")
468+
raise typer.Exit(1)
469+
470+
# Validate reference/sketch images before any pipeline work starts.
471+
input_images: list[str] = []
472+
if image:
473+
from PIL import Image as PILImage
474+
from PIL import UnidentifiedImageError
475+
476+
for image_path in image:
477+
img_file = Path(image_path)
478+
if not img_file.is_file():
479+
console.print(f"[red]Error: Image file not found: {image_path}[/red]")
480+
raise typer.Exit(1)
481+
try:
482+
with PILImage.open(img_file) as im:
483+
im.verify()
484+
except (UnidentifiedImageError, OSError, ValueError):
485+
console.print(
486+
f"[red]Error: Not a valid raster image (e.g. PNG, JPEG, WebP): "
487+
f"{image_path}[/red]"
488+
)
489+
raise typer.Exit(1)
490+
input_images.append(str(img_file))
458491

459492
_valid_categories = {
460493
"agent_reasoning",
@@ -721,6 +754,7 @@ async def _run_continue():
721754
diagram_type=DiagramType.METHODOLOGY,
722755
aspect_ratio=aspect_ratio,
723756
reference_ids=ref_id_list,
757+
input_images=input_images,
724758
)
725759

726760
# Determine expected output file extension based on settings.output_format
@@ -764,6 +798,8 @@ async def _run_continue():
764798
pdf_note = ""
765799
if input_path.suffix.lower() == ".pdf":
766800
pdf_note = f"\nPDF pages: {pdf_pages.strip() if pdf_pages else 'all'}"
801+
if input_images:
802+
pdf_note += f"\nReference images: {', '.join(input_images)}"
767803
console.print(
768804
Panel.fit(
769805
"[bold]PaperBanana[/bold] - Dry Run\n\n"

paperbanana/core/pipeline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ def _extra(**payload: Any) -> Dict[str, Any]:
560560
seed=seed,
561561
aspect_ratio=effective_ratio,
562562
vector_formats=vector_formats,
563+
sketch_guided=bool(input.input_images),
563564
)
564565
visualizer_seconds = time.perf_counter() - visualizer_start
565566
if image_path is None:
@@ -1267,6 +1268,7 @@ async def generate(
12671268
"raw_data": input.raw_data,
12681269
"aspect_ratio": input.aspect_ratio,
12691270
"vector_export": self._effective_vector_export(input),
1271+
"input_images": input.input_images,
12701272
},
12711273
self._run_dir / "run_input.json",
12721274
)
@@ -1342,6 +1344,7 @@ async def generate(
13421344
diagram_type=input.diagram_type,
13431345
raw_data=input.raw_data,
13441346
aspect_ratio=input.aspect_ratio,
1347+
input_images=input.input_images,
13451348
)
13461349
except Exception:
13471350
optimize_seconds = time.perf_counter() - optimize_start
@@ -1471,6 +1474,7 @@ async def generate(
14711474
examples=examples,
14721475
diagram_type=input.diagram_type,
14731476
supported_ratios=getattr(self.visualizer.image_gen, "supported_ratios", None),
1477+
input_images=input.input_images,
14741478
)
14751479
planning_seconds = time.perf_counter() - planning_start
14761480
_emit_progress(

paperbanana/core/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ class GenerationInput(BaseModel):
9191
default=None,
9292
description="Optional vector export (svg/pdf/both); None uses Settings.vector_export",
9393
)
94+
input_images: list[str] = Field(
95+
default_factory=list,
96+
description=(
97+
"Paths to user-provided reference/sketch images (e.g. a hand-drawn "
98+
"sketch, whiteboard photo, or prior figure version) that guide the "
99+
"Planner alongside retrieved exemplars."
100+
),
101+
)
94102

95103
@field_validator("aspect_ratio")
96104
@classmethod

0 commit comments

Comments
 (0)