Ga naar hoofdinhoud

GLM Image-tutorial: een generator voor infografische decks bouwen

Leer hoe je GLM-Image van Z.ai gebruikt om vanuit één prompt een complete infografische slidedeck te genereren.
Bijgewerkt 2 okt 2026  · 12 min lezen

Verken met AI

ChatGPTClaudePerplexity

Infographics en slidedecks zijn een van de moeilijkste beeldgeneratietaken, omdat je niet alleen visuals maakt, maar ook gestructureerde informatie zoals koppen, bullet points, grafieken en een footer, terwijl je tekst en lay-out consistent houdt. GLM-Image is Z.ai’s model voor beeldgeneratie dat hoge-resolutie, tekstzware, lay-outbewuste beelden kan produceren wanneer je het aanstuurt met expliciete ontwerpbeperkingen.

In deze tutorial laat ik je stap voor stap zien hoe je met een eenvoudige Streamlit-app een prompt-naar-infografisch deckgenerator bouwt. Je kunt vrijwel elk onderwerp invoeren. Gemini 2.5 Flash voert webgebaseerd onderzoek uit om actuele statistieken en trends op te halen, de LLM zet dat onderzoek om in een strikt slide-JSON-plan, en GLM-Image rendert elke slide als een infografiek met hoge resolutie.

Aan het einde heb je een complete pijplijn die één prompt omzet in een downloadbaar deck, zonder dat je een GPU nodig hebt, omdat al het zware werk via API’s gebeurt. Bekijk de versnelde video die de workflow laat zien:

Wat is GLM Image?

GLM-Image is Z.ai’s tekst-naar-beeldmodel (aangeboden via de glm-image-model-ID), speciaal gebouwd voor kennisdichte, lay-outgevoelige generatie zoals posters, PPT-achtige visuals en wetenschappelijke diagrammen.

Figuur: GLM-Image: Autoregressief voor kennisdichte en natuurgetrouwe beeldgeneratie

Onder de motorkap gebruikt het een hybride autoregressieve en diffusierecoder-opzet waarbij een autoregressieve backbone zich richt op instructiebegrip en globale lay-out, terwijl de diffusierecoder hoge-frequentiedetails verfijnt (inclusief scherpere tekstlijnen). Deze hybride opzet is een belangrijke reden waarom GLM-Image voor tekstzware visuals vaak betrouwbaarder is dan veel pure diffusiegeneratoren.

In deze tutorial gebruiken we GLM-Image als de renderengine voor elke slide. In plaats van vage prompts geven we een strikte, lay-outbewuste prompt die het volgende vastlegt:

  • Eén effen achtergrondkleur (geen verlopen),
  • Paletbeperkingen (achtergrond / accent / tekst),
  • Duidelijke typografiehiërarchie (kop vs. body vs. voetnoot),
  • Expliciete lay-outregels per slidetype (stat, vergelijking, tijdlijn, proces, lijst),
  • Optionele instructies voor grafiektekenen (staaf / taart / lijn),
  • Footernotities en slide­nummering.

Opmerking: GLM-Image retourneert een afbeeldings-URL, ondersteunt gangbare presentatieratio’s zoals 16:9 en werkt het best met aanbevolen formaten zoals 1728×960.

GLM Image-tutorial: bouw een realtime infografische generator

In deze sectie bouwen we een prompt-naar-infografisch slidedeck, aangestuurd door Gemini 2.5 Flash en GLM-Image, verpakt in een Streamlit-applicatie. In grote lijnen voert de app drie kerntaken uit:

  • Onderzoek een onderwerp met actuele webdata, zodat het deck recente cijfers en trends weerspiegelt in plaats van verouderde modelkennis
  • Zet het onderzoek om in een gestructureerd slideplan met een strikt JSON-schema dat slide­lay-outs definieert zoals stat, tijdlijn, vergelijking, proces en samenvatting
  • Render elke slide als een volledige infografische afbeelding met GLM-Image, inclusief typografiehiërarchie, themakleuren, lay-outbeperkingen, optionele grafieken en slidenummering.

Achter de schermen draait de workflow in twee sleutel­fases:

  • Fase 1: De functie research_topic(topic) gebruikt Gemini 2.5 Flash met web­zoek ingeschakeld om een gegevensrijke onderzoeks­samenvatting te produceren, afgestemd op het exacte onderwerp.
  • Fase 2: De functie structure_slides() zet dat onderzoek om in een JSON-deck, dat vervolgens wordt omgezet in gedetailleerde GLM-Image-prompts en slide-voor-slide gerenderd.

Stap 1: Haal API-sleutels op

Voordat we de infografische generator bouwen, hebben we toegang nodig tot twee externe services die de kern van de pijplijn aandrijven:

  • OpenRouter biedt toegang tot Gemini 2.5 Flash, die we gebruiken voor webgebaseerd onderzoek en het structureren van slides.
  • Z.ai host het GLM-Image-model via API en HuggingFace, dat de uiteindelijke infografische slides genereert.

Deze scheiding is bewust: het tekstmodel verwerkt dataverzameling en planning, terwijl GLM-Image zich volledig richt op visuele generatie.

Stap 1.1: OpenRouter API-sleutel

OpenRouter fungeert als een uniforme gateway die je met één sleutel toegang geeft tot meerdere modellen (Gemini, OpenAI, Claude en anderen).

  • Maak een account aan op OpenRouter en genereer een API-sleutel.
  • Voeg een klein tegoed toe (ongeveer $5). Dit is voldoende voor meerdere runs van deze demo en toekomstige experimenten.

In deze tutorial gebruiken we Gemini 2.5 Flash omdat het webzoek ondersteunt, waardoor de app actuele data en statistieken kan ophalen in plaats van uitsluitend te vertrouwen op modelkennis. Je kunt desgewenst een ander ondersteund model gebruiken.

Stap 1.2: Z.ai API-sleutel

Maak vervolgens een account aan op Z.ai om toegang te krijgen tot het GLM Image-model.

Z.ai API Key

  • Ga naar API Management en selecteer Billing
  • Klik op Add Payment Method en voer je kaartgegevens in
  • Genereer een API-sleutel voor het beeldgeneratie-endpoint

Deze sleutel wordt gebruikt om het glm-image-model aan te roepen voor het renderen van elke slide.

Stap 1.3: Sla sleutels op als omgevingsvariabelen

Zodra beide sleutels zijn aangemaakt, sla ze dan op als omgevingsvariabelen met de volgende commando’s:

export OPENROUTER_API_KEY="Your_OpenRouter_API_Key"
export ZAI_API_KEY="Your_ZAI_API_Key"

Deze omgevingsvariabelen houden credentials buiten je codebase en maken deployment naar Streamlit Cloud, Docker of andere omgevingen veel eenvoudiger. 

Stap 2: Installeer dependencies

Vervolgens installeren we de vereiste dependencies en beginnen we met het bouwen van de Streamlit-applicatie.

pip install streamlit requests Pillow

Zodra de packages zijn geïnstalleerd, importeren we de standaardbibliotheken die we overal in de app gebruiken, samen met een paar constanten die de twee API-endpoints definiëren.

import streamlit as st
import json
import time
import requests
import os
import io
import re
import zipfile
from datetime import date
from PIL import Image
from typing import Optional, List, Dict
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "google/gemini-2.5-flash"
GLM_IMAGE_API_URL = "https://api.z.ai/api/paas/v4/images/generations"
GLM_IMAGE_MODEL = "glm-image"

Deze libraries vormen de runtime-laag van de applicatie. Streamlit verzorgt de interactieve webinterface, Requests handelt API-calls af naar OpenRouter (Gemini 2.5 Flash) en Z.ai (GLM-Image), en Pillow stelt ons in staat gegenereerde afbeeldingen te laden en weer te geven. Aanvullende standaardbibliotheken beheren JSON-parsing, omgevingsvariabelen, timing en het exporteren van resultaten als ZIP-bestanden. 

We definiëren ook constanten voor de OpenRouter- en GLM-Image-endpoints, zodat de app weet waar onderzoeks-, structurering- en beeldgeneratie-aanvragen naartoe moeten. Deze setup houdt de omgeving lichtgewicht, met alle zware berekeningen via API’s.

Stap 3: Definieer resoluties en presentatiethema’s

In deze stap definiëren we een kleine set resolutiepresets en een themabibliotheek die de achtergrondkleur, accentkleur, typografische sfeer en de formulering van de infografische stijl bepaalt die we in elke GLM-Image-prompt injecteren.

RESOLUTIONS = {
    "16:9 Landscape (1728×960)": "1728x960",
    "3:2 Landscape (1568×1056)": "1568x1056",
    "1:1 Square (1280×1280)": "1280x1280",
    "2:3 Portrait (1056×1568)": "1056x1568",
}
THEMES = {
    "Corporate Navy": {
        "desc": "Professional dark blue palette, clean sans-serif, data-driven",
        "colors": {"bg": "#0F172A", "accent": "#38BDF8", "text": "#F1F5F9"},
        "style": (
            "professional corporate infographic, dark navy blue (#0F172A) background, "
            "cyan (#38BDF8) accent color, clean white (#F1F5F9) text, "
            "modern sans-serif typography, clean grid layout, subtle geometric decorations, "
            "professional data visualization elements, business presentation quality"
        ),
    },
    "Neon Noir": {
        "desc": "Dark mode with vivid neon pink accents, tech-forward aesthetic",
        "colors": {"bg": "#1A1A2E", "accent": "#E94560", "text": "#EAEAEA"},
        "style": (
            "dark mode tech infographic, deep dark (#1A1A2E) background, "
            "vivid neon pink (#E94560) accent color, light gray (#EAEAEA) text, "
            "bold modern typography with sharp edges, neon glow effects, "
            "futuristic layout with geometric shapes, startup pitch deck aesthetic"
        ),
    },
    "Warm Paper": {
        "desc": "Cream textured backgrounds, burnt orange accents, editorial feel",
        "colors": {"bg": "#F8F5F0", "accent": "#D4641A", "text": "#1C1917"},
        "style": (
            "warm editorial infographic on cream (#F8F5F0) textured paper background, "
            "burnt orange (#D4641A) accent color, dark brown (#1C1917) text, "
            "elegant serif typography mixed with clean sans-serif, "
            "magazine editorial layout, hand-drawn illustration elements, "
            "warm earth tones, scholarly professional design"
        ),
    },
    "Cyber Teal": {
        "desc": "Black backgrounds, electric teal highlights, terminal-inspired",
        "colors": {"bg": "#0C0A09", "accent": "#22D3EE", "text": "#FAFAF9"},
        "style": (
            "cyberpunk infographic, black (#0C0A09) background, "
            "electric teal (#22D3EE) accent color, white (#FAFAF9) text, "
            "monospace and tech typography, terminal-inspired layout elements, "
            "circuit board patterns, data stream visuals, hacker aesthetic, "
            "sharp angular design elements"
        ),
    },
    "Indigo Dream": {
        "desc": "Deep purple base with soft violet accents, premium feel",
        "colors": {"bg": "#1E1B4B", "accent": "#A78BFA", "text": "#EDE9FE"},
        "style": (
            "premium luxury infographic, deep indigo (#1E1B4B) background, "
            "soft violet (#A78BFA) accent color, lavender (#EDE9FE) text, "
            "elegant modern typography, gradient overlays, "
            "smooth rounded shapes, glassmorphism effects, "
            "high-end presentation quality with subtle glow effects"
        ),
    },
    "Solar Gold": {
        "desc": "Warm cream with golden accents, confident and bold",
        "colors": {"bg": "#FEFCE8", "accent": "#A16207", "text": "#1C1917"},
        "style": (
            "premium golden infographic, warm cream (#FEFCE8) background, "
            "rich gold (#A16207) accent color, dark (#1C1917) text, "
            "bold confident typography, art deco inspired geometric patterns, "
            "luxury brand aesthetic, clean information hierarchy, "
            "warm highlights and gold foil style embellishments"
        ),
    },
    "Forest Green": {
        "desc": "Dark green palette, nature-inspired, calming data storytelling",
        "colors": {"bg": "#052E16", "accent": "#4ADE80", "text": "#F0FDF4"},
        "style": (
            "nature-inspired infographic, deep forest green (#052E16) background, "
            "bright green (#4ADE80) accent color, light mint (#F0FDF4) text, "
            "organic flowing typography, leaf and nature motif decorations, "
            "sustainability report aesthetic, calming visual design, "
            "clean environmental data presentation"
        ),
    },
    "Crimson Bold": {
        "desc": "High contrast red on dark, attention-grabbing poster style",
        "colors": {"bg": "#1C1917", "accent": "#EF4444", "text": "#FAFAF9"},
        "style": (
            "bold poster-style infographic, dark charcoal (#1C1917) background, "
            "vivid crimson red (#EF4444) accent color, white (#FAFAF9) text, "
            "oversized impactful numbers, bold condensed typography, "
            "high contrast design, attention-grabbing layout, "
            "social media optimized visual hierarchy"
        ),
    },
}
EXAMPLE_TOPICS = [
    "AI Industry Market Trends January 2026",
    "Global Electric Vehicle Sales 2025",
    "SpaceX Starship Progress 2025-2026",
    "State of Remote Work 2026",
    "Bitcoin & Crypto Market February 2026",
    "Climate Change Key Metrics 2025",
]

De dictionary RESOLUTIONS koppelt een label aan de exacte WxH-string die het Z.ai-beeldgeneratie-endpoint verwacht. Het belangrijkste voordeel is voorspelbaarheid: je kunt van formaat wisselen (brede slides vs. vierkante social assets vs. portrait) zonder promptlogica aan te passen. De beelverhoudingen:

  • 16:9 is het best voor klassieke slidedecks en presentatie-achtige infographics.
  • 1:1 werkt goed voor LinkedIn/Twitter-carrouselachtige visuals.
  • 2:3 is handig voor mobiel-first infographicposters.

We definiëren ook THEMES, waarbij elk thema een compact designsysteem is met drie onderdelen:

  • desc: een korte UI-beschrijving die in de sidebar wordt getoond
  • colors: ons strikte palet (achtergrond, accent, tekst)
  • style: het promptbare stijlblok dat GLM-Image richting geeft aan consistente lay-out, typografie en decoratie

In de praktijk wordt dit thema-object een herbruikbaar “prompt­prefix” voor elke slide, zodat het deck aanvoelt als één geheel in plaats van 7 losse afbeeldingen.

Stap 4: Fase 1 - Onderzoek met Gemini 2.5 Flash

Voordat we slides genereren, hebben we actuele data over het onderwerp nodig. In plaats van te vertrouwen op statische modelkennis, gebruikt deze stap Gemini 2.5 Flash via OpenRouter met webzoek ingeschakeld om recente statistieken, tijdlijnen en kerninzichten te verzamelen.

def call_openrouter(
    messages: List[Dict],
    api_key: str,
    use_web_search: bool = False,
    temperature: float = 0.7,
    max_tokens: int = 4096,
) -> str:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://glm-image-infographic-demo.streamlit.app",
        "X-Title": "GLM-Image Infographic Generator",
    }

    payload = {
        "model": OPENROUTER_MODEL,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
    }
    if use_web_search:
        today = date.today().strftime("%B %d, %Y")
        payload["plugins"] = [{
            "id": "web",
            "max_results": 10,
            "search_prompt": (
                f"Use these recent web search results (searched on {today}) to answer "
                f"with the most up-to-date information available:"
            ),
        }]

    resp = requests.post(OPENROUTER_API_URL, headers=headers, json=payload, timeout=120)
    resp.raise_for_status()
    data = resp.json()
    content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
    if not content:
        raise ValueError(f"Empty response from OpenRouter: {json.dumps(data, indent=2)[:500]}")
    return content
def research_topic(topic: str, api_key: str) -> str:
    today = date.today().strftime("%B %d, %Y")  # e.g. "February 13, 2026"
    current_year = date.today().year

    messages = [
        {
            "role": "system",
            "content": (
                f"You are a focused research analyst. Today's date is {today}. "
                f"CRITICAL: You must research ONLY the specific topic provided. Do NOT include "
                f"information about unrelated topics, industries, or subjects. If web search returns "
                f"irrelevant results, ignore them completely. "
                f"Your training data may be outdated. Rely primarily on the web search results "
                f"provided to you for current data. Prioritize sources from {current_year} and "
                f"late {current_year - 1}. If a data point comes from an older source, note the year. "
                f"Compile comprehensive, data-rich research on the given topic. Include specific "
                f"numbers, percentages, dollar amounts, growth rates, timelines, and key players. "
                f"Provide data suitable for multiple visualization formats: charts, comparisons, "
                f"timelines, and processes. Be thorough but concise. Stay strictly on topic."
            ),
        },
        {
            "role": "user",
            "content": (
                f"Research ONLY this specific topic and provide a data-rich summary:\n\n"
                f"**{topic}**\n\n"
                f"CRITICAL INSTRUCTIONS:\n"
                f"1. Research EXCLUSIVELY about '{topic}' - nothing else\n"
                f"2. Ignore any web search results not directly related to '{topic}'\n"
                f"3. Do NOT include data about semiconductors, smartphones, grooming products, or other "
                f"unrelated industries unless they are directly part of '{topic}'\n"
                f"4. Search for the most recent data available as of {today}\n"
                f"5. Prefer sources published in {current_year}\n\n"
                f"For '{topic}' specifically, include:\n"
                f"- Key statistics with specific numbers, percentages, or dollar amounts\n"
                f"- Comparison data directly relevant to '{topic}'\n"
                f"- Timeline or chronological events related to '{topic}'\n"
                f"- Process or methodology descriptions relevant to '{topic}'\n"
                f"- Market sizes, growth rates, and future outlook for '{topic}'\n"
                f"- Major players/entities involved in '{topic}'\n\n"
                f"Structure the data so it can be visualized as bar charts, pie charts, "
                f"timelines, comparison tables, and process diagrams - all focused on '{topic}'."
            ),
        },
    ]
    return call_openrouter(messages, api_key, use_web_search=True, temperature=0.5)

De bovenstaande code definieert twee belangrijke helperfuncties die de onderzoekslaag van de pijplijn vormen.

  • De functie call_openrouter() fungeert als interface voor het verzenden van chatverzoeken naar OpenRouter met het model Gemini 2.5 Flash. Ze bouwt de requestpayload op met modelnaam, berichten, temperatuur en tokenlimieten en verwerkt authenticatie via de API-sleutel. 
  • Als use_web_search=True, schakelt de functie de webplugin in en voegt een zoekprompt toe zodat het model de meest recente informatie prioriteert. Na het verzenden van de aanvraag valideert ze de response en extraheert de gegenereerde tekst, zodat de pijplijn niet doorgaat met lege of mislukte outputs.
  • De functie research_topic() bouwt een taakspecifieke onderzoeksprompt ontworpen voor infografische generatie. De prompt vraagt expliciet om kwantitatieve informatie zoals statistieken, groeipercentages, marktomvang, tijdlijnen, vergelijkingen en sleutelspelers, zodat deze later gevisualiseerd kunnen worden als grafieken, tabellen of diagrammen. De functie roept daarna call_openrouter() aan met een lagere temperatuur om de output feitelijk en gestructureerd te houden in plaats van creatief.

Je kunt experimenteren met parameters zoals temperatuur, het aantal webresultaten of de strengheid van de onderzoeksinstructies om de balans te sturen tussen breedte, actualiteit en precisie van de verzamelde data.

In de volgende stap zetten we dit ruwe onderzoek om in een gestructureerd slideschema (JSON) om de lay-out, kernstatistieken en visuele elementen voor elke slide te definiëren voordat we ze naar het GLM-Image-model sturen.

Stap 5: Fase 2 - Zet onderzoek om naar gestructureerde slide-JSON

Zodra het onderzoek klaar is, is de volgende stap het omzetten naar een gestructureerde blauwdruk die GLM-Image consistent kan renderen. In plaats van paragrafen te sturen, genereren we een strikt JSON-plan waarin elke slide zijn lay-out, kerntest en optionele grafiekdata specificeert.

def structure_slides(topic: str, research: str, num_slides: int, api_key: str) -> List[Dict]:
    system_prompt = f"""You create structured infographic slide decks. Return ONLY a valid JSON array with exactly {num_slides} slide objects. No markdown backticks. No explanation. Just the JSON.
SLIDE FORMATS:
Slide 1 — Title:
{{"type":"title","slide_number":1,"headline":"Bold Title (max 8 words)","subheadline":"One-liner with a key stat","tag":"CATEGORY LABEL","visual_elements":"2-3 topic-relevant decorative motifs (e.g. globe icon, circuit pattern for tech topics)"}}
Slides 2 to {num_slides-1} — Content (pick ONE layout per slide):
Layout "stat" — Use when there is one standout number to feature:
{{"type":"content","slide_number":N,"layout":"stat","headline":"Section Title (max 6 words)","key_stat":"$4.2T or +340%","key_stat_label":"What the stat means","bullets":["3-4 bullets with specific numbers"],"visual_elements":"2-3 topic-relevant icons","chart_data":{{"type":"bar|pie|line","title":"Chart title","data":[{{"label":"Category","value":"123"}}]}},"bottom_note":"Source"}}
Layout "comparison" — Use for side-by-side comparisons:
{{"type":"content","slide_number":N,"layout":"comparison","headline":"Section Title","items":[{{"label":"Item A","points":["fact 1 with number","fact 2 with number"]}},{{"label":"Item B","points":["fact 1","fact 2"]}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "timeline" — Use for chronological progressions:
{{"type":"content","slide_number":N,"layout":"timeline","headline":"Section Title","milestones":[{{"date":"2020","event":"Event description with metric"}},{{"date":"2023","event":"Another event"}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "process" — Use for step-by-step flows:
{{"type":"content","slide_number":N,"layout":"process","headline":"Section Title","steps":[{{"step":"1","title":"Step Name","detail":"Description with metric"}},{{"step":"2","title":"Next Step","detail":"Description"}}],"visual_elements":"topic-relevant icons","bottom_note":"Source"}}
Layout "list" — Use for data-rich bullets without a dominant stat:
{{"type":"content","slide_number":N,"layout":"list","headline":"Section Title","bullets":["4-5 bullets each with a specific number/metric"],"visual_elements":"topic-relevant icons","chart_data":{{"type":"bar|pie|line","title":"Chart title","data":[{{"label":"Category","value":"123"}}]}},"bottom_note":"Source"}}
Slide {num_slides} — Summary:
{{"type":"summary","slide_number":{num_slides},"headline":"Key Takeaways","takeaways":["4-5 punchy takeaway lines with data"],"outlook":"One sentence future outlook","visual_elements":"topic-relevant closing motif"}}
RULES:
- Every bullet MUST contain a specific number, percentage, or dollar amount
- Use a MIX of layouts across the deck. Do NOT use "stat" for every slide. Choose the layout that best fits the content. At most half of content slides should use "stat" layout.
- key_stat is ONLY used with "stat" layout. Other layouts do NOT have key_stat.
- visual_elements MUST be topic-specific (e.g. for EV topic: "electric car silhouette, battery icon, charging station"; for AI topic: "neural network diagram, robot icon, chip illustration"; for climate: "globe, thermometer, polar bear"). NEVER use generic shapes.
- chart_data is OPTIONAL. Include it when the slide's data naturally fits a chart. Fields:
  * type: "bar", "pie", or "line"
  * title: short chart title
  * data: array of {{"label":"...","value":"..."}} with real numbers from the research
  * axis_label: (optional) label for the value axis
  Not every slide needs chart_data. Use it for stat and list layouts when data is quantitative.
- Each slide covers a distinct aspect — no repetition
- Ensure logical narrative flow across slides"""
    messages = [
        {"role": "system", "content": system_prompt},
        {
            "role": "user",
            "content": (
                f"Create a {num_slides}-slide infographic deck about:\n\n"
                f"**{topic}**\n\n"
                f"RESEARCH DATA:\n{research}\n\n"
                f"Return ONLY the JSON array."
            ),
        },
    ]
    raw = call_openrouter(messages, api_key, use_web_search=False, temperature=0.2)
    raw = re.sub(r"^```(?:json)?\s*", "", raw.strip())
    raw = re.sub(r"\s*```$", "", raw.strip())
    return json.loads(raw)
def _build_chart_instruction(chart_data: Dict, accent_hex: str) -> str:
    if not chart_data or not chart_data.get("type"):
        return ""
    chart_type = chart_data["type"]
    title = chart_data.get("title", "Chart")
    data = chart_data.get("data", [])
    axis_label = chart_data.get("axis_label", "")
    if not data:
        return ""
    if chart_type == "bar":
        bars = ", ".join([f"'{d['label']}': {d['value']}" for d in data])
        axis_part = f" Y-axis label: '{axis_label}'." if axis_label else ""
        return (
            f"Draw a vertical bar chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 40% of the slide width. "
            f"Bars: [{bars}]. Label each bar clearly below it with its category name. "
            f"Display the value on top of each bar.{axis_part} "
            f"Use {accent_hex} color for the bars with slight shade variation between them. "
        )
    elif chart_type == "pie":
        segments = ", ".join([f"'{d['label']}': {d['value']}%" for d in data])
        return (
            f"Draw a pie chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 35% of the slide width. "
            f"Segments: [{segments}]. Label each segment with its name and percentage value. "
            f"Use {accent_hex} as the primary segment color with contrasting shades for other segments. "
        )
    elif chart_type == "line":
        points = ", ".join([f"({d['label']}, {d['value']})" for d in data])
        axis_part = f" Y-axis: '{axis_label}'." if axis_label else ""
        return (
            f"Draw a line chart titled '{title}' in the lower-right area of the slide, "
            f"taking up about 40% of the slide width. "
            f"Data points: [{points}]. Mark each data point with a dot and label its value. "
            f"Connect points with a smooth line in {accent_hex} color.{axis_part} "
        )
    elif chart_type == "table":
        rows = " | ".join([f"{d['label']}: {d['value']}" for d in data])
        return (
            f"Draw a data table titled '{title}' in the lower portion of the slide. "
            f"Data: [{rows}]. Use alternating row shading with {accent_hex} colored header row. "
            f"Keep text small but legible. "
        )
    return ""

In fase 2 definiëren we twee hoofdfuncties: één om onze slides te structureren en een andere om instructies voor grafieken of diagrammen te bouwen. Deze functies omvatten:

  • Functie structure_slides(): deze gebruikt Gemini (zonder webzoek) als structurering­engine. Ze bouwt een system prompt die het model dwingt om alleen een geldige JSON-array met exact het gevraagde aantal slides terug te geven. Elk deck volgt een vast narratief patroon: een titelslide, meerdere contentslides en een eind­samenvatting. Contentslides kunnen verschillende lay-outs gebruiken, zoals stat, vergelijking, tijdlijn, proces of lijst, en de prompt handhaaft belangrijke beperkingen zoals:
  • Elke bullet moet een concreet getal bevatten (aantal, percentage of dollarwaarde)
  • Lay-outs moeten worden gemixt om herhaling te vermijden
  • Visuele elementen moeten onderwerp-specifiek zijn
  • Een optionele chart_data is opgenomen wanneer de inhoud zich natuurlijk leent voor een grafiek
  • De functie roept vervolgens call_openrouter() aan met een lage temperatuur voor stabiele output, verwijdert eventuele per ongeluk toegevoegde markdown-wrappers en parseert de response naar een Python-dictionary voor vervolggebruik.
  • Functie _build_chart_instruction(): deze helper zet de optionele chart_data uit de JSON om in tekeninstructies voor GLM-Image. Afhankelijk van het grafiektype (staaf, taart, lijn of tabel) genereert ze duidelijke aanwijzingen over plaatsing, labeling, grootte en kleurgebruik op basis van de accentkleur van het thema. 

Aan het einde van deze stap hebben we een nette specificatie op slideniveau die zowel de verhaallijn als de visuele structuur van het deck definieert.

Stap 6: Zet slide-JSON om in GLM-Image-prompts

In deze stap vertalen we elk slide-object naar een sterk begrensde prompt die het beeldmodel precies vertelt wat het moet tekenen, waar het moet worden geplaatst en hoe het moet worden vormgegeven.

def build_glm_prompt(slide: Dict, theme: Dict, total: int, topic: str) -> str:
    style = theme["style"]
    colors = theme["colors"]
    bg_hex = colors["bg"]
    accent_hex = colors["accent"]
    text_hex = colors["text"]
    bg_anchor = (
        f"BACKGROUND: Use a solid, uniform {bg_hex} background across the entire image. "
        f"Do not add gradients, varying tones, or alternate background colors. "
        f"The background must be exactly {bg_hex}. "
    )
    common_footer = (
        f"Typography hierarchy: Headlines in bold large font, subheadings in semibold medium font, "
        f"body text in regular smaller font, footnotes in small font. "
        f"All text must be {text_hex} color unless it is an accent element using {accent_hex}. "
        f"In the bottom-right corner, display '{slide.get('slide_number', 1)} / {total}' in small text using {text_hex} at 50% opacity. "
        f"The overall design should be a single cohesive infographic slide image, "
        f"high resolution, crisp text rendering, professional quality. "
        f"All text in the image must be clearly legible and accurately spelled."
    )
    visual_elements = slide.get("visual_elements", "")
    visual_elements_instruction = (
        f"Include these topic-relevant visual elements as subtle icons or illustrations: {visual_elements}. "
        if visual_elements else ""
    )
    if slide["type"] == "title":
        return (
            f"{bg_anchor}"
            f"Create a professional infographic title slide. {style}. "
            f"Display these text elements in the image:\n"
            f"- Small uppercase tag at the top-center: '{slide.get('tag', 'REPORT')}'\n"
            f"- Large bold headline centered in the middle of the slide: '{slide['headline']}'\n"
            f"- Smaller subtitle centered below the headline: '{slide['subheadline']}'\n\n"
            f"The headline should be the dominant visual element. "
            f"{visual_elements_instruction}"
            f"{common_footer}"
        )
    elif slide["type"] == "summary":
        items = "\n".join(
            [f"  {i+1}. '{t}'" for i, t in enumerate(slide.get("takeaways", []))]
        )
        return (
            f"{bg_anchor}"
            f"Create a professional infographic summary slide. {style}. "
            f"Display these text elements in the image:\n"
            f"- Header at top-left: '{slide['headline']}'\n"
            f"- Numbered takeaway list in the center area:\n{items}\n"
            f"- Italic footer outlook at bottom-left: '{slide.get('outlook', '')}'\n\n"
            f"Use numbered circles in {accent_hex} color or checkmarks for each takeaway. "
            f"Make the layout clean and conclusive. "
            f"{visual_elements_instruction}"
            f"{common_footer}"
        )
    else: 
        layout = slide.get("layout", "stat")
        chart_instruction = _build_chart_instruction(slide.get("chart_data", {}), accent_hex)
        if layout == "stat":
            bullets_text = "\n".join([f"  • '{b}'" for b in slide.get("bullets", [])])
            has_chart = bool(chart_instruction)
            bullet_position = "in the left half of the slide" if has_chart else "below the statistic"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic content slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- One large featured statistic displayed very prominently in the center: '{slide.get('key_stat', '')}'\n"
                f"- Label below the stat in smaller text: '{slide.get('key_stat_label', '')}'\n"
                f"- Bullet points with data {bullet_position}:\n{bullets_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"The key statistic should be the most visually dominant element — "
                f"displayed in a very large, bold font in {accent_hex} color inside a highlight box. "
                f"Bullets should use small {accent_hex} colored icons or dot indicators. "
                f"{chart_instruction}"
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "comparison":
            items = slide.get("items", [])
            items_text = ""
            for idx, item in enumerate(items):
                label = item.get("label", f"Item {idx+1}")
                points = item.get("points", [])
                points_text = "\n".join([f"    - '{p}'" for p in points])
                col_pos = "left column" if idx == 0 else "right column"
                items_text += f"  [{col_pos}] '{label}':\n{points_text}\n"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic comparison slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Side-by-side comparison layout with a vertical divider line in {accent_hex}:\n{items_text}"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Use a clean two-column layout. Each column header should be bold and in {accent_hex}. "
                f"Bullet points under each column should have small icons. "
                f"Add a 'VS' or divider element between the columns. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "timeline":
            milestones = slide.get("milestones", [])
            milestones_text = "\n".join(
                [f"  [{m.get('date', '')}] → '{m.get('event', '')}'" for m in milestones]
            )
            return (
                f"{bg_anchor}"
                f"Create a professional infographic timeline slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Horizontal timeline with these milestones connected by a line:\n{milestones_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Draw a horizontal timeline line in {accent_hex} running across the center of the slide. "
                f"Place milestone dates above the line and event descriptions below, connected by vertical dots. "
                f"Each milestone dot should be a circle in {accent_hex}. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        elif layout == "process":
            steps = slide.get("steps", [])
            steps_text = "\n".join(
                [f"  Step {s.get('step', idx+1)}: '{s.get('title', '')}' — {s.get('detail', '')}" for idx, s in enumerate(steps)]
            )
            return (
                f"{bg_anchor}"
                f"Create a professional infographic process flow slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Process flow with numbered steps connected by arrows:\n{steps_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Arrange steps horizontally or in a flow diagram with arrows between them. "
                f"Each step should have a numbered circle in {accent_hex} with the step title in bold below it "
                f"and the detail text in smaller font underneath. "
                f"Connect steps with directional arrows in {accent_hex}. "
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )
        else:  
            bullets_text = "\n".join([f"  • '{b}'" for b in slide.get("bullets", [])])
            has_chart = bool(chart_instruction)
            bullet_position = "in the left half of the slide" if has_chart else "in the center area"
            return (
                f"{bg_anchor}"
                f"Create a professional infographic content slide. {style}. "
                f"Display these text elements in the image:\n"
                f"- Section header at top-left: '{slide['headline']}'\n"
                f"- Data-rich bullet points {bullet_position}:\n{bullets_text}\n"
                f"- Footer at bottom: '{slide.get('bottom_note', '')}'\n\n"
                f"Bullets should use small {accent_hex} colored icons or numbered indicators. "
                f"Focus on clean information hierarchy and data-rich visual presentation. "
                f"{chart_instruction}"
                f"{visual_elements_instruction}"
                f"{common_footer}"
            )

De functie build_glm_prompt() zet één slide-JSON-object om in één GLM-Image-promptstring. Dat gebeurt in drie lagen:

  • Thema- en achtergrondcontrole: de functie haalt de style en het kleurenpalet van het thema op en past vervolgens een strikte achtergrond-anker toe. Dit vergrendelt de slide op één effen achtergrondkleur en voorkomt verlopen of variatie.
  • Lay-outregels: een gedeeld instructieblok handhaaft tekshiërarchie, consistente tekstkleuren en weergavekwaliteit. Het voegt ook een kleine slide­counter N / total toe, zodat elke afbeelding aanvoelt als een pagina uit een deck in plaats van een losstaande graphic.
  • Lay-outspecifieke structuur: de functie past de prompt aan op basis van het slidetype:
    • Titel: de headline is het dominante visuele element dat het onderwerp direct neerzet, terwijl tag en subheadline context en een kernstatistiek bieden.
    • Samenvatting: deze slide bundelt de belangrijkste inzichten in beknopte, krachtige punten.
    • Contentslides: deze slides ondersteunen diverse lay-outs. Een stat-lay-out benadrukt één groot kerngetal met ondersteunende bullets, comparison gebruikt een strakke tweekolomsstructuur, timeline en process visualiseren chronologische gebeurtenissen, terwijl list datarijke bullets presenteert die eventueel met een grafiek kunnen worden gecombineerd.

Als er chart_data aanwezig is, zet de functie _build_chart_instruction() die om in expliciete tekeninstructies. Dit verhoogt de betrouwbaarheid omdat het model precies te horen krijgt wat en waar te tekenen.

Stap 7: Genereer afbeeldingen met GLM-Image 

Nu is de laatste stap om de bovenstaande prompts daadwerkelijk te renderen tot infografische afbeeldingen.

def generate_glm_image(prompt: str, api_key: str, size: str = "1728x960") -> Optional[Image.Image]:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": GLM_IMAGE_MODEL,
        "prompt": prompt,
        "size": size,
    }
    try:
        resp = requests.post(GLM_IMAGE_API_URL, headers=headers, json=payload, timeout=180)
        resp.raise_for_status()
        data = resp.json()
        url = data.get("data", [{}])[0].get("url")
        if not url:
            st.error(f"GLM-Image returned no URL. Response: {json.dumps(data)[:300]}")
            return None
        img_resp = requests.get(url, timeout=60)
        img_resp.raise_for_status()
        return Image.open(io.BytesIO(img_resp.content)).convert("RGB")
    except requests.exceptions.Timeout:
        st.error("GLM-Image request timed out (180s). The model may be under heavy load.")
        return None
    except requests.exceptions.HTTPError as e:
        st.error(f"GLM-Image HTTP error: {e} — {e.response.text[:300] if e.response else ''}")
        return None
    except Exception as e:
        st.error(f"GLM-Image error: {e}")
        return None

Zo handelt de bovenstaande functie de volledige workflow voor beeldgeneratie met GLM-Image af:

  • De functie generate_glm_image() is het startpunt voor elke slide. Ze bouwt eerst de requestpayload met vereiste velden zoals de glm-image-modelidentifier, de slideprompt (die al lay-out, tekst, thema en grafiekinstructies bevat) en de doelresolutie, zoals 1728x960. 
  • Vervolgens stuurt de functie deze payload naar het Z.ai-beeldgeneratie-endpoint met requests.post() en een langere timeout, omdat beeldgeneratie doorgaans langer duurt dan tekstresponsen.
  • Zodra het verzoek slaagt, extraheert de functie de gehoste afbeeldings-URL uit de API-respons, downloadt de image-bytes met requests.get(), valideert de response en zet de inhoud om naar een RGB-PIL.Image-object. 

Je kunt experimenteren met verschillende resolutiewaarden om de kwaliteit-latencybalans te verkennen en te begrijpen hoe de outputgrootte de generatietijd en visuele helderheid beïnvloedt.

Stap 8: Bouw de Streamlit-UI

Tot slot integreren we alle functies in de Streamlit-workflow om slides sequentieel te genereren, voortgang te volgen en het uiteindelijke deck te verpakken voor download.

def render_theme_preview(name: str, theme: Dict):
    c = theme["colors"]
    return (
        f'<div style="display:inline-flex;align-items:center;gap:6px;">'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["bg"]};border:1px solid #555;"></span>'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["accent"]};"></span>'
        f'<span style="display:inline-block;width:14px;height:14px;border-radius:3px;background:{c["text"]};border:1px solid #555;"></span>'
        f'<span style="">{name}</span>'
        f'</div>'
    )
def main():
    st.set_page_config(
        page_title="GLM-Image Infographic Generator",
        page_icon="📊",
        layout="wide",
        initial_sidebar_state="expanded",
    )
    st.markdown("""
    <style>
    @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
    @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap');
    .stApp {}
    .hero-banner {
        background: linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #0F172A 100%);
        
        border-radius: 16px;
        
        border: 1px solid #334155;
        position: relative;
        overflow: hidden;
    }
    .hero-banner::before {
        content: '';
        position: absolute;
        top: -50%;
        right: -20%;
        width: 400px;
        height: 400px;
        background: radial-gradient(circle, rgba(56,189,248,0.08) 0%, transparent 70%);
        border-radius: 50%;
    }
    .hero-banner h1 {
        
        
        font-weight: 800;
        
        letter-spacing: -0.5px;
    }
    .hero-banner .subtitle {
        
        font-size: 0.95rem;
        
    }
    .pipeline-tag {
        display: inline-block;
        
        background: rgba(56,189,248,0.1);
        
        
        border-radius: 20px;
        
        font-weight: 600;
        
        letter-spacing: 0.3px;
        border: 1px solid rgba(56,189,248,0.2);
    }
    .phase-header {
        
        
        letter-spacing: 1px;
        
        text-transform: uppercase;
        
    }
    .cost-card {
        background: #1E293B;
        border: 1px solid #334155;
        border-radius: 10px;
        
        
    }
    .cost-card .total {
        
        font-weight: 800;
        
    }
    .slide-nav-btn {
        background: #1E293B;
        border: 1px solid #334155;
        
        
        border-radius: 8px;
        cursor: pointer;
        font-weight: 600;
    }
    #MainMenu {visibility: hidden;}
    footer {visibility: hidden;}
    </style>
    """, unsafe_allow_html=True)
    st.markdown("""
    <div class="hero-banner">
        <h1>GLM-Image Infographic Generator</h1>
        <p class="subtitle">Enter any topic → AI researches the web → GLM-Image generates a full infographic slide deck</p>
        <div class="pipeline-tag">GEMINI 2.5 FLASH and GLM-IMAGE </div>
    </div>
    """, unsafe_allow_html=True)
    with st.sidebar:
        openrouter_key = os.environ.get("OPENROUTER_API_KEY", "")
        zai_key = os.environ.get("ZAI_API_KEY", "")
        st.markdown("#### Visual Theme")
        theme_name = st.selectbox(
            "Select Theme",
            list(THEMES.keys()),
            index=0,
        )
        theme = THEMES[theme_name]
        st.caption(theme["desc"])
        c = theme["colors"]
        st.markdown(
            f'<div style="display:flex;gap:8px;">'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["bg"]};border:1px solid #555;" title="Background"></div>'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["accent"]};" title="Accent"></div>'
            f'<div style="flex:1;height:32px;border-radius:6px;background:{c["text"]};border:1px solid #555;" title="Text"></div>'
            f'</div>',
            unsafe_allow_html=True,
        )
        st.markdown("#### Slide Settings")
        resolution = st.selectbox("Resolution", list(RESOLUTIONS.keys()), index=0)
        num_slides = st.slider("Number of Slides", 4, 10, 5)
        st.divider()
    if "selected_example" in st.session_state:
        default_topic = st.session_state.pop("selected_example")
    else:
        default_topic = ""
    topic = st.text_input(
        "Enter a topic to research and visualize",
        value=default_topic,
        placeholder="e.g. AI Industry Market Trends January 2026",
    )
    st.markdown("**Quick examples:**")
    cols = st.columns(3)
    for i, ex in enumerate(EXAMPLE_TOPICS):
        with cols[i % 3]:
            if st.button(ex, key=f"ex_{i}", use_container_width=True):
                st.session_state["selected_example"] = ex
                st.rerun()
    st.markdown("---")
    generate = st.button(
        "Generate Infographic Deck",
        type="primary",
        use_container_width=True,
        disabled=(not topic or not openrouter_key or not zai_key),
    )
    if generate:
        if not openrouter_key:
            st.error("Please enter your **OpenRouter API Key** in the sidebar.")
            st.stop()
        if not zai_key:
            st.error("Please enter your **Z.ai API Key** in the sidebar.")
            st.stop()
        size = RESOLUTIONS[resolution]
        st.markdown('<div class="phase-header">Phase 1 — Research & Structure</div>', unsafe_allow_html=True)
        with st.status("Researching topic with Gemini 2.5 Flash + Web Search...", expanded=True) as status:
            st.markdown(f"**Topic:** {topic}")
            st.markdown(f"**Model:** {OPENROUTER_MODEL}")
            t0 = time.time()
            try:
                research = research_topic(topic, openrouter_key)
                t_research = time.time() - t0
                st.success(f"Research complete in {t_research:.1f}s")
                status.update(label="Research complete", state="complete")
            except Exception as e:
                st.error(f"Research failed: {e}")
                status.update(label="Research failed", state="error")
                st.stop()
        with st.status("Structuring slide content...", expanded=False) as status:
            try:
                slides_data = structure_slides(topic, research, num_slides, openrouter_key)
                st.success(f"Structured {len(slides_data)} slides")
                status.update(label=f"{len(slides_data)} slides structured", state="complete")
            except Exception as e:
                st.error(f"Structuring failed: {e}")
                status.update(label="Structuring failed", state="error")
                st.stop()
        with st.expander("Research Summary", expanded=False):
            st.markdown(research)
        with st.expander("Slide JSON", expanded=False):
            st.json(slides_data)
        st.markdown("---")
        st.markdown('<div class="phase-header">Phase 2 — GLM-Image Generation</div>', unsafe_allow_html=True)
        progress = st.progress(0, text="Starting image generation...")
        images: List[Optional[Image.Image]] = []
        prompts: List[str] = []
        timings: List[float] = []
        for i, slide in enumerate(slides_data):
            headline = slide.get("headline", f"Slide {i+1}")
            progress.progress(
                i / len(slides_data),
                text=f"Generating slide {i+1}/{len(slides_data)}: {headline}",
            )
            prompt = build_glm_prompt(slide, theme, len(slides_data), topic)
            prompts.append(prompt)
            t0 = time.time()
            img = generate_glm_image(prompt, zai_key, size)
            elapsed = time.time() - t0
            timings.append(elapsed)
            images.append(img)
            if img:
                st.toast(f"Slide {i+1} generated ({elapsed:.1f}s)")
            else:
                st.toast(f"Slide {i+1} failed", icon="")
        progress.progress(1.0, text="All slides generated!")
        time.sleep(0.3)
        progress.empty()
        st.session_state["images"] = images
        st.session_state["slides_data"] = slides_data
        st.session_state["prompts"] = prompts
        st.session_state["timings"] = timings
        st.session_state["research"] = research
        st.session_state["gen_topic"] = topic
        st.session_state["gen_theme"] = theme_name
    if "images" in st.session_state and st.session_state["images"]:
        images = st.session_state["images"]
        slides_data = st.session_state["slides_data"]
        prompts = st.session_state.get("prompts", [])
        timings = st.session_state.get("timings", [])
        st.markdown("---")
        gen_topic = st.session_state.get("gen_topic", "")
        gen_theme = st.session_state.get("gen_theme", "")
        success_count = sum(1 for img in images if img is not None)
        st.markdown(
            f"### Infographic Deck: *{gen_topic}*\n"
            f"{success_count}/{len(images)} slides · {gen_theme} theme · "
            f"{list(RESOLUTIONS.keys())[0]} resolution"
        )
        slide_idx = st.select_slider(
            "Navigate Slides",
            options=list(range(1, len(images) + 1)),
            value=1,
            format_func=lambda x: f"Slide {x}: {slides_data[x-1].get('headline', '') if x-1 < len(slides_data) else ''}",
        )
        idx = slide_idx - 1
        col_img, col_meta = st.columns([3, 1])
        with col_img:
            if images[idx] is not None:
                st.image(images[idx], use_container_width=True)
            else:
                st.warning(
                    f"Slide {slide_idx} failed to generate. "
                    "Check your Z.ai API key and quota."
                )
        with col_meta:
            slide = slides_data[idx] if idx < len(slides_data) else {}
            st.markdown(f"**Slide {slide_idx} / {len(images)}**")
            slide_type = slide.get('type', 'N/A')
            layout = slide.get('layout', '')
            type_label = f"{slide_type}" + (f" · {layout}" if layout else "")
            st.markdown(f"**Type:** {type_label}")
            if slide.get("key_stat"):
                tc = THEMES.get(gen_theme, THEMES["Corporate Navy"])["colors"]
                st.markdown(
                    f'<div style="background:{tc["bg"]};border:2px solid {tc["accent"]}; '
                    f'border-radius:12px;text-align:center;">'
                    f'<div style="font-weight:800;color:{tc["accent"]};">'
                    f'{slide["key_stat"]}</div>'
                    f'<div style="color:{tc["text"]};opacity:0.7;">'
                    f'{slide.get("key_stat_label","")}</div></div>',
                    unsafe_allow_html=True,
                )
            if slide.get("bullets"):
                st.markdown("**Data Points:**")
                for b in slide["bullets"]:
                    st.markdown(f"• {b}")
            if slide.get("items"):
                st.markdown("**Comparison:**")
                for item in slide["items"]:
                    st.markdown(f"**{item.get('label', '')}**")
                    for p in item.get("points", []):
                        st.markdown(f"  • {p}")
            if slide.get("milestones"):
                st.markdown("**Timeline:**")
                for m in slide["milestones"]:
                    st.markdown(f"• **{m.get('date', '')}** — {m.get('event', '')}")
            if slide.get("steps"):
                st.markdown("**Process:**")
                for s in slide["steps"]:
                    st.markdown(f"{s.get('step', '')}. **{s.get('title', '')}** — {s.get('detail', '')}")
            if slide.get("takeaways"):
                st.markdown("**Takeaways:**")
                for i, t in enumerate(slide["takeaways"], 1):
                    st.markdown(f"{i}. {t}")
            if idx < len(timings):
                st.caption(f"Generated in {timings[idx]:.1f}s")
        if idx < len(prompts):
            with st.expander(f"GLM-Image Prompt — Slide {slide_idx}"):
                st.code(prompts[idx], language="text")
        st.markdown("---")
        st.markdown("**All Slides:**")
        thumb_cols = st.columns(min(len(images), 8))
        for i, img in enumerate(images):
            with thumb_cols[i % len(thumb_cols)]:
                if img is not None:
                    st.image(img, caption=f"Slide {i+1}", use_container_width=True)
                else:
                    st.caption(f"Slide {i+1}")
        st.markdown("---")
        dl_col1, dl_col2, dl_col3, _ = st.columns([1, 1, 1, 1])
        with dl_col1:
            zip_buf = io.BytesIO()
            with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
                for i, img in enumerate(images):
                    if img is not None:
                        buf = io.BytesIO()
                        img.save(buf, format="PNG", quality=95)
                        zf.writestr(f"slide_{i+1:02d}.png", buf.getvalue())
                zf.writestr("slide_data.json", json.dumps(slides_data, indent=2, ensure_ascii=False))
                zf.writestr("prompts.json", json.dumps(prompts, indent=2, ensure_ascii=False))
            safe_name = re.sub(r"[^a-zA-Z0-9]+", "_", gen_topic)[:40]
            st.download_button(
                "Download ZIP (all slides)",
                data=zip_buf.getvalue(),
                file_name=f"infographic_{safe_name}.zip",
                mime="application/zip",
                use_container_width=True,
            )
        with dl_col2:
            st.download_button(
                "Download Slide JSON",
                data=json.dumps(slides_data, indent=2, ensure_ascii=False),
                file_name=f"slides_{safe_name}.json",
                mime="application/json",
                use_container_width=True,
            )
    elif "images" not in st.session_state:
        st.markdown(
            '<div style="text-align:center;">'
            '<div style="opacity:0.3;">📊</div>'
            '<p style="max-width:500px;">'
            "Enter any topic above, and the system will search the web for the latest data, "
            "structure it into slides, and generate a complete infographic deck using GLM-Image."
            "</p></div>",
            unsafe_allow_html=True,
        )
if __name__ == "__main__":
    main()

De bovenstaande code definieert de complete Streamlit-applicatie die alle stadia van de pijplijn verbindt in één workflow.

  • De functie main() is het startpunt dat de paginalay-out configureert, aangepaste styling toepast en de banner rendert. De sidebar fungeert vervolgens als configuratiepaneel, waar gebruikers een visueel thema kunnen kiezen, de kleuren kunnen voorvertonen, de outputresolutie selecteren en het aantal slides bepalen. Deze instellingen beïnvloeden rechtstreeks de promptconstructie en beeldgeneratie.
  • De hoofdinterface verzamelt de onderwerp­input en biedt snelle voorbeeldknoppen voor sneller experimenteren. De Generate Infographic Deck-knop valideert dat het onderwerp en de vereiste API-sleutels aanwezig zijn voordat de uitvoering start.
  • Eenmaal gestart, draait de workflow in twee fasen. Eerst voert het systeem webonderzoek uit en zet de resultaten om in gestructureerde slide-JSON met het taalmodel, met voortgangsindicatoren en uitklapbare secties om de onderzoeks­samenvatting en gestructureerde output te bekijken. Vervolgens genereert de app sequentieel afbeeldingen met GLM-Image, met een voortgangsbalk, timing per slide en toastmeldingen, terwijl afbeeldingen, prompts en metadata in st.session_state worden opgeslagen zodat resultaten tussen reruns behouden blijven.
  • Na de generatie schakelt de interface over naar een deckviewer waarin gebruikers kunnen navigeren tussen slides, gestructureerde inhoudsdetails kunnen bekijken, de exacte generatieprompt kunnen inspecteren en thumbnails kunnen bekijken. 
  • Tot slot verpakt de app alle outputs, inclusief slide-afbeeldingen, slide-JSON en prompts, in een downloadbare ZIP, met aparte JSON-exportopties.

Wil je het zelf proberen? Sla de code op als app.py en start:

Streamlit run app.py

image5.png

Conclusie

In deze tutorial hebben we een realtime generator voor infografische decks gebouwd: een gebruiker voert een onderwerp in, Gemini 2.5 Flash voert webgebaseerd onderzoek uit en structureert de content in een slideplan, en GLM-Image rendert elke slide als een infografische afbeelding met hoge resolutie. De sleutel hiervoor is een pijplijn bouwen die ongestructureerde informatie omzet in een begrensde renderspecificatie. 

Toch is er ruimte voor verbetering. Het GLM-Image-model spelt af en toe woorden verkeerd, wat een veelvoorkomende foutmodus is voor de meeste beeldgeneratiemodellen bij het produceren van lange, dichte tekst in afbeeldingen.

Vanaf hier kun je deze demo uitbouwen tot een productieklare oplossing door bronvermelding toe te voegen, herhalingschecks en per-slide regeneratie. Maar zelfs in de huidige vorm is de app een sterk voorbeeld van hoe beeldgeneratiemodellen kunnen worden ingezet als visuele renderengines voor dataverhalen.

Bekijk ook zeker onze gids over Google’s nieuwe beeldgeneratiemodel, Nano Banana 2!

GLM Image FAQ's

Heb ik een GPU nodig om dit GLM Image-project te draaien?

Nee. De applicatie draait volledig via API’s (OpenRouter voor tekst en GLM-Image voor visuals), dus er is geen lokale GPU nodig. Een standaardmachine met een stabiele internetverbinding is voldoende.

Hoeveel kost het om een deck te genereren met GLM Image?

De kosten hangen af van het aantal slides en het API-gebruik. Voor typische runs (4–8 slides) is een paar dollar aan OpenRouter- en Z.ai-tegoed meestal genoeg. Ongeveer $5 op elk account is voldoende voor meerdere experimenten.

Kan ik een ander tekstmodel gebruiken in plaats van Gemini 2.5 Flash?

Omdat het project OpenRouter gebruikt, kun je overschakelen naar andere ondersteunde modellen zoals OpenAI, Claude of andere Gemini-varianten. Werk gewoon de modelnaam bij op basis van je voorkeursprovider en kosten/prestatiebehoeften.

Kan ik de visuele stijl van de slides aanpassen?

Ja. De app bevat themaselectie (achtergrond-, accent- en tekstkleuren), resolutie-instellingen en regelaars voor het aantal slides. Je kunt ook de the­madictionary uitbreiden of prompttemplates aanpassen om eigen stijlen te creëren.

Kan ik Gemini rechtstreeks gebruiken in plaats van OpenRouter?

Ja. OpenRouter wordt hier voor het gemak gebruikt, maar je kunt Gemini 2.5 Flash ook direct aanroepen via Google AI Studio of Vertex AI. Vervang simpelweg het OpenRouter-endpoint door de officiële Gemini-API en behoud dezelfde berichtstructuur.


Aashi Dutt's photo
Author
Aashi Dutt
LinkedIn
Twitter

Ik ben een Google Developers Expert in ML (Gen AI), een Kaggle 3x Expert en een Women Techmakers Ambassador met meer dan 3 jaar ervaring in tech. In 2020 heb ik een healthtech-startup mee opgericht en ik volg een master computer science aan Georgia Tech, met als specialisatie machine learning.

Onderwerpen
Kunstmatige intelligentie
Large Language Models

Topcursussen bij DataCamp

Cursus

Agentic Systems ontwerpen met LangChain

3 uur
14.8K
Leer de basis van LangChain-agents kennen en maak je eigen chatagents.
Bekijk detailsRight Arrow
Start Cursus
Meer bekijkenRight Arrow
Gerelateerd

blog

AI vanaf nul leren in 2026: een complete gids van de experts

Ontdek alles wat je moet weten om in 2026 AI te leren, van tips om te beginnen tot handige resources en inzichten van industrie-experts.
Adel Nehme's photo

Adel Nehme

15 min

Meer BekijkenMeer Bekijken