Module 7: Use Cases
3. Automated Image Analysis
Description
Analyzing one image is useful. Analyzing 500 images with classification, data extraction, and quality control is a production system. In this capsule you build automated image analysis pipelines: batch processing, category classification, structured attribute extraction, and visual quality control.
Why it matters: The most profitable use cases of vision in production are automations: an e-commerce that classifies 1000 products a day, a quality control system that reviews manufacturing photos, a real-estate platform that extracts features from property photos. All of them require batch processing, error handling, and structured results.
Connection with the module: This pattern is one of the destinations of the Use Case Selector (capsule 08). When the router detects that the input is an image, it applies the analysis pipeline you build here. The batch and retry patterns you implement are reused in capsule 06 (Production Patterns).
Visual Pipeline
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ List of │────▶│ Validate │────▶│ Process │
│ images │ │ and prepare │ │ in batch │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Results │◀────│ Aggregate │◀────│ Vision API │
│ (JSON/CSV) │ │ results │ │ per image │
└──────────────┘ └──────────────┘ └──────────────┘
Stages:
- Receive a list of images — Local paths or URLs
- Validate — Check that they exist, supported format, reasonable size
- Prepare — Resize if needed, convert to base64
- Process in batch — Send each image to the Vision API (with controlled parallelism)
- Aggregate results — Combine into a unified structure
- Export — JSON, CSV, or database
Step 1: Validation and Preparation
from pathlib import Path
from PIL import Image
import base64
import io
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
MAX_SIZE_MB = 20
MAX_DIMENSION = 4096
def validate_image(path: str) -> dict:
p = Path(path)
if not p.exists():
return {"valid": False, "error": "File not found"}
if p.suffix.lower() not in SUPPORTED_FORMATS:
return {"valid": False, "error": f"Unsupported format: {p.suffix}"}
size_mb = p.stat().st_size / (1024 * 1024)
if size_mb > MAX_SIZE_MB:
return {"valid": False, "error": f"File too large: {size_mb:.1f}MB"}
try:
img = Image.open(path)
width, height = img.size
img.close()
except Exception as e:
return {"valid": False, "error": f"Can't open: {e}"}
return {
"valid": True,
"path": path,
"format": p.suffix.lower(),
"size_mb": round(size_mb, 2),
"dimensions": (width, height)
}
def validate_batch(paths: list[str]) -> dict:
valid = []
invalid = []
for path in paths:
result = validate_image(path)
if result["valid"]:
valid.append(result)
else:
invalid.append({"path": path, **result})
return {
"valid": valid,
"invalid": invalid,
"total": len(paths),
"valid_count": len(valid),
"invalid_count": len(invalid)
}
Prepare an image for the API
def prepare_image(path: str, max_dimension: int = 2048) -> str:
img = Image.open(path)
if max(img.size) > max_dimension:
ratio = max_dimension / max(img.size)
new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
img = img.resize(new_size, Image.LANCZOS)
if img.mode == "RGBA":
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85)
img.close()
return base64.b64encode(buffer.getvalue()).decode()
Step 2: Batch Classification
Simple classifier
from openai import OpenAI
client = OpenAI()
def classify_image(image_b64: str, categories: list[str]) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": f"Classify this image into ONE of these categories: {', '.join(categories)}.\nReply ONLY with the category name, nothing else."
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}],
max_tokens=20,
temperature=0
)
return response.choices[0].message.content.strip()
def classify_batch(
image_paths: list[str],
categories: list[str]
) -> list[dict]:
results = []
for path in image_paths:
try:
b64 = prepare_image(path)
category = classify_image(b64, categories)
results.append({
"path": path,
"category": category,
"status": "success"
})
except Exception as e:
results.append({
"path": path,
"category": None,
"status": "error",
"error": str(e)
})
return results
Classifier with confidence
import json
def classify_with_confidence(image_b64: str, categories: list[str]) -> dict:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
f"Classify this image into one of: {', '.join(categories)}.\n"
"Reply in JSON: {\"category\": \"...\", \"confidence\": 0.0-1.0, \"reasoning\": \"...\"}"
)
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}],
max_tokens=100,
temperature=0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
Step 3: Structured Extraction
Product attribute extractor
def extract_product_attributes(image_b64: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Analyze this product and extract attributes in JSON:\n"
"{\n"
" \"product_type\": \"product type\",\n"
" \"color_primary\": \"primary color\",\n"
" \"color_secondary\": \"secondary color or null\",\n"
" \"material\": \"visible material\",\n"
" \"condition\": \"new/used/damaged\",\n"
" \"brand_visible\": \"visible brand or null\",\n"
" \"text_visible\": \"legible text or null\",\n"
" \"description\": \"description in 1 sentence\"\n"
"}"
)
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}],
max_tokens=300,
temperature=0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
def extract_batch_products(image_paths: list[str]) -> list[dict]:
results = []
for path in image_paths:
try:
b64 = prepare_image(path)
attributes = extract_product_attributes(b64)
results.append({
"path": path,
"attributes": attributes,
"status": "success"
})
except Exception as e:
results.append({
"path": path,
"attributes": None,
"status": "error",
"error": str(e)
})
return results
Generic extractor with a configurable schema
def extract_with_schema(image_b64: str, schema: dict) -> dict:
schema_str = json.dumps(schema, indent=2, ensure_ascii=False)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": f"Extract data from this image following this JSON schema:\n{schema_str}\n\nReply ONLY with the JSON."
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}],
max_tokens=500,
temperature=0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
Step 4: Product Catalog Pipeline
import csv
from datetime import datetime
class ProductCatalogAnalyzer:
def __init__(self, categories: list[str]):
self.categories = categories
self.results: list[dict] = []
def analyze(self, image_paths: list[str]) -> list[dict]:
validation = validate_batch(image_paths)
if validation["invalid"]:
print(f"Invalid images: {validation['invalid_count']}")
for inv in validation["invalid"]:
self.results.append({
"path": inv["path"],
"status": "invalid",
"error": inv["error"]
})
for img_info in validation["valid"]:
path = img_info["path"]
try:
b64 = prepare_image(path)
classification = classify_with_confidence(b64, self.categories)
attributes = extract_product_attributes(b64)
self.results.append({
"path": path,
"status": "success",
"category": classification["category"],
"confidence": classification["confidence"],
"attributes": attributes,
"dimensions": img_info["dimensions"],
"size_mb": img_info["size_mb"]
})
except Exception as e:
self.results.append({
"path": path,
"status": "error",
"error": str(e)
})
return self.results
def export_csv(self, output_path: str) -> str:
successful = [r for r in self.results if r["status"] == "success"]
if not successful:
return "No results to export."
fieldnames = ["path", "category", "confidence", "product_type",
"color_primary", "material", "condition", "description"]
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for r in successful:
row = {
"path": r["path"],
"category": r["category"],
"confidence": r["confidence"]
}
attrs = r.get("attributes", {})
row.update({
"product_type": attrs.get("product_type", ""),
"color_primary": attrs.get("color_primary", ""),
"material": attrs.get("material", ""),
"condition": attrs.get("condition", ""),
"description": attrs.get("description", "")
})
writer.writerow(row)
return output_path
def summary(self) -> dict:
total = len(self.results)
success = sum(1 for r in self.results if r["status"] == "success")
errors = sum(1 for r in self.results if r["status"] == "error")
invalid = sum(1 for r in self.results if r["status"] == "invalid")
category_counts = {}
for r in self.results:
if r["status"] == "success":
cat = r["category"]
category_counts[cat] = category_counts.get(cat, 0) + 1
return {
"total": total,
"success": success,
"errors": errors,
"invalid": invalid,
"categories": category_counts,
"success_rate": round(success / total * 100, 1) if total > 0 else 0
}
Usage:
analyzer = ProductCatalogAnalyzer(
categories=["electronics", "clothing", "home", "sports", "food"]
)
results = analyzer.analyze([
"products/img_001.jpg",
"products/img_002.jpg",
"products/img_003.jpg"
])
print(analyzer.summary())
analyzer.export_csv("analyzed_catalog.csv")
Step 5: Quality Control System
class QualityControlSystem:
def __init__(self, criteria: dict = None):
self.criteria = criteria or {
"min_resolution": (800, 600),
"check_blur": True,
"check_lighting": True,
"check_composition": True
}
def check_image(self, image_path: str) -> dict:
validation = validate_image(image_path)
if not validation["valid"]:
return {"path": image_path, "pass": False, "issues": [validation["error"]]}
issues = []
w, h = validation["dimensions"]
min_w, min_h = self.criteria["min_resolution"]
if w < min_w or h < min_h:
issues.append(f"Insufficient resolution: {w}x{h}, minimum {min_w}x{min_h}")
b64 = prepare_image(image_path)
quality_check = self._check_quality_with_vision(b64)
issues.extend(quality_check.get("issues", []))
return {
"path": image_path,
"pass": len(issues) == 0,
"issues": issues,
"quality_score": quality_check.get("score", 0),
"details": quality_check
}
def _check_quality_with_vision(self, image_b64: str) -> dict:
checks = []
if self.criteria.get("check_blur"):
checks.append("blur (is it blurry?)")
if self.criteria.get("check_lighting"):
checks.append("lighting (too dark/bright?)")
if self.criteria.get("check_composition"):
checks.append("composition (is the subject centered and complete?)")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Evaluate the quality of this image for a product catalog.\n"
f"Check: {', '.join(checks)}.\n"
"Reply in JSON:\n"
"{\n"
" \"score\": 1-10,\n"
" \"issues\": [\"list of problems found\"],\n"
" \"blur\": \"ok/issue\",\n"
" \"lighting\": \"ok/issue\",\n"
" \"composition\": \"ok/issue\",\n"
" \"recommendation\": \"approved/reshoot\"\n"
"}"
)
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}],
max_tokens=200,
temperature=0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
def check_batch(self, image_paths: list[str]) -> dict:
results = []
for path in image_paths:
result = self.check_image(path)
results.append(result)
passed = sum(1 for r in results if r["pass"])
failed = len(results) - passed
return {
"results": results,
"total": len(results),
"passed": passed,
"failed": failed,
"pass_rate": round(passed / len(results) * 100, 1) if results else 0
}
Parallel Processing
To process hundreds of images, use controlled parallelism:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def process_batch_parallel(
image_paths: list[str],
process_fn,
max_workers: int = 3,
delay_between: float = 0.5
) -> list[dict]:
results = []
def process_one(path: str) -> dict:
time.sleep(delay_between)
try:
return {"path": path, "result": process_fn(path), "status": "success"}
except Exception as e:
return {"path": path, "result": None, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(process_one, path): path for path in image_paths}
for future in as_completed(futures):
results.append(future.result())
return sorted(results, key=lambda r: image_paths.index(r["path"]))
With automatic retry
def process_with_retry(
path: str,
process_fn,
max_retries: int = 3
) -> dict:
for attempt in range(max_retries):
try:
result = process_fn(path)
return {"path": path, "result": result, "status": "success", "attempts": attempt + 1}
except Exception as e:
if attempt < max_retries - 1:
wait = 2 ** attempt
time.sleep(wait)
continue
return {
"path": path,
"result": None,
"status": "error",
"error": str(e),
"attempts": max_retries
}
Troubleshooting
Problem 1: Rate limit with many images
Symptom: Error 429 after processing 20-30 images.
Cause: The Vision API has rate limits per minute (RPM) and per tokens (TPM).
Solution:
import time
def classify_batch_rate_limited(
paths: list[str],
categories: list[str],
requests_per_minute: int = 30
) -> list[dict]:
delay = 60.0 / requests_per_minute
results = []
for i, path in enumerate(paths):
if i > 0:
time.sleep(delay)
try:
b64 = prepare_image(path)
category = classify_image(b64, categories)
results.append({"path": path, "category": category, "status": "success"})
except Exception as e:
if "rate" in str(e).lower():
time.sleep(10)
try:
b64 = prepare_image(path)
category = classify_image(b64, categories)
results.append({"path": path, "category": category, "status": "success"})
except Exception as e2:
results.append({"path": path, "category": None, "status": "error", "error": str(e2)})
else:
results.append({"path": path, "category": None, "status": "error", "error": str(e)})
return results
Problem 2: Inconsistent classification
Symptom: The same image is classified differently in successive calls.
Solution: Use temperature=0 and a more restrictive prompt:
prompt = (
f"Classify this image into EXACTLY ONE category from this list: {categories_str}.\n"
"Rules:\n"
"- Reply ONLY with the exact name of the category\n"
"- Do NOT add explanation or punctuation\n"
"- If it doesn't fit any, choose the closest one"
)
Problem 3: Very heavy images
Symptom: Slow calls or timeout on images of 10+ MB.
Solution: Resize before sending (already included in prepare_image). To reduce even further:
def prepare_image_low_cost(path: str) -> str:
img = Image.open(path)
img = img.resize((512, 512), Image.LANCZOS)
if img.mode == "RGBA":
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=70)
img.close()
return base64.b64encode(buffer.getvalue()).decode()
Problem 4: Malformed JSON in extraction
Symptom: json.loads fails to parse the response.
Solution: Use response_format={"type": "json_object"} (already included in the examples). As a fallback:
def safe_json_parse(text: str) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
try:
return json.loads(text[start:end])
except json.JSONDecodeError:
pass
return {"raw_response": text, "parse_error": True}
Exercises
Exercise 1: Image comparator
Create a function that takes two images of the same product and determines whether they're the same item, comparing extracted attributes.
See solution
def compare_product_images(path_a: str, path_b: str) -> dict:
b64_a = prepare_image(path_a)
b64_b = prepare_image(path_b)
attrs_a = extract_product_attributes(b64_a)
attrs_b = extract_product_attributes(b64_b)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Compare these two product images.\n"
"Reply in JSON: {\"same_product\": true/false, "
"\"similarity_score\": 0.0-1.0, \"differences\": [\"...\"]}"
)
},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_a}"}},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_b}"}}
]
}],
max_tokens=200,
temperature=0,
response_format={"type": "json_object"}
)
comparison = json.loads(response.choices[0].message.content)
comparison["attributes_a"] = attrs_a
comparison["attributes_b"] = attrs_b
return comparison
Exercise 2: Pipeline with progress and logging
Modify ProductCatalogAnalyzer.analyze so it prints progress (X/N processed) and logs timing per image.
See solution
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("catalog_analyzer")
def analyze_with_progress(self, image_paths: list[str]) -> list[dict]:
validation = validate_batch(image_paths)
total = validation["valid_count"]
processed = 0
for img_info in validation["valid"]:
path = img_info["path"]
start = time.time()
processed += 1
try:
b64 = prepare_image(path)
classification = classify_with_confidence(b64, self.categories)
attributes = extract_product_attributes(b64)
elapsed = time.time() - start
self.results.append({
"path": path,
"status": "success",
"category": classification["category"],
"confidence": classification["confidence"],
"attributes": attributes,
"processing_time": round(elapsed, 2)
})
logger.info(f"[{processed}/{total}] {path} → {classification['category']} ({elapsed:.1f}s)")
except Exception as e:
elapsed = time.time() - start
self.results.append({
"path": path,
"status": "error",
"error": str(e),
"processing_time": round(elapsed, 2)
})
logger.error(f"[{processed}/{total}] {path} → ERROR: {e} ({elapsed:.1f}s)")
return self.results
Exercise 3: Multi-provider with fallback
Implement classification that tries GPT-4o-mini first, and if it fails, uses Claude 3 as a fallback.
See solution
import anthropic
def classify_with_fallback(image_path: str, categories: list[str]) -> dict:
b64 = prepare_image(image_path)
try:
category = classify_image(b64, categories)
return {"category": category, "provider": "openai", "status": "success"}
except Exception as openai_error:
pass
try:
claude = anthropic.Anthropic()
response = claude.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=50,
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": f"Classify this image into ONE of: {', '.join(categories)}. Only the name."
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": b64
}
}
]
}]
)
return {
"category": response.content[0].text.strip(),
"provider": "anthropic",
"status": "success"
}
except Exception as claude_error:
return {
"category": None,
"provider": None,
"status": "error",
"errors": {
"openai": str(openai_error),
"anthropic": str(claude_error)
}
}
Additional Resources
- OpenAI Vision Guide — Official Vision guide
- OpenAI Batch API — Batch processing
- Pillow Documentation — Image processing in Python
- Anthropic Vision — Vision with Claude