Batch Image Descriptions to HTML Table

Let’s build something real. You have a folder full of images. You want:

  1. An AI-generated description for each one
  2. An HTML page with thumbnails, file links, and descriptions all in a table

This is the kind of task where calling LLMs from Python shines — doing it manually would take ages.


Why Prompt Quality Matters Here

When you ask an LLM to describe a single image, a simple prompt works fine. But when you’re batch-processing dozens of images, the consistency of the output format is critical. You want every description to follow the same structure so your HTML table looks uniform.

A weak prompt like “Describe this image” gives you inconsistent output — some descriptions are one sentence, others are paragraphs, some are poetic, others clinical.

A well-crafted prompt gives you clean, structured output every time.

:bulb: The prompt is the hardest-working line of code in this project.


The Script

import os
import base64
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ["OPENROUTER_API_KEY"],
)

# --- CONFIGURATION ---
IMAGE_DIR = "./images"       # folder with your images
OUTPUT_HTML = "gallery.html" # output file
MODEL = "openai/gpt-4o"     # must be a vision-capable model

# --- THE PROMPT ---
# This is where the quality lives. Notice: structured output format,
# length constraint, style guidance — all in one prompt.

SYSTEM_PROMPT = """You are a scientific image archivist. Your task is to write
concise, objective descriptions of images for a research gallery.

For each image, provide exactly this JSON structure:
{
  "filename": "the filename",
  "title": "A short descriptive title (max 60 characters)",
  "description": "A factual, neutral description (2-3 sentences). Describe what
    is visible: subject, setting, key visual elements, colours, composition.
    Do not interpret or evaluate — describe what you see.",
  "category": "one of: microscopy, field_photo, diagram, equipment, gel_blot,
    graph, portrait, other"
}

Rules:
- Be factual, not poetic
- Describe the visual content, not what it "means"
- Max 80 words for the description
- If the image is unclear, say so honestly
"""

# --- IMAGE ENCODING ---
def encode_image(path):
    """Read an image file and encode it as base64."""
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

# --- PROCESS ONE IMAGE ---
def describe_image(image_path):
    """Send an image to the LLM and get back a structured description."""
    ext = image_path.suffix.lower()
    mime_map = {
        ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
        ".png": "image/png", ".gif": "image/gif",
        ".webp": "image/webp",
    }
    mime_type = mime_map.get(ext, "image/jpeg")

    b64 = encode_image(image_path)

    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": f"Describe this image. Filename: {image_path.name}"
                    },
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:{mime_type};base64,{b64}"
                        },
                    },
                ],
            },
        ],
        response_format={"type": "json_object"},
    )

    return response.choices[0].message.content

# --- BUILD HTML ---
def build_html(descriptions):
    """Generate an HTML table from the image descriptions."""
    rows = ""
    for desc in descriptions:
        rows += f"""<tr>
            <td><img src="{desc['filename']}" width="150" loading="lazy"></td>
            <td><a href="{desc['filename']}">{desc['filename']}</a></td>
            <td><strong>{desc['title']}</strong></td>
            <td>{desc['description']}</td>
            <td><code>{desc['category']}</code></td>
        </tr>\n"""

    return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Image Gallery</title>
<style>
  body 
  table 
  th, td 
  th 
  img 
  tr:hover 
</style></head>
<body>
<h1>Image Gallery</h1>
<table>
<tr><th>Thumbnail</th><th>File</th><th>Title</th><th>Description</th><th>Category</th></tr>
{rows}
</table>
</body></html>"""

# --- MAIN ---
def main():
    import json

    image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
    images = sorted([
        p for p in Path(IMAGE_DIR).iterdir()
        if p.suffix.lower() in image_extensions
    ])

    if not images:
        print(f"No images found in {IMAGE_DIR}")
        return

    print(f"Found {len(images)} images. Processing...")

    results = []
    for i, img in enumerate(images, 1):
        print(f"  [{i}/{len(images)}] {img.name} ...", end=" ")
        try:
            raw = describe_image(img)
            desc = json.loads(raw)
            results.append(desc)
            print("OK")
        except Exception as e:
            print(f"FAILED: {e}")
            results.append({
                "filename": img.name,
                "title": "ERROR",
                "description": str(e),
                "category": "other",
            })

    html = build_html(results)
    with open(OUTPUT_HTML, "w") as f:
        f.write(html)

    print(f"\nDone! Open {OUTPUT_HTML} in your browser.")

if __name__ == "__main__":
    main()

:bulb: Note that the script here privileges simplicity, but it has some bugs related to the image paths… can you fix it?


What makes this prompt decent?

Let’s break down the SYSTEM_PROMPT:

Element What it does
Role assignment (“scientific image archivist”) Sets tone and vocabulary level
Exact output format (JSON schema) Guarantees machine-parseable output
Field length constraints (60 chars, 80 words) Prevents rambling
Style rules (“factual, not poetic”) Consistency across all images
Category choices (controlled vocabulary) Enables filtering in the HTML
Fallback instruction (“say so honestly”) Handles edge cases gracefully

:bulb: you can try, experiment, and improve this!


Running It

# Put some images in a folder
mkdir images
cp ~/Pictures/microscopy/*.jpg images/

# Run
python gallery.py

# Open the result
open gallery.html

The output is a clean HTML table you can open in any browser, share with colleagues, or embed in a lab notebook.


What we did

  1. Vision models — pass images as base64 alongside text prompts
  2. Structured output — use response_format={"type": "json_object"} for reliable, parseable responses
  3. Prompt design matters — a good prompt is the difference between usable output and garbage
  4. Batch processing — loop over files, handle errors gracefully, build a final product

:arrow_right: Next: the reverse direction — generating images with AI.


Previous submodule:
Next submodule: