Batch Image Generation Pipeline for Agents
Step-by-step tutorial for building a batch image generation pipeline — agent takes product descriptions, generates variants, scores them, picks the best. Python code with parallel generation, error handling, and cost tracking.
TL;DR — This tutorial builds a complete batch image generation pipeline: an agent takes 100 product descriptions, generates 5 image variants each (500 images), scores them with a vision model, and picks the best. Full Python code using the OpenAI SDK on SandBase, with parallel execution, error handling, retry logic, and cost tracking. Estimated cost: $7.50–$20.00 depending on model choice.
What we’re building
A production-grade image generation pipeline for e-commerce that:
- Takes product descriptions as input (name, category, key features)
- Generates 5 image variants per product using different prompts/angles
- Scores each variant using a vision model for quality and relevance
- Selects the best variant per product
- Tracks costs, handles failures, and logs everything
This pattern applies to any batch generation workflow — marketing campaigns, social media content calendars, catalog refreshes, A/B test asset creation.
For model selection guidance, see our best AI image generation APIs guide. For a similar pattern applied to video, see our video generation agent tutorial.
Architecture overview
┌─────────────────────────────────────────────────────────┐
│ Batch Pipeline │
├─────────────────────────────────────────────────────────┤
│ Input: 100 product descriptions │
│ ↓ │
│ Prompt Generator (5 prompts per product = 500 prompts) │
│ ↓ │
│ Parallel Image Generation (batch of 10 concurrent) │
│ ↓ │
│ Quality Scorer (vision model evaluation) │
│ ↓ │
│ Selector (pick best per product) │
│ ↓ │
│ Output: 100 best images + metadata │
└─────────────────────────────────────────────────────────┘
Prerequisites
pip install openai aiohttp asyncio aiofiles tenacity pydantic
Step 1: Define the data model
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class Product(BaseModel):
id: str
name: str
category: str
features: list[str]
style_preference: Optional[str] = None
class ImageVariant(BaseModel):
product_id: str
variant_index: int
prompt: str
image_url: Optional[str] = None
image_b64: Optional[str] = None
score: Optional[float] = None
error: Optional[str] = None
latency_ms: int = 0
cost: float = 0.0
class PipelineResult(BaseModel):
product_id: str
best_variant: Optional[ImageVariant] = None
all_variants: list[ImageVariant] = []
total_cost: float = 0.0
total_time_ms: int = 0
Step 2: Prompt generation
Each product gets 5 different prompts to maximize variety:
class PromptGenerator:
"""Generate diverse prompts for each product."""
ANGLES = [
("hero", "clean white background, studio lighting, centered product, "
"e-commerce hero shot, ultra-sharp detail"),
("lifestyle", "product in use, natural environment, lifestyle photography, "
"warm lighting, realistic setting"),
("detail", "macro close-up, showing texture and material quality, "
"shallow depth of field, studio lighting"),
("context", "product on a styled surface with complementary props, "
"flat lay or shelf arrangement, editorial style"),
("dramatic", "dramatic lighting, dark background with rim light, "
"premium feel, high contrast, luxury presentation"),
]
def generate_prompts(self, product: Product) -> list[str]:
"""Generate 5 diverse prompts for one product."""
prompts = []
features_text = ", ".join(product.features[:3])
for angle_name, angle_style in self.ANGLES:
prompt = (
f"{product.name}, {features_text}, "
f"{angle_style}, "
f"professional product photography, 8K quality"
)
if product.style_preference:
prompt += f", {product.style_preference}"
prompts.append(prompt)
return prompts
Step 3: Parallel image generation with error handling
import asyncio
import time
import aiohttp
from tenacity import retry, stop_after_attempt, wait_exponential
class BatchImageGenerator:
"""Generate images in parallel with rate limiting and error handling."""
def __init__(
self,
api_key: str,
model: str = "bytedance/seedream/5.0/pro/fast",
max_concurrent: int = 10,
cost_per_image: float = 0.015
):
self.api_key = api_key
self.base_url = "https://api.sandbase.ai/v1"
self.model = model
self.semaphore = asyncio.Semaphore(max_concurrent)
self.cost_per_image = cost_per_image
self.total_cost = 0.0
self.success_count = 0
self.failure_count = 0
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=30)
)
async def _generate_single(self, prompt: str) -> dict:
"""Generate a single image with retry logic (submit + poll)."""
async with self.semaphore:
start = time.time()
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
# Submit generation task
async with session.post(
f"{self.base_url}/run",
headers=headers,
json={"model": self.model, "prompt": prompt},
) as resp:
submit = await resp.json()
task_id = submit["id"]
# Poll for completion
poll_headers = {"Authorization": f"Bearer {self.api_key}"}
while True:
async with session.get(
f"{self.base_url}/generations/{task_id}",
headers=poll_headers,
) as resp:
result = await resp.json()
if result["status"] in ("completed", "failed", "timeout"):
break
await asyncio.sleep(2)
latency = int((time.time() - start) * 1000)
return {
"url": result["outputs"][0]["url"],
"latency_ms": latency
}
async def generate_variant(
self, product_id: str, variant_index: int, prompt: str
) -> ImageVariant:
"""Generate one image variant, handling errors gracefully."""
variant = ImageVariant(
product_id=product_id,
variant_index=variant_index,
prompt=prompt
)
try:
result = await self._generate_single(prompt)
variant.image_url = result["url"]
variant.latency_ms = result["latency_ms"]
variant.cost = self.cost_per_image
self.total_cost += self.cost_per_image
self.success_count += 1
except Exception as e:
variant.error = str(e)
self.failure_count += 1
return variant
async def generate_batch(
self, products: list[Product], prompts_per_product: dict[str, list[str]]
) -> list[PipelineResult]:
"""Generate all variants for all products in parallel."""
all_tasks = []
for product in products:
prompts = prompts_per_product[product.id]
for i, prompt in enumerate(prompts):
task = self.generate_variant(product.id, i, prompt)
all_tasks.append((product.id, task))
# Execute all tasks with concurrency control
results_by_product: dict[str, list[ImageVariant]] = {}
tasks = [task for _, task in all_tasks]
product_ids = [pid for pid, _ in all_tasks]
variants = await asyncio.gather(*tasks)
for pid, variant in zip(product_ids, variants):
if pid not in results_by_product:
results_by_product[pid] = []
results_by_product[pid].append(variant)
# Build results
pipeline_results = []
for product in products:
variants = results_by_product.get(product.id, [])
result = PipelineResult(
product_id=product.id,
all_variants=variants,
total_cost=sum(v.cost for v in variants),
total_time_ms=max((v.latency_ms for v in variants), default=0)
)
pipeline_results.append(result)
return pipeline_results
def report(self):
"""Print generation statistics."""
total = self.success_count + self.failure_count
print(f"Generated: {self.success_count}/{total} images")
print(f"Failed: {self.failure_count}/{total}")
print(f"Total cost: ${self.total_cost:.2f}")
print(f"Success rate: {self.success_count/max(total,1)*100:.1f}%")
Step 4: Quality scoring with vision model
from openai import AsyncOpenAI
class ImageScorer:
"""Score images using a vision model for quality and relevance."""
def __init__(self, api_key: str):
self.client = AsyncOpenAI(
base_url="https://api.sandbase.ai/v1",
api_key=api_key
)
async def score_variant(
self, variant: ImageVariant, product: Product
) -> float:
"""Score an image variant 0-1 for quality and product relevance."""
if not variant.image_url:
return 0.0
scoring_prompt = f"""Rate this product image on a scale of 0 to 100.
Product: {product.name}
Category: {product.category}
Key features that should be visible: {', '.join(product.features)}
Scoring criteria:
- Image quality and sharpness (25 points)
- Product visibility and focus (25 points)
- Professional composition (25 points)
- Relevance to product description (25 points)
Return ONLY a number between 0 and 100."""
try:
response = await self.client.chat.completions.create(
model="openai/gpt-4o-mini", # Cost-effective for scoring
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": scoring_prompt},
{"type": "image_url", "image_url": {"url": variant.image_url}}
]
}
],
max_tokens=10
)
score_text = response.choices[0].message.content.strip()
score = float(score_text) / 100.0
return min(max(score, 0.0), 1.0)
except Exception:
return 0.5 # Default score on error
async def score_all(
self, results: list[PipelineResult], products: dict[str, Product]
) -> list[PipelineResult]:
"""Score all variants and select the best per product."""
scoring_tasks = []
for result in results:
product = products[result.product_id]
for variant in result.all_variants:
if variant.image_url:
scoring_tasks.append(
self._score_and_assign(variant, product)
)
await asyncio.gather(*scoring_tasks)
# Select best variant per product
for result in results:
scored_variants = [v for v in result.all_variants if v.score is not None]
if scored_variants:
result.best_variant = max(scored_variants, key=lambda v: v.score)
return results
async def _score_and_assign(self, variant: ImageVariant, product: Product):
variant.score = await self.score_variant(variant, product)
Step 5: Putting it all together
import json
async def run_batch_pipeline(
products: list[Product],
api_key: str,
model: str = "bytedance/seedream/5.0/pro/fast",
max_concurrent: int = 10,
):
"""Run the complete batch image generation pipeline."""
print(f"Starting batch pipeline: {len(products)} products × 5 variants = "
f"{len(products) * 5} images")
print(f"Model: {model}")
print(f"Concurrency: {max_concurrent}")
print("=" * 60)
# Phase 1: Generate prompts
prompt_gen = PromptGenerator()
prompts_per_product = {}
for product in products:
prompts_per_product[product.id] = prompt_gen.generate_prompts(product)
print(f"Phase 1: Generated {sum(len(p) for p in prompts_per_product.values())} prompts")
# Phase 2: Generate images
generator = BatchImageGenerator(
api_key=api_key,
model=model,
max_concurrent=max_concurrent
)
results = await generator.generate_batch(products, prompts_per_product)
print(f"Phase 2: Image generation complete")
generator.report()
# Phase 3: Score and select
scorer = ImageScorer(api_key=api_key)
products_dict = {p.id: p for p in products}
results = await scorer.score_all(results, products_dict)
# Phase 4: Report
selected_count = sum(1 for r in results if r.best_variant)
total_cost = sum(r.total_cost for r in results)
print(f"\nPhase 3: Scoring complete")
print(f"Products with selected images: {selected_count}/{len(products)}")
print(f"Total pipeline cost: ${total_cost:.2f}")
# Save results
output = []
for result in results:
output.append({
"product_id": result.product_id,
"best_image": result.best_variant.image_url if result.best_variant else None,
"best_score": result.best_variant.score if result.best_variant else None,
"variants_generated": len(result.all_variants),
"cost": result.total_cost
})
with open("pipeline_results.json", "w") as f:
json.dump(output, f, indent=2)
return results
# Example usage
if __name__ == "__main__":
# Sample products
products = [
Product(
id=f"prod_{i:03d}",
name=f"Premium Wireless Earbuds Model {i}",
category="electronics",
features=["noise cancellation", "30h battery", "IPX5 waterproof"],
style_preference="modern minimalist"
)
for i in range(100)
]
results = asyncio.run(run_batch_pipeline(
products=products,
api_key="your-sandbase-api-key",
model="bytedance/seedream/5.0/pro/fast",
max_concurrent=10
))
Cost estimation
By model choice (100 products × 5 variants = 500 images)
| Model | Per image | 500 images | + Scoring (500 calls) | Total |
|---|---|---|---|---|
| Nano Banana Lite | $0.008 | $4.00 | ~$1.50 | $5.50 |
| Nano Banana 2 Lite | $0.01 | $5.00 | ~$1.50 | $6.50 |
| Seedream Fast | $0.015 | $7.50 | ~$1.50 | $9.00 |
| Qwen-Image-3 | $0.03 | $15.00 | ~$1.50 | $16.50 |
| Seedream Pro | $0.04 | $20.00 | ~$1.50 | $21.50 |
Time estimation (sequential vs parallel)
| Model | Sequential (500 images) | Parallel (10 concurrent) |
|---|---|---|
| Nano Banana Lite | ~17 min | ~2 min |
| Seedream Fast | ~25 min | ~3 min |
| Qwen-Image-3 | ~58 min | ~6 min |
| Seedream Pro | ~83 min | ~9 min |
With 10 concurrent requests, the entire 500-image pipeline completes in 2–9 minutes depending on model choice.
Advanced: hybrid model strategy
Use different models for exploration vs. final selection:
async def hybrid_pipeline(products: list[Product], api_key: str):
"""Two-phase pipeline: Fast exploration, Pro refinement."""
# Phase A: Generate all 500 with Fast ($7.50)
fast_results = await run_batch_pipeline(
products=products,
api_key=api_key,
model="bytedance/seedream/5.0/pro/fast",
max_concurrent=10
)
# Phase B: Regenerate top variant prompts with Pro ($4.00 for 100 images)
pro_generator = BatchImageGenerator(
api_key=api_key,
model="bytedance/seedream/5.0/pro",
max_concurrent=5
)
for result in fast_results:
if result.best_variant:
# Regenerate the winning prompt with Pro quality
pro_variant = await pro_generator.generate_variant(
result.product_id,
variant_index=99, # Mark as Pro version
prompt=result.best_variant.prompt
)
if pro_variant.image_url:
result.best_variant = pro_variant
# Total cost: $7.50 (Fast exploration) + $4.00 (Pro finals) = $11.50
# vs. all-Pro: $21.50 — saving 46% while getting Pro quality on finals
return fast_results
Error handling patterns
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from openai import RateLimitError, APITimeoutError, APIConnectionError
# Retry configuration for production
RETRY_CONFIG = {
"stop": stop_after_attempt(3),
"wait": wait_exponential(multiplier=1, min=2, max=60),
"retry": retry_if_exception_type((RateLimitError, APITimeoutError, APIConnectionError)),
}
# Dead letter queue for persistent failures
class DeadLetterQueue:
def __init__(self):
self.failed_items = []
def add(self, product_id: str, prompt: str, error: str):
self.failed_items.append({
"product_id": product_id,
"prompt": prompt,
"error": error,
"timestamp": datetime.now().isoformat()
})
def retry_all(self):
"""Retry all failed items in next pipeline run."""
items = self.failed_items.copy()
self.failed_items.clear()
return items
Monitoring and observability
class PipelineMetrics:
"""Track pipeline performance metrics."""
def __init__(self):
self.start_time = time.time()
self.images_generated = 0
self.images_failed = 0
self.total_cost = 0.0
self.latencies: list[int] = []
def record_success(self, latency_ms: int, cost: float):
self.images_generated += 1
self.total_cost += cost
self.latencies.append(latency_ms)
def record_failure(self):
self.images_failed += 1
def summary(self) -> dict:
elapsed = time.time() - self.start_time
return {
"duration_seconds": round(elapsed, 1),
"images_generated": self.images_generated,
"images_failed": self.images_failed,
"success_rate": f"{self.images_generated / max(self.images_generated + self.images_failed, 1) * 100:.1f}%",
"total_cost": f"${self.total_cost:.2f}",
"avg_latency_ms": round(sum(self.latencies) / max(len(self.latencies), 1)),
"p95_latency_ms": sorted(self.latencies)[int(len(self.latencies) * 0.95)] if self.latencies else 0,
"images_per_second": round(self.images_generated / max(elapsed, 1), 2),
}
Related Reading
- Best AI Image Generation APIs in 2026
- Seedream vs Qwen-Image-3 vs Nano Banana (2026)
- Seedream 5.0 Pro: ByteDance’s Image Generator
- Qwen-Image-3: Generation + Edit in One Model
- Best AI Image Editing APIs for Agents (2026)
- Per-Call vs Token Pricing: Which Works for Agents
Conclusion
Batch image generation for agents follows a consistent pattern: generate many, score automatically, keep the best. The key engineering decisions are:
- Model choice — Seedream Fast offers the best quality-per-dollar for batch operations
- Concurrency — 10 parallel requests is a good default; adjust based on rate limits
- Error handling — Retry with exponential backoff, dead letter queue for persistent failures
- Cost control — Track spend per product, set budgets, use hybrid strategies
The complete pipeline handles 100 products (500 images) in under 10 minutes for $9–$21 depending on quality requirements. Scale linearly to thousands of products by increasing concurrency or running multiple batches.


