# Blog Featured Image Generation

**Last Updated:** 2026-04-04

Guide for generating blog featured images using **Google Gemini 2.5 Flash Image**. For style guidelines, see [BLOG_FEATURED_IMAGE_STYLE_GUIDE.md](BLOG_FEATURED_IMAGE_STYLE_GUIDE.md).

## Overview

The `generate-blog-featured-image.py` script creates featured images for blog posts using **Gemini 2.5 Flash Image**. It reads the post title and excerpt, builds a contextual prompt with topic-specific additions, saves the image to `v2/img/insights/`, and **by default** runs the optimization pipeline (PNG → WebP + responsive srcset) and updates the post JSON automatically.

## Prerequisites

Configure a **Gemini Developer API** key (dedicated key; do not reuse Google Maps keys). Full steps: [GEMINI_API_KEY_LOCAL.md](../../development/GEMINI_API_KEY_LOCAL.md).

**Image model:** Default REST model id is **`gemini-2.5-flash-image`**. Override with **`GEMINI_IMAGE_MODEL`** (e.g. a higher-quality preview id) when approved; image endpoints are often priced **per image** as well as by tokens — see [Gemini API pricing](https://ai.google.dev/gemini-api/docs/pricing) and **`docs/development/GEMINI_OPTIMIZATION_GUIDE.md`**.

1. **GEMINI_API_KEY**
   - [Create API key](https://aistudio.google.com/app/apikey) (Google AI Studio)
   - Set in repo-root `.env` or: `export GEMINI_API_KEY=your-key`
   - Or create `v2/config/gemini-api-key.php` (copy from `gemini-api-key.php.example`)

2. **Python Dependencies**
   ```bash
   pip install Pillow requests
   ```
   - `Pillow` – WebP conversion and resize (used by optimize-blog-featured-image.py)
   - `requests` – Gemini REST API (in project requirements.txt)

## Usage

### From Post JSON

```bash
python3 v2/scripts/blog/generate-blog-featured-image.py --post=personalkosten-gastronomie --category=lexikon
```

- If `IMAGE_PROMPT.md` exists: uses its custom prompt (see Per-post custom prompts below)
- Else: builds prompt from **`generate-blog-featured-image.py`** using **slug-first** rules, then **title + slug keyword** table (longest match), then a **concrete default** — not a generic “conceptual” line
- Saves to `v2/img/insights/{slug}-featured.png`
- **By default:** Runs `optimize-blog-featured-image.py` to create WebP variants and update post JSON

### Automatic prompt construction (no IMAGE_PROMPT.md)

1. **Slug-first patterns** (concrete scenes aligned with curated posts): `35-stunden-woche` → five slim desk slats; `48-stunden-woche` → seven-part corridor perspective with horizontal “cap”; `^\d+-stunden-woche$` (e.g. `40-stunden-woche`) → five task chairs at a continuous bench desk in open-plan office. Extends the table in code when new families need the same treatment.
2. **Keyword table** `TOPIC_PROMPTS`: match against **title head** (text before `–`, `—`, or `:`) **plus** slug with hyphens as spaces. **Longest key wins** (e.g. `personalkosten` over `personal`).
3. **Style base** stresses **editorial, on-theme, recognizable** scenes—not “conceptual” filler.
4. **Global negatives** appended: spheres, hero clocks, cube rows as week metaphor, etc.

Run `python3 v2/scripts/blog/generate-blog-featured-image.py --post=SLUG --category=CAT --dry-run` to inspect the built prompt before calling the API.

### Per-post Custom Prompts

Create `docs/content/blog/posts/{category}/{slug}/IMAGE_PROMPT.md` when you need **stricter negatives**, **anti-collision** wording vs. related slugs, or a scene the auto builder does not yet cover. Use the template at `docs/content/blog/posts/_templates/IMAGE_PROMPT_TEMPLATE.md`.

**When to create custom IMAGE_PROMPT.md:**
- Auto prompt still produces off-topic, text-heavy, or colliding images after one refinement cycle
- Topic is not covered by slug patterns or `TOPIC_PROMPTS` (add a slug rule or keyword in `generate-blog-featured-image.py` first when the case is recurring)
- Related posts share the same auto scene and diversity audit flags adjacency (explicit Scene Type + negatives in `IMAGE_PROMPT.md`)

**Prompt quality checklist:**
1. **On-theme, topic-specific:** Scene is recognizably related to the topic – concrete workplace/context, not cryptic abstract metaphors. Avoid "soft nodes, flowing lines", "abstract connected structure", abstract nature metaphors. Prefer: organized desk for organization topics, workbench for production, schedule grid for planning, workplace scenes for workplace topics. **Test:** Would someone reading the post title recognize this image as related? If not, make it more concrete.
2. **Anti-AI look:** Include "Editorial photography, natural texture, soft film-like quality" or "Lived-in feel – not pristine or sterile"
3. **Negative instructions:** Add "Avoid: blue sphere, sundial, vertical columns, stacked blocks, abstract nodes, flowing lines, tablet showing abstract blocks, abstract nature metaphors"
4. **Composition:** Specify one of: low angle, bird's eye, wide shot, close-up
5. **Anti-duplication:** Run `audit-blog-image-scene-types.py --report-adjacent=3`. Identify related topics (posts sharing keyword space). Choose scene type different from adjacent posts AND related topics. Match scene to topic meaning, not related outcome (e.g. BUrlG = law/foundation, not travel). Document in IMAGE_PROMPT Scene Type section.
6. **Iterate if needed:** If first image is too abstract/conceptual, refine prompt to be more concrete and recognizable before regenerating.

The script extracts **only** the `## Prompt` section (not `## Suggested Prompt` or `## Alternative`). See [BLOG_FEATURED_IMAGE_STYLE_GUIDE.md](BLOG_FEATURED_IMAGE_STYLE_GUIDE.md) for no-text rules and conceptual vs literal guidance.

**Abstract-prompt warning:** When loading a custom prompt, the script checks the descriptive part (before "Avoid:") for generic abstract phrases (e.g. "abstract flowing shapes", "layered forms", "soft nodes"). If found, it prints a note suggesting concrete, topic-specific scenes. Phrases in the Avoid section are ignored.

### Diversity

Prefer variety across the blog. Use the [scene-type taxonomy](BLOG_FEATURED_IMAGE_STYLE_GUIDE.md#diversity-scene-type-taxonomy) in the style guide to mix scene types (desk, abstract, travel, silhouettes, night, timer, etc.) so the index and category pages feel visually diverse.

### Prompt Engineering Best Practices

- **Granularity:** Enumerate object counts, positions, colors, sizes in prompts
- **Conceptual scaffolding:** State intent first ("Conveys X as…"), then map to concrete visual elements
- **Composition:** Include one of: low angle, bird's eye, diagonal, wide shot, close-up
- **Negative instructions:** Add at end: "Avoid: blue sphere, sundial shape, generic clock, floating pie chart, stock office with laptop"

See [BLOG_FEATURED_IMAGE_STYLE_GUIDE.md#prompt-engineering-best-practices](BLOG_FEATURED_IMAGE_STYLE_GUIDE.md).

### Anti-Repetition

- **Pre-generation checklist:** Run `python3 v2/scripts/blog/audit-blog-image-scene-types.py` to check scene-type distribution
- **Duplicate detection:** Run `php v2/scripts/blog/audit-blog-image-duplicates.php` to find posts sharing the same image or using default prompts

### Custom Prompt (CLI Override)

```bash
python3 v2/scripts/blog/generate-blog-featured-image.py --prompt="Professional office with coffee shop counter, warm lighting"
```

### Dry Run

```bash
python3 v2/scripts/blog/generate-blog-featured-image.py --post=slug --category=lexikon --dry-run
```

Prints the prompt and output path without calling the API.

### Skip Optimization

```bash
python3 v2/scripts/blog/generate-blog-featured-image.py --post=slug --category=lexikon --skip-optimize
```

Saves PNG only; does not run WebP conversion or update post JSON. Use when you need the raw PNG for manual editing.

### Custom Output Path

```bash
python3 v2/scripts/blog/generate-blog-featured-image.py --post=slug --category=lexikon --output=/path/to/custom.webp
```

## Output

- **Format:** PNG (Gemini 2.5 Flash Image; 16:9 aspect ratio)
- **Path:** `v2/img/insights/{slug}-featured.png`
- **Served as:** `/insights/bilder/{slug}-featured.png` (via .htaccess rewrite to `v2/img/insights/`)

## Optimization Pipeline

By default, after saving the PNG, `generate-blog-featured-image.py` calls `optimize-blog-featured-image.py` to:

1. Convert PNG → WebP (quality 85)
2. Create responsive variants: 640w, 1024w, 1280w, 1792w (only sizes ≤ source width)
3. Update post JSON `featured_image` with `src`, `srcset`, `alt`, `width`, `height`
4. Remove source PNG (use `--keep-png` to retain)

**Alt text:** After optimize runs, `alt` is set from post title: `{Post Title} | Ordio` (max 150 chars). Existing alt is preserved; if empty, the script uses the post title. See [BLOG_IMAGE_ALT_GUIDELINES.md](BLOG_IMAGE_ALT_GUIDELINES.md).

### Manual Optimization

For existing PNG images or when using `--skip-optimize`:

```bash
python3 v2/scripts/blog/optimize-blog-featured-image.py --post=slug --category=lexikon
```

Options:

| Option | Description |
|--------|-------------|
| `--post=slug` | Post slug (required) |
| `--category=category` | Category: lexikon, ratgeber, inside-ordio |
| `--input=path` | Custom input PNG path (default: `v2/img/insights/{slug}-featured.png`) |
| `--dry-run` | Print planned changes without writing |
| `--keep-png` | Keep source PNG after conversion (default: delete) |

### Audit Existing Posts

To see which posts need optimization (PNG or missing srcset):

```bash
php v2/scripts/blog/audit-blog-featured-images.php
php v2/scripts/blog/audit-blog-featured-images.php --category=lexikon
```

To audit scene-type diversity across featured images:

```bash
python3 v2/scripts/blog/audit-blog-image-scene-types.py
python3 v2/scripts/blog/audit-blog-image-scene-types.py --category=lexikon
python3 v2/scripts/blog/audit-blog-image-scene-types.py --report-adjacent=3  # Warn when 4+ consecutive posts share same scene
```

To detect duplicate or similar-default images:

```bash
php v2/scripts/blog/audit-blog-image-duplicates.php
php v2/scripts/blog/audit-blog-image-duplicates.php --category=lexikon
```

Output: `docs/data/blog-image-duplicates-report.json` and CLI summary.

**Audit snapshot (2026-02-11):** 108 total posts, 99 with featured image. All use WebP format; 98 posts lack srcset. Run the audit script for current numbers.

## Updating Post JSON (Manual)

When using `--skip-optimize` or when optimization fails, update the post JSON manually:

```json
{
  "featured_image": {
    "src": "/insights/bilder/{slug}-featured.png",
    "alt": "Post title | Ordio",
    "width": 1792,
    "height": 1024
  }
}
```

Prefer running the optimization script so the post gets WebP and srcset automatically.

## Cost

- **Gemini 2.5 Flash Image:** ~$0.039 per image (1290 output tokens); pricing per [Google AI pricing](https://ai.google.dev/pricing)

## Case Study: Lohnarten (2026-02-12)

**Problem:** Initial prompt used "abstract vertical columns or stacked blocks" – produced generic AI imagery (three colored bars) that could apply to any topic.

**Root cause:** Prompt matched style-guide anti-patterns: "single central vertical bar", "tablet showing abstract blocks", "abstract stacked blocks".

**Solution:** Rewrote IMAGE_PROMPT.md with topic-specific metaphor: "organized compartment tray (empty, no labels)" – conveys classification, structure, building blocks without generic abstract shapes. Added "Editorial photography, natural texture, lived-in feel" to avoid sterile AI look.

**Process improvements:** Added Lohnarten to topic-keyword table; added "abstract stacked blocks / vertical columns" to repetitive-elements exclusion list; enhanced IMAGE_PROMPT_TEMPLATE.md with "Why Not" section and prompt quality checklist.

## Troubleshooting

| Issue | Solution |
|-------|----------|
| `Gemini API key not found` | Set `export GEMINI_API_KEY=your-key` or create `v2/config/gemini-api-key.php` (copy from `gemini-api-key.php.example`) |
| `GEMINI_API_KEY` not set | Set `GEMINI_API_KEY` in `.env` or create `v2/config/gemini-api-key.php` (see [GEMINI_API_KEY_LOCAL.md](../../development/GEMINI_API_KEY_LOCAL.md)) |
| `No module named 'PIL'` (optimize) | Run `pip install Pillow` |
| Post not found | Verify post exists at `v2/data/blog/posts/{category}/{slug}.json` |
| Content policy error | Adjust prompt to avoid prohibited content |
| Optimize script fails | Ensure PNG exists at `v2/img/insights/{slug}-featured.png`; run with `--dry-run` to debug |
| Image contains text | Create IMAGE_PROMPT.md with explicit "No text" and avoid contracts, calendars, checklists in prompt |
| Image too literal | Use conceptual prompts (mood, atmosphere) instead of topic-name objects; see style guide |

## Related

- [BLOG_FEATURED_IMAGE_STYLE_GUIDE.md](BLOG_FEATURED_IMAGE_STYLE_GUIDE.md) – Style and prompt guidelines
- [BLOG_POST_IMPROVEMENT_PROCESS.md](BLOG_POST_IMPROVEMENT_PROCESS.md) – New Post Creation workflow
- `.cursor/rules/blog-new-post-creation.mdc` – Cursor rule for new post workflow
