Skills › Scientific Agent Skills › scientific-slides › generate_slide_image_ai.py
generate_slide_image_ai.py
824 lines · skills/scientific-slides/scripts/generate_slide_image_ai.py at 5f2bc03
1#!/usr/bin/env python3
2"""
3AI-powered slide image generation using Nano Banana 2.
5This script generates presentation slides or slide visuals using AI:
6- full_slide mode: Generate complete slides with title, content, and visuals (for PDF workflow)
7- visual_only mode: Generate just images/figures to place on slides (for PPT workflow)
9Supports attaching conceptual or style references. Attachments may be redrawn;
10embed original quantitative figures with native slide composition instead.
12Uses smart iterative refinement:
131. Generate initial image with Nano Banana 2
142. Quality review using Gemini 3.7 Flash
153. Only regenerate if quality is below threshold
164. Repeat until quality meets standards (max iterations)
18Requirements:
19 - OPENROUTER_API_KEY environment variable
20 - requests library
22Usage:
23 # Full slide for PDF workflow
24 python generate_slide_image_ai.py "Title: Introduction to ML\nKey points: supervised learning, neural networks" -o slide_01.png
26 # Visual only for PPT workflow
27 python generate_slide_image_ai.py "Neural network architecture diagram" -o figure.png --visual-only
29 # With reference images attached
30 python generate_slide_image_ai.py "Draft a conceptual overview in this style" -o slide.png --attach style_reference.png
31"""
33import argparse
34import base64
35import json
36import os
37import re
38import sys
39import time
40from pathlib import Path
41from typing import Optional, Dict, Any, List, NamedTuple
44try:
45 import requests
46except ImportError:
47 print("Error: requests library not found. Install with: uv pip install requests")
48 sys.exit(1)
51class ReviewResult(NamedTuple):
52 """The outcome of one quality review.
54 A named tuple rather than a bare tuple because the review has several
55 failure modes -- the model returns no choices, the request raises, the
56 answer arrives in an unexpected shape -- and each of them used to be a
57 chance to return the wrong number of values to the caller.
59 `score` is None whenever no usable number was parsed, and `reviewed` is
60 False whenever the review never produced an answer at all. Neither case
61 invents a score: a slide whose quality was not measured is reported as
62 unmeasured, not as passing.
63 """
65 critique: str
66 score: Optional[float]
67 needs_improvement: bool
68 reviewed: bool
69 error: Optional[str] = None
72# Markdown decoration the reviewer routinely wraps its answer in. Gemini writes
73# "**SCORE:** 8.5" far more often than the bare "SCORE: 8.5" the rubric asks
74# for, and a pattern that does not allow for it silently scores every image
75# at whatever the default happens to be.
76_MARKUP = re.compile(r"[*_`#]")
78_SCORE_PATTERN = re.compile(r"\bSCORE\s*:?\s*(\d+(?:\.\d+)?)", re.IGNORECASE)
79_LOOSE_SCORE_PATTERN = re.compile(
80 r"(?:score|rating|quality)\s*[:\s]\s*(\d+(?:\.\d+)?)\s*(?:/\s*10)?", re.IGNORECASE
81)
82# The underscore is optional because normalization strips it out of
83# NEEDS_IMPROVEMENT along with the surrounding markdown.
84_VERDICT_PATTERN = re.compile(
85 r"^\s*VERDICT\s*:?\s*(ACCEPTABLE|NEEDS[_ ]?IMPROVEMENT)", re.IGNORECASE | re.MULTILINE
86)
89def _normalize_review_text(text: str) -> str:
90 """Strip markdown decoration so the rubric's fields can be found."""
91 return _MARKUP.sub("", text or "")
94def _parse_score(text: str) -> Optional[float]:
95 """Return the reviewer's 0-10 score, or None when there isn't a usable one.
97 None rather than a default: a fabricated score is indistinguishable from a
98 real one once it reaches the review log, and a default near the middle of
99 the range quietly passes lenient thresholds while failing strict ones.
100 """
101 normalized = _normalize_review_text(text)
102 match = _SCORE_PATTERN.search(normalized) or _LOOSE_SCORE_PATTERN.search(normalized)
103 if not match:
104 return None
105 score = float(match.group(1))
106 # A number outside the rubric's range means something other than the score
107 # was matched -- a slide number, a percentage, an iteration number.
108 return score if 0.0 <= score <= 10.0 else None
111def _parse_verdict(text: str) -> Optional[str]:
112 """Return ACCEPTABLE / NEEDS_IMPROVEMENT from the verdict line, or None.
114 Anchored to the start of a line. The rubric prompt spells out both tokens
115 when it explains the response format, so a reviewer that echoes those
116 instructions back would fail a perfectly good image under a plain
117 substring search.
118 """
119 match = _VERDICT_PATTERN.search(_normalize_review_text(text))
120 if not match:
121 return None
122 verdict = match.group(1).upper().replace(" ", "").replace("_", "")
123 return "NEEDS_IMPROVEMENT" if verdict == "NEEDSIMPROVEMENT" else "ACCEPTABLE"
126def _resolve_api_key(explicit: Optional[str] = None) -> Optional[str]:
127 """Resolve the OpenRouter key from --api-key, the environment, then any .env file.
129 The .env scan walks up from the working directory and finally checks the
130 script's own directory, so running from anywhere inside a project picks up
131 the key at its root. Only the standard library is used: python-dotenv is a
132 common omission, and a missing optional dependency should not read as a
133 missing credential.
134 """
135 if explicit:
136 return explicit
138 from_env = os.environ.get("OPENROUTER_API_KEY", "").strip()
139 if from_env:
140 return from_env
142 cwd = Path.cwd()
143 for directory in [cwd, *cwd.parents, Path(__file__).resolve().parent]:
144 env_file = directory / ".env"
145 if not env_file.is_file():
146 continue
147 try:
148 content = env_file.read_text(encoding="utf-8", errors="replace")
149 except OSError:
150 continue
151 for raw in content.splitlines():
152 line = raw.strip()
153 if line.startswith("#") or "=" not in line:
154 continue
155 name, _, value = line.partition("=")
156 if name.strip() == "OPENROUTER_API_KEY":
157 value = value.strip().strip('"').strip("'")
158 if value:
159 return value
161 return None
164class SlideImageGenerator:
165 """Generate presentation slides or visuals using AI with iterative refinement.
167 Two modes:
168 - full_slide: Generate complete slide with title, content, visuals (for PDF workflow)
169 - visual_only: Generate just the image/figure for a slide (for PPT workflow)
170 """
172 # Quality threshold for presentations (lower than journal/conference papers)
173 QUALITY_THRESHOLD = 6.5
175 # Guidelines for generating full slides (complete slide images)
176 FULL_SLIDE_GUIDELINES = """
177Create a professional presentation slide image with these requirements:
179SLIDE LAYOUT (16:9 aspect ratio):
180- Clean, modern slide design
181- Clear visual hierarchy: title at top, content below
182- Generous margins (at least 5% on all sides)
183- Balanced composition with intentional white space
185TYPOGRAPHY:
186- LARGE, bold title text (easily readable from distance)
187- Clear, sans-serif fonts throughout
188- High contrast text (dark on light or light on dark)
189- Bullet points or key phrases, NOT paragraphs
190- Maximum 5-6 lines of text content
191- Use the supplied author/presenter; omit it when no name is provided
193VISUAL ELEMENTS:
194- Use GENERIC, simple images and icons - avoid overly specific or detailed imagery
195- MINIMAL extra elements - no decorative borders, shadows, or flourishes
196- Visuals should support and enhance the message, not distract
197- Professional, clean aesthetic with restraint
198- Consistent color scheme (2-3 main colors only)
199- Prefer abstract/conceptual visuals over literal representations
201PROFESSIONAL MINIMALISM:
202- Less is more: favor empty space over additional elements
203- No unnecessary decorations, gradients, or visual noise
204- Clean lines and simple shapes
205- Focused content without visual clutter
206- Corporate/academic level of professionalism
208PRESENTATION QUALITY:
209- Designed for projection (high contrast)
210- Bold, impactful design that commands attention
211- Professional and polished appearance
212- No cluttered or busy layouts
213- Consistent styling throughout the deck
214"""
216 # Guidelines for generating slide visuals only (figures/images for PPT)
217 VISUAL_ONLY_GUIDELINES = """
218Create a high-quality visual/figure for a presentation slide:
220IMAGE QUALITY:
221- Clean, professional appearance
222- High resolution and sharp details
223- Suitable for embedding in a slide
225DESIGN:
226- Simple, clear composition with MINIMAL elements
227- High contrast for projection readability
228- No text unless essential to the visual
229- Transparent or white background preferred
230- GENERIC imagery - avoid overly specific or detailed visuals
232PROFESSIONAL MINIMALISM:
233- Favor simplicity over complexity
234- No decorative elements, shadows, or flourishes
235- Clean lines and simple shapes only
236- Remove any unnecessary visual noise
237- Abstract/conceptual rather than literal representations
239STYLE:
240- Modern, professional aesthetic
241- Colorblind-friendly colors
242- Bold but restrained imagery
243- Suitable for scientific/professional presentations
244- Corporate/academic level of polish
245"""
247 def __init__(self, api_key: Optional[str] = None, verbose: bool = False):
248 """
249 Initialize the generator.
251 Args:
252 api_key: OpenRouter API key (or use OPENROUTER_API_KEY env var)
253 verbose: Print detailed progress information
254 """
255 self.api_key = _resolve_api_key(api_key)
257 if not self.api_key:
258 raise ValueError(
259 "OPENROUTER_API_KEY not found. Please either:\n"
260 " 1. Set the OPENROUTER_API_KEY environment variable\n"
261 " 2. Add OPENROUTER_API_KEY to your .env file\n"
262 " 3. Pass api_key parameter to the constructor\n"
263 "Get your API key from: https://openrouter.ai/keys"
264 )
266 self.verbose = verbose
267 self._last_error = None
268 self.base_url = "https://openrouter.ai/api/v1"
269 # Nano Banana 2 for image generation. The slug must be an image-output
270 # model; a text-only chat model is rejected with "No endpoints found that
271 # support the requested output modalities".
272 # https://openrouter.ai/google/gemini-3.1-flash-image
273 self.image_model = "google/gemini-3.1-flash-image"
274 # Gemini 3.7 Flash for quality review - reads the image, answers in text
275 self.review_model = "google/gemini-3.7-flash"
277 def _log(self, message: str):
278 """Log message if verbose mode is enabled."""
279 if self.verbose:
280 print(f"[{time.strftime('%H:%M:%S')}] {message}")
282 def _post_request(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
283 """Send one non-streaming request; never automatically replay a paid call."""
284 headers = {
285 "Authorization": f"Bearer {self.api_key}",
286 "Content-Type": "application/json",
287 "X-Title": "Scientific Slide Generator",
288 }
289 self._log(f"POST {endpoint} using {payload['model']}")
290 try:
291 response = requests.post(
292 f"{self.base_url}/{endpoint}", headers=headers,
293 json=payload, timeout=120,
294 )
295 try:
296 response_json = response.json()
297 except ValueError:
298 raise RuntimeError(
299 f"API returned non-JSON data (HTTP {response.status_code})"
300 ) from None
301 if not isinstance(response_json, dict):
302 raise RuntimeError("API response must be a JSON object")
303 if response.status_code != 200 or "error" in response_json:
304 error = response_json.get("error", {})
305 detail = error.get("message", "request rejected") if isinstance(error, dict) else str(error)
306 # Providers occasionally echo input; do not dump whole bodies or credentials.
307 detail = str(detail).replace(self.api_key, "[redacted]")[:500]
308 raise RuntimeError(f"API request failed (HTTP {response.status_code}): {detail}")
309 return response_json
310 except requests.exceptions.Timeout:
311 raise RuntimeError("API request timed out after 120 seconds") from None
312 except requests.exceptions.RequestException as exc:
313 detail = str(exc).replace(self.api_key, "[redacted]")
314 raise RuntimeError(f"API request failed: {detail}") from None
316 def _make_request(self, model: str, messages: List[Dict[str, Any]],
317 modalities: Optional[List[str]] = None) -> Dict[str, Any]:
318 """Use chat completions for the text-and-image vision review."""
319 payload = {"model": model, "messages": messages}
320 if modalities:
321 payload["modalities"] = modalities
322 return self._post_request("chat/completions", payload)
324 def _extract_image_from_response(self, response: Dict[str, Any]) -> bytes:
325 """Decode the dedicated Image API response and enforce this helper's PNG contract.
327 The provider may omit media_type; the PNG signature still has to match.
328 This is a format check, not a full image-decoder or scientific-content check.
329 """
330 data = response.get("data")
331 if not isinstance(data, list) or not data or not isinstance(data[0], dict):
332 raise RuntimeError("Image API returned no data[0] image")
333 item = data[0]
334 if item.get("media_type") not in (None, "image/png"):
335 raise RuntimeError(f"Expected PNG output; received {item['media_type']}")
336 encoded = item.get("b64_json")
337 if not isinstance(encoded, str) or not encoded:
338 raise RuntimeError("Image API returned no b64_json image")
339 try:
340 image_data = base64.b64decode(encoded, validate=True)
341 except ValueError:
342 raise RuntimeError("Image API returned invalid base64 image data") from None
343 if not image_data.startswith(b"\x89PNG\r\n\x1a\n"):
344 raise RuntimeError("Image API output does not have a PNG signature")
345 return image_data
347 def _image_to_base64(self, image_path: str) -> str:
348 """Convert image file to base64 data URL."""
349 with open(image_path, "rb") as f:
350 image_data = f.read()
352 ext = Path(image_path).suffix.lower()
353 mime_types = {
354 ".png": "image/png",
355 ".jpg": "image/jpeg",
356 ".jpeg": "image/jpeg",
357 ".gif": "image/gif",
358 ".webp": "image/webp"
359 }
360 if ext not in mime_types:
361 raise ValueError(f"Unsupported attachment format: {ext}; use PNG, JPEG, GIF, or WebP")
362 mime_type = mime_types[ext]
364 base64_data = base64.b64encode(image_data).decode("utf-8")
365 return f"data:{mime_type};base64,{base64_data}"
367 def generate_image(self, prompt: str, attachments: Optional[List[str]] = None) -> Optional[bytes]:
368 """Generate one widescreen PNG using the dedicated Image API.
370 References guide generation; they are not preserved as original pixels.
371 A missing or unsupported reference aborts before a paid request.
372 """
373 self._last_error = None
374 try:
375 if attachments and len(attachments) > 14:
376 raise ValueError("This image model accepts at most 14 reference images")
377 payload = {
378 "model": self.image_model, "prompt": prompt,
379 "n": 1, "aspect_ratio": "16:9",
380 }
381 if attachments:
382 payload["input_references"] = [
383 {"type": "image_url", "image_url": {"url": self._image_to_base64(path)}}
384 for path in attachments
385 ]
386 # output_format is not in this model's endpoint capability map.
387 # Validate the returned PNG bytes instead of sending unsupported knobs.
388 response = self._post_request("images", payload)
389 image_data = self._extract_image_from_response(response)
390 self._log(f"[OK] Generated PNG ({len(image_data)} bytes)")
391 return image_data
392 except (RuntimeError, ValueError, OSError) as exc:
393 self._last_error = str(exc)
394 self._log(f"[FAIL] Generation failed: {self._last_error}")
395 return None
397 def review_image(self, image_path: str, original_prompt: str,
398 iteration: int, visual_only: bool = False,
399 max_iterations: int = 2) -> ReviewResult:
400 """Review generated image using Gemini 3.7 Flash."""
401 image_data_url = self._image_to_base64(image_path)
402 threshold = self.QUALITY_THRESHOLD
404 image_type = "slide visual/figure" if visual_only else "presentation slide"
406 review_prompt = f"""You are an expert reviewer evaluating a {image_type} for presentation quality.
408ORIGINAL REQUEST: {original_prompt}
410QUALITY THRESHOLD: {threshold}/10
411ITERATION: {iteration}/{max_iterations}
413Evaluate this {image_type} on these criteria:
4151. **Visual Impact** (0-2 points)
416 - Bold, attention-grabbing design
417 - Professional appearance
418 - Suitable for projection
4202. **Clarity** (0-2 points)
421 - Easy to understand at a glance
422 - Clear visual hierarchy
423 - Not cluttered or busy
4253. **Readability** (0-2 points)
426 - Text is large and readable (if present)
427 - High contrast
428 - Clean typography
4304. **Composition** (0-2 points)
431 - Balanced layout
432 - Good use of space
433 - Appropriate margins
4355. **Relevance** (0-2 points)
436 - Matches the requested content
437 - Appropriate style for presentations
438 - Professional quality
440RESPOND IN THIS EXACT FORMAT:
441SCORE: [total score 0-10]
443STRENGTHS:
444- [strength 1]
445- [strength 2]
447ISSUES:
448- [issue 1 if any]
449- [issue 2 if any]
451VERDICT: [ACCEPTABLE or NEEDS_IMPROVEMENT]
453If score >= {threshold}, the image is ACCEPTABLE.
454If score < {threshold}, mark as NEEDS_IMPROVEMENT with specific suggestions."""
456 messages = [
457 {
458 "role": "user",
459 "content": [
460 {"type": "text", "text": review_prompt},
461 {"type": "image_url", "image_url": {"url": image_data_url}}
462 ]
463 }
464 ]
466 try:
467 response = self._make_request(model=self.review_model, messages=messages)
469 choices = response.get("choices", [])
470 if not choices:
471 # A normal enough outcome -- a rate limit, a content filter, a
472 # provider hiccup. The image itself is fine; only its review is
473 # missing, and saying so beats inventing a score.
474 reason = "the review model returned no choices"
475 self._log(f"[WARN] Review unavailable: {reason}")
476 return ReviewResult(
477 f"Review unavailable: {reason}.",
478 None, False, reviewed=False, error=reason,
479 )
481 message = choices[0].get("message", {})
482 content = message.get("content", "")
484 reasoning = message.get("reasoning", "")
485 if reasoning and not content:
486 content = reasoning
488 if isinstance(content, list):
489 text_parts = []
490 for block in content:
491 if isinstance(block, dict) and block.get("type") == "text":
492 text_parts.append(block.get("text", ""))
493 content = "\n".join(text_parts)
495 score = _parse_score(content)
496 verdict = _parse_verdict(content)
498 if score is None and verdict is None:
499 reason = "no score or verdict found in the review"
500 self._log(f"[WARN] Review unusable: {reason}")
501 return ReviewResult(
502 content if content else f"Review unusable: {reason}.",
503 None, False, reviewed=True, error=reason,
504 )
506 needs_improvement = verdict == "NEEDS_IMPROVEMENT" or (
507 score is not None and score < threshold
508 )
510 shown = f"{score}/10" if score is not None else "not stated"
511 self._log(f"[OK] Review complete (Score: {shown}, Threshold: {threshold}/10)")
513 return ReviewResult(
514 content if content else "Review returned no text",
515 score,
516 needs_improvement,
517 reviewed=True,
518 error=None if score is not None else "no score found in the review",
519 )
520 except Exception as e:
521 # A failed review must not fail the run -- the image is already
522 # generated -- but it must not read as a pass either.
523 self._log(f"[WARN] Review failed: {str(e)}")
524 return ReviewResult(
525 f"Review failed: {str(e)}",
526 None, False, reviewed=False, error=str(e),
527 )
529 def improve_prompt(self, original_prompt: str, critique: str,
530 iteration: int, visual_only: bool = False) -> str:
531 """Improve the generation prompt based on critique."""
532 guidelines = self.VISUAL_ONLY_GUIDELINES if visual_only else self.FULL_SLIDE_GUIDELINES
534 return f"""{guidelines}
536USER REQUEST: {original_prompt}
538ITERATION {iteration}: Based on previous feedback, address these specific improvements:
539{critique}
541Generate an improved version that addresses all the critique points."""
543 def generate_slide(self, user_prompt: str, output_path: str,
544 visual_only: bool = False,
545 iterations: int = 2,
546 attachments: Optional[List[str]] = None) -> Dict[str, Any]:
547 """
548 Generate a slide image or visual with iterative refinement.
550 Args:
551 user_prompt: Description of the slide/visual to generate
552 output_path: Path to save final image
553 visual_only: If True, generate just the visual (for PPT workflow)
554 iterations: Maximum refinement iterations (default: 2)
555 attachments: Optional list of image file paths to attach as context
557 Returns:
558 Dictionary with generation results and metadata
559 """
560 if isinstance(iterations, bool) or not isinstance(iterations, int) or not 1 <= iterations <= 2:
561 raise ValueError("iterations must be 1 or 2")
562 output_path = Path(output_path)
563 if output_path.suffix.lower() != ".png":
564 raise ValueError("Output path must end in .png; format conversion is not supported")
565 output_dir = output_path.parent
566 output_dir.mkdir(parents=True, exist_ok=True)
568 base_name = output_path.stem
569 extension = output_path.suffix or ".png"
571 mode = "visual_only" if visual_only else "full_slide"
572 guidelines = self.VISUAL_ONLY_GUIDELINES if visual_only else self.FULL_SLIDE_GUIDELINES
574 results = {
575 "user_prompt": user_prompt,
576 "mode": mode,
577 "quality_threshold": self.QUALITY_THRESHOLD,
578 "image_model": self.image_model,
579 "review_model": self.review_model,
580 "quality_met": False,
581 "termination_reason": None,
582 "attachments": attachments or [],
583 "iterations": [],
584 "final_image": None,
585 # None, not 0.0 -- a run whose review never completed has no score,
586 # and a number here would be read as one the reviewer gave.
587 "final_score": None,
588 "final_reviewed": False,
589 "success": False,
590 "early_stop": False
591 }
593 current_prompt = f"""{guidelines}
595USER REQUEST: {user_prompt}
597Generate a high-quality {'visual/figure' if visual_only else 'presentation slide'} that meets all the guidelines above."""
599 print(f"\n{'='*60}")
600 print(f"Generating Slide {'Visual' if visual_only else 'Image'}")
601 print(f"{'='*60}")
602 print(f"Description: {user_prompt[:100]}{'...' if len(user_prompt) > 100 else ''}")
603 print(f"Mode: {mode}")
604 if attachments:
605 print(f"Attachments: {len(attachments)} image(s)")
606 for att in attachments:
607 print(f" - {att}")
608 print(f"Quality Threshold: {self.QUALITY_THRESHOLD}/10")
609 print(f"Max Iterations: {iterations}")
610 print(f"Output: {output_path}")
611 print(f"{'='*60}\n")
613 # Track temporary files for cleanup
614 temp_files = []
615 final_image_data = None
617 for i in range(1, iterations + 1):
618 print(f"\n[Iteration {i}/{iterations}]")
619 print("-" * 40)
621 print(f"Generating image with Nano Banana 2...")
622 image_data = self.generate_image(current_prompt, attachments=attachments)
624 if not image_data:
625 error_msg = self._last_error or 'Image generation failed'
626 print(f"[FAIL] Generation failed: {error_msg}")
627 results["iterations"].append({
628 "iteration": i,
629 "success": False,
630 "error": error_msg
631 })
632 results["termination_reason"] = "generation_failed"
633 break
635 # Save to temporary file for review (will be cleaned up)
636 import tempfile
637 temp_fd, temp_path = tempfile.mkstemp(suffix=extension)
638 os.close(temp_fd)
639 temp_path = Path(temp_path)
640 temp_files.append(temp_path)
642 with open(temp_path, "wb") as f:
643 f.write(image_data)
644 print(f"[OK] Generated image (iteration {i})")
646 print(f"Reviewing image with {self.review_model}...")
647 review = self.review_image(
648 str(temp_path), user_prompt, i, visual_only, iterations
649 )
650 if review.score is not None:
651 print(f"[OK] Score: {review.score}/10 (threshold: {self.QUALITY_THRESHOLD}/10)")
652 else:
653 print(f"[WARN] Review unavailable - image kept, quality not verified")
654 print(f" Reason: {review.error}")
656 results["iterations"].append({
657 "iteration": i,
658 "critique": review.critique,
659 "score": review.score,
660 "reviewed": review.reviewed,
661 "review_error": review.error,
662 "needs_improvement": review.needs_improvement,
663 "success": True
664 })
666 # Keep this draft even when the next refinement request fails.
667 final_image_data = image_data
668 results["final_score"] = review.score
669 results["final_reviewed"] = review.reviewed and review.score is not None
670 results["success"] = True
671 results["quality_met"] = (
672 results["final_reviewed"] and review.score >= self.QUALITY_THRESHOLD
673 and not review.needs_improvement
674 )
676 if not review.needs_improvement:
677 results["termination_reason"] = (
678 "quality_threshold_met" if results["quality_met"] else "review_unavailable"
679 )
680 if review.score is not None:
681 print(f"\n[OK] Quality meets threshold ({review.score} >= {self.QUALITY_THRESHOLD})")
682 else:
683 # Regenerating cannot fix a reviewer that did not answer.
684 print(f"\n[WARN] Stopping without a verified score - review the image yourself")
685 final_image_data = image_data
686 results["final_score"] = review.score
687 results["final_reviewed"] = review.reviewed and review.score is not None
688 results["success"] = True
689 results["early_stop"] = True
690 break
692 if i == iterations:
693 results["termination_reason"] = "max_iterations"
694 print(f"\n[WARN] Maximum iterations reached")
695 final_image_data = image_data
696 results["final_score"] = review.score
697 results["final_reviewed"] = review.reviewed and review.score is not None
698 results["success"] = True
699 break
701 print(f"\n[WARN] Review requests improvement (score: {review.score}; threshold: {self.QUALITY_THRESHOLD})")
702 print(f"Improving prompt...")
703 current_prompt = self.improve_prompt(user_prompt, review.critique, i + 1, visual_only)
705 # Clean up temporary files
706 for temp_file in temp_files:
707 try:
708 if temp_file.exists():
709 temp_file.unlink()
710 except Exception:
711 pass
713 # Save only the final image to output path
714 if results["success"] and final_image_data:
715 with open(output_path, "wb") as f:
716 f.write(final_image_data)
717 results["final_image"] = str(output_path)
718 print(f"\n[OK] Final image: {output_path}")
720 log_path = output_dir / f"{base_name}_review_log.json"
721 log_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
722 print(f"[OK] Review log: {log_path}")
724 print(f"\n{'='*60}")
725 print(f"Generation Complete!")
726 if results["final_score"] is not None:
727 print(f"Final Score: {results['final_score']}/10")
728 else:
729 print(f"Final Score: unavailable (the review did not produce one)")
730 if results["early_stop"]:
731 success_count = len([r for r in results['iterations'] if r.get('success')])
732 print(f"Iterations Used: {success_count}/{iterations} (early stop)")
733 print(f"{'='*60}\n")
735 return results
738def main():
739 """Command-line interface."""
740 parser = argparse.ArgumentParser(
741 description="Generate presentation slides or visuals using Nano Banana 2 AI",
742 formatter_class=argparse.RawDescriptionHelpFormatter,
743 epilog="""
744Examples:
745 # Generate a full slide (for PDF workflow)
746 python generate_slide_image_ai.py "Title: Machine Learning Basics\\nKey points: supervised learning, neural networks, deep learning" -o slide_01.png
748 # Generate just a visual/figure (for PPT workflow)
749 python generate_slide_image_ai.py "Neural network architecture diagram with input, hidden, and output layers" -o figure.png --visual-only
751 # With reference images attached (Nano Banana 2 will see these)
752 python generate_slide_image_ai.py "Create a slide explaining this chart with key insights" -o slide.png --attach chart.png
753 python generate_slide_image_ai.py "Combine these images into a comparison slide" -o compare.png --attach before.png --attach after.png
755 # With custom iterations
756 python generate_slide_image_ai.py "Title slide for AI Conference 2025" -o title.png --iterations 2
758 # Verbose output
759 python generate_slide_image_ai.py "Data flow diagram" -o flow.png -v
761Environment:
762 OPENROUTER_API_KEY OpenRouter API key (required)
763 """
764 )
766 parser.add_argument("prompt", help="Description of the slide or visual to generate")
767 parser.add_argument("-o", "--output", required=True, help="Output image path")
768 parser.add_argument("--attach", action="append", dest="attachments", metavar="IMAGE",
769 help="Attach image file(s) as context for generation (can use multiple times)")
770 parser.add_argument("--visual-only", action="store_true",
771 help="Generate just the visual/figure (for PPT workflow)")
772 parser.add_argument("--iterations", type=int, default=2,
773 help="Maximum refinement iterations (default: 2)")
774 parser.add_argument("--api-key", help="OpenRouter API key (or set OPENROUTER_API_KEY)")
775 parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
777 args = parser.parse_args()
779 # Resolves --api-key, the environment, then any .env file
780 api_key = _resolve_api_key(args.api_key)
781 if not api_key:
782 print("Error: OPENROUTER_API_KEY not found")
783 print("\nSet it with:")
784 print(" export OPENROUTER_API_KEY='your_api_key'")
785 print("\nOr add OPENROUTER_API_KEY=your_api_key to a .env file")
786 print("Or provide via --api-key flag")
787 sys.exit(1)
789 if args.iterations < 1 or args.iterations > 2:
790 print("Error: Iterations must be between 1 and 2")
791 sys.exit(1)
793 # Validate attachments exist
794 if args.attachments:
795 for att in args.attachments:
796 if not Path(att).exists():
797 print(f"Error: Attachment file not found: {att}")
798 sys.exit(1)
800 try:
801 generator = SlideImageGenerator(api_key=api_key, verbose=args.verbose)
802 results = generator.generate_slide(
803 user_prompt=args.prompt,
804 output_path=args.output,
805 visual_only=args.visual_only,
806 iterations=args.iterations,
807 attachments=args.attachments
808 )
810 if results["success"]:
811 print(f"\n[OK] Success! Image saved to: {args.output}")
812 if not results["quality_met"]:
813 print("[WARN] Quality criteria not verified or not met; inspect the saved draft.")
814 sys.exit(0)
815 else:
816 print(f"\n[FAIL] Generation failed. Check review log for details.")
817 sys.exit(1)
818 except Exception as e:
819 print(f"\n[FAIL] Error: {str(e)}")
820 sys.exit(1)
823if __name__ == "__main__":
824 main()