Artikel

17. Bildgenerierung und Bildverwaltung

Allgemein

Bilder sind in diesem System eigene Content-Objekte. Sie werden nicht erst beim Posten gesucht oder improvisiert, sondern vorbereitet, gespeichert, geprüft und mit Metadaten versehen.

Für einen Verlag sind vier Bildtypen relevant:

Covermotive
Social-Media-Grafiken
Zitatkarten
Blog- und Link-Vorschaubilder

Die Bildpipeline bleibt lokal:

Bildquelle
    ↓
Validierung
    ↓
Formatvariante
    ↓
Zuschnitt
    ↓
Dateiname
    ↓
Metadaten
    ↓
Posting-Queue

17.1 Bildquellen

Mögliche Bildquellen:

bestehende Covermotive
KI-generierte Motive
selbst gestaltete Grafiken
Zitatkarten
Blog-Titelbilder
Autorenbilder
gemeinfreie Abbildungen

Für das System werden alle Bilder als strukturierte Objekte behandelt.

Beispiel:

{
  "id": "image_001",
  "source_type": "cover",
  "book_id": "book_001",
  "title": "Stolz und Vorurteil Covermotiv",
  "image_path": "storage/images/source/stolz-und-vorurteil.jpg",
  "alt_text": "Malerisches Covermotiv zu Stolz und Vorurteil mit historischer Figur vor englischem Landsitz.",
  "rights_status": "owned",
  "usable_for": [
    "linkedin",
    "instagram",
    "facebook"
  ],
  "tags": [
    "klassiker",
    "jane-austen",
    "covermotiv"
  ]
}

Datenmodell:

from dataclasses import dataclass


@dataclass(frozen=True)
class ImageAsset:
    id: str
    source_type: str
    book_id: str | None
    title: str
    image_path: str
    alt_text: str
    rights_status: str
    usable_for: list[str]
    tags: list[str]

Erlaubte source_type-Werte:

cover
social_graphic
quote_card
blog_header
author_image

Erlaubte rights_status-Werte:

owned
public_domain
licensed
generated

Bilder ohne geklärte Rechte werden nicht verwendet.

17.2 Covermotiv vs. Social-Media-Grafik

Ein Covermotiv ist nicht automatisch eine gute Social-Media-Grafik.

Covermotiv:

für Buchcover gedacht
meist hochformatig
oft ohne Text nutzbar
bildet Stimmung oder Motiv des Buches ab
muss nicht im Feed optimal funktionieren

Social-Media-Grafik:

für Feed-Ansicht optimiert
klarer Bildausschnitt
funktioniert auch klein
hat definierte Plattformgröße
enthält optional Text
hat genug Rand für Crop

Für das Verlagssystem ist die Unterscheidung wichtig:

Covermotiv bleibt Original.
Social-Media-Grafik ist eine abgeleitete Variante.

Verzeichnisstruktur:

storage/images/
├── source/
│   ├── stolz-und-vorurteil.jpg
│   └── paradies-der-damen.jpg
├── prepared/
│   ├── linkedin/
│   ├── instagram/
│   └── facebook/
└── quote_cards/

Das Original wird nicht überschrieben.

17.3 Dateinamen-Konvention

Dateinamen müssen maschinenlesbar, stabil und frei von Sonderzeichen sein.

Schema:

{platform}_{content_type}_{source_id}_{width}x{height}.jpg

Beispiele:

linkedin_cover_book_001_1200x627.jpg
instagram_cover_book_001_1080x1350.jpg
facebook_quote_quote_001_1080x1080.jpg

Normalisierung:

class FilenameNormalizer:
    def normalize(self, value: str) -> str:
        lowered_value = value.lower().strip()

        replacements: dict[str, str] = {
            "ä": "ae",
            "ö": "oe",
            "ü": "ue",
            "ß": "ss",
            "é": "e",
            "è": "e",
            "á": "a",
            "à": "a",
            "ô": "o",
            " ": "-",
            "_": "-",
        }

        for search, replacement in replacements.items():
            lowered_value = lowered_value.replace(search, replacement)

        result = ""

        for character in lowered_value:
            if character.isalnum() is True or character == "-":
                result = result + character

        while "--" in result:
            result = result.replace("--", "-")

        return result.strip("-")

Dateiname erzeugen:

class ImageFilenameFactory:
    def __init__(self) -> None:
        self.normalizer = FilenameNormalizer()

    def create(
        self,
        platform: str,
        content_type: str,
        source_id: str,
        width: int,
        height: int,
        extension: str,
    ) -> str:
        normalized_platform = self.normalizer.normalize(platform)
        normalized_content_type = self.normalizer.normalize(content_type)
        normalized_source_id = self.normalizer.normalize(source_id)

        return (
            normalized_platform
            + "_"
            + normalized_content_type
            + "_"
            + normalized_source_id
            + "_"
            + str(width)
            + "x"
            + str(height)
            + extension
        )

17.4 Bildgrößen pro Plattform

Für das MVP reichen wenige Zielgrößen.

LinkedIn Linkbild: 1200 × 627
LinkedIn Feedbild: 1200 × 1200
Instagram Feed Hochformat: 1080 × 1350
Instagram Feed Quadrat: 1080 × 1080
Facebook Feed Quadrat: 1080 × 1080
Facebook Feed Hochformat: 1080 × 1350
Facebook Linkbild: 1200 × 630

LinkedIn nennt für benutzerdefinierte Bilder in Page-Posts mit URL ein Verhältnis von 1.91:1 und 1200 × 627 Pixeln; für Single-Image-Ads nennt LinkedIn unter anderem 4:5 und 2:3 als empfohlene vertikale Varianten. (LinkedIn)

Für Instagram-Feedposts sind 1080 × 1080, 1080 × 1350 und 1080 × 566 gängige Formate; aktuelle Größenübersichten nennen 1080 × 1350 als besonders relevantes Hochformat im Feed. (Buffer)

Meta nennt für Facebook-Feed-Anzeigen unter anderem 1:1 und 4:5, mit empfohlenen Mindestgrößen von 1080 × 1080 beziehungsweise 1440 × 1800 für Ads; für organische Verlagsposts reicht im MVP eine lokale 1080er-Variante. (Facebook)

Konfiguration:

from dataclasses import dataclass


@dataclass(frozen=True)
class ImageTargetSize:
    platform: str
    variant: str
    width: int
    height: int


IMAGE_TARGET_SIZES: list[ImageTargetSize] = [
    ImageTargetSize(
        platform="linkedin",
        variant="link",
        width=1200,
        height=627,
    ),
    ImageTargetSize(
        platform="linkedin",
        variant="feed_square",
        width=1200,
        height=1200,
    ),
    ImageTargetSize(
        platform="instagram",
        variant="feed_portrait",
        width=1080,
        height=1350,
    ),
    ImageTargetSize(
        platform="instagram",
        variant="feed_square",
        width=1080,
        height=1080,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="feed_square",
        width=1080,
        height=1080,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="feed_portrait",
        width=1080,
        height=1350,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="link",
        width=1200,
        height=630,
    ),
]

17.5 Automatisches Zuschneiden

Automatisches Zuschneiden muss kontrolliert erfolgen. Ein blindes Center-Crop kann wichtige Bildteile abschneiden.

Für das MVP reicht:

Bild öffnen
auf Zielverhältnis zuschneiden
zentriert beschneiden
auf Zielgröße skalieren
speichern

Abhängigkeit:

pip install pillow

Crop-Service:

from pathlib import Path

from PIL import Image


class ImageCropper:
    def crop_to_size(
        self,
        source_path: Path,
        target_path: Path,
        target_width: int,
        target_height: int,
    ) -> None:
        if source_path.exists() is False:
            raise RuntimeError("Source image does not exist: " + str(source_path))

        target_path.parent.mkdir(parents=True, exist_ok=True)

        with Image.open(source_path) as image:
            converted_image = image.convert("RGB")
            source_width, source_height = converted_image.size

            target_ratio = target_width / target_height
            source_ratio = source_width / source_height

            if source_ratio > target_ratio:
                new_width = int(source_height * target_ratio)
                left = int((source_width - new_width) / 2)
                upper = 0
                right = left + new_width
                lower = source_height
            else:
                new_height = int(source_width / target_ratio)
                left = 0
                upper = int((source_height - new_height) / 2)
                right = source_width
                lower = upper + new_height

            cropped_image = converted_image.crop(
                (
                    left,
                    upper,
                    right,
                    lower,
                )
            )

            resized_image = cropped_image.resize(
                (
                    target_width,
                    target_height,
                )
            )

            resized_image.save(
                target_path,
                quality=92,
                optimize=True,
            )

Problemfälle:

Gesichter am Rand
Buchmotiv nicht zentriert
Text im Bild
sehr schmale Cover
sehr breite Landschaften

Für diese Fälle sollte das System später Fokuszonen speichern.

Beispiel:

{
  "image_id": "image_001",
  "focus_x": 0.5,
  "focus_y": 0.35
}

Für das MVP bleibt Center-Crop ausreichend.

17.6 Textfreie Motive

Für Covermotive und Social-Media-Bilder sollte eine Grundregel gelten:

Motivbilder bleiben textfrei.
Text kommt nur in Zitatkarten oder bewusst gestaltete Grafiken.

Gründe:

KI-Schrift ist oft fehlerhaft
Plattform-Crops schneiden Text ab
Text im Bild erschwert Wiederverwendung
Alt-Text und Caption sind besser kontrollierbar

Regel im System:

class ImageUsagePolicy:
    def assert_text_allowed(self, image_type: str) -> None:
        text_allowed_types = [
            "quote_card",
            "social_graphic",
        ]

        if image_type not in text_allowed_types:
            raise RuntimeError("Text is not allowed for image type: " + image_type)

Für normale Covermotive:

keine Schrift im Motiv
keine Logos im Bild
keine zufälligen KI-Buchstaben
keine falschen Titel

Für die spätere Posting-Pipeline heißt das:

Bildmotiv: textfrei
Caption: Text
Zitatkarte: kontrollierter Text mit lokaler Schrift

17.7 Zitatkarten

Zitatkarten sind gezielt gestaltete Bilder mit Text.

Sie eignen sich für:

Instagram
Facebook
gelegentlich LinkedIn

Bestandteile:

Zitat
Autor
Werk
Hintergrundbild oder Farbfläche
klare Typografie
kein zu langer Text

Datenmodell:

from dataclasses import dataclass


@dataclass(frozen=True)
class QuoteCardRequest:
    quote_id: str
    quote_text: str
    author: str
    work_title: str
    output_path: Path
    width: int
    height: int

Grundregel:

maximal 220 Zeichen Zitattext
sonst nicht als Zitatkarte verwenden

Validator:

class QuoteCardValidator:
    def validate(self, request: QuoteCardRequest) -> None:
        if request.quote_text.strip() == "":
            raise RuntimeError("Quote text must not be empty.")

        if len(request.quote_text) > 220:
            raise RuntimeError("Quote text is too long for a quote card.")

        if request.author.strip() == "":
            raise RuntimeError("Author must not be empty.")

        if request.work_title.strip() == "":
            raise RuntimeError("Work title must not be empty.")

Einfache Zitatkarte mit Pillow:

from pathlib import Path

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont


class QuoteCardGenerator:
    def create(self, request: QuoteCardRequest) -> None:
        QuoteCardValidator().validate(request)

        request.output_path.parent.mkdir(parents=True, exist_ok=True)

        image = Image.new(
            mode="RGB",
            size=(
                request.width,
                request.height,
            ),
            color=(245, 245, 245),
        )

        draw = ImageDraw.Draw(image)

        font = ImageFont.load_default()
        small_font = ImageFont.load_default()

        margin = int(request.width * 0.10)
        max_text_width = request.width - margin * 2

        quote_lines = self._wrap_text(
            text="»" + request.quote_text + "«",
            font=font,
            max_width=max_text_width,
            draw=draw,
        )

        y = int(request.height * 0.25)

        for line in quote_lines:
            draw.text(
                (
                    margin,
                    y,
                ),
                line,
                fill=(20, 20, 20),
                font=font,
            )
            y += 28

        y += 40

        draw.text(
            (
                margin,
                y,
            ),
            request.author,
            fill=(20, 20, 20),
            font=small_font,
        )

        y += 28

        draw.text(
            (
                margin,
                y,
            ),
            request.work_title,
            fill=(80, 80, 80),
            font=small_font,
        )

        image.save(
            request.output_path,
            quality=92,
            optimize=True,
        )

    def _wrap_text(
        self,
        text: str,
        font: ImageFont.ImageFont,
        max_width: int,
        draw: ImageDraw.ImageDraw,
    ) -> list[str]:
        words = text.split()
        lines: list[str] = []
        current_line = ""

        for word in words:
            if current_line == "":
                candidate = word
            else:
                candidate = current_line + " " + word

            bounding_box = draw.textbbox(
                (
                    0,
                    0,
                ),
                candidate,
                font=font,
            )

            candidate_width = bounding_box[2] - bounding_box[0]

            if candidate_width <= max_width:
                current_line = candidate
            else:
                if current_line != "":
                    lines.append(current_line)

                current_line = word

        if current_line != "":
            lines.append(current_line)

        return lines

Hinweis für ein echtes Projekt: ImageFont.load_default() ist nur ein technischer Platzhalter. Produktiv sollte eine fest definierte, lokal installierte Schrift verwendet werden. Schriftdateien werden nicht im Repository verteilt, wenn die Lizenz das nicht ausdrücklich erlaubt.

17.8 Beispiel: image_prepare.py

Datei:

src/image_prepare.py

import argparse
from dataclasses import dataclass
from pathlib import Path

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont


class ImagePrepareError(Exception):
    pass


@dataclass(frozen=True)
class ImageTargetSize:
    platform: str
    variant: str
    width: int
    height: int


@dataclass(frozen=True)
class QuoteCardRequest:
    quote_id: str
    quote_text: str
    author: str
    work_title: str
    output_path: Path
    width: int
    height: int


IMAGE_TARGET_SIZES: list[ImageTargetSize] = [
    ImageTargetSize(
        platform="linkedin",
        variant="link",
        width=1200,
        height=627,
    ),
    ImageTargetSize(
        platform="linkedin",
        variant="feed_square",
        width=1200,
        height=1200,
    ),
    ImageTargetSize(
        platform="instagram",
        variant="feed_portrait",
        width=1080,
        height=1350,
    ),
    ImageTargetSize(
        platform="instagram",
        variant="feed_square",
        width=1080,
        height=1080,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="feed_square",
        width=1080,
        height=1080,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="feed_portrait",
        width=1080,
        height=1350,
    ),
    ImageTargetSize(
        platform="facebook",
        variant="link",
        width=1200,
        height=630,
    ),
]


class FilenameNormalizer:
    def normalize(self, value: str) -> str:
        lowered_value = value.lower().strip()

        replacements: dict[str, str] = {
            "ä": "ae",
            "ö": "oe",
            "ü": "ue",
            "ß": "ss",
            "é": "e",
            "è": "e",
            "á": "a",
            "à": "a",
            "ô": "o",
            " ": "-",
            "_": "-",
        }

        for search, replacement in replacements.items():
            lowered_value = lowered_value.replace(search, replacement)

        result = ""

        for character in lowered_value:
            if character.isalnum() is True or character == "-":
                result = result + character

        while "--" in result:
            result = result.replace("--", "-")

        return result.strip("-")


class ImageFilenameFactory:
    def __init__(self) -> None:
        self.normalizer = FilenameNormalizer()

    def create(
        self,
        platform: str,
        content_type: str,
        source_id: str,
        width: int,
        height: int,
        extension: str,
    ) -> str:
        normalized_platform = self.normalizer.normalize(platform)
        normalized_content_type = self.normalizer.normalize(content_type)
        normalized_source_id = self.normalizer.normalize(source_id)

        return (
            normalized_platform
            + "_"
            + normalized_content_type
            + "_"
            + normalized_source_id
            + "_"
            + str(width)
            + "x"
            + str(height)
            + extension
        )


class ImageCropper:
    def crop_to_size(
        self,
        source_path: Path,
        target_path: Path,
        target_width: int,
        target_height: int,
    ) -> None:
        if source_path.exists() is False:
            raise ImagePrepareError("Source image does not exist: " + str(source_path))

        if source_path.is_file() is False:
            raise ImagePrepareError("Source path is not a file: " + str(source_path))

        target_path.parent.mkdir(parents=True, exist_ok=True)

        with Image.open(source_path) as image:
            converted_image = image.convert("RGB")
            source_width, source_height = converted_image.size

            target_ratio = target_width / target_height
            source_ratio = source_width / source_height

            if source_ratio > target_ratio:
                new_width = int(source_height * target_ratio)
                left = int((source_width - new_width) / 2)
                upper = 0
                right = left + new_width
                lower = source_height
            else:
                new_height = int(source_width / target_ratio)
                left = 0
                upper = int((source_height - new_height) / 2)
                right = source_width
                lower = upper + new_height

            cropped_image = converted_image.crop(
                (
                    left,
                    upper,
                    right,
                    lower,
                )
            )

            resized_image = cropped_image.resize(
                (
                    target_width,
                    target_height,
                )
            )

            resized_image.save(
                target_path,
                quality=92,
                optimize=True,
            )


class ImagePrepareService:
    def __init__(self) -> None:
        self.filename_factory = ImageFilenameFactory()
        self.cropper = ImageCropper()

    def prepare_for_platform(
        self,
        source_path: Path,
        output_dir: Path,
        platform: str,
        variant: str,
        content_type: str,
        source_id: str,
    ) -> Path:
        target_size = self._find_target_size(platform, variant)

        output_filename = self.filename_factory.create(
            platform=target_size.platform,
            content_type=content_type,
            source_id=source_id,
            width=target_size.width,
            height=target_size.height,
            extension=".jpg",
        )

        target_path = output_dir / platform / output_filename

        self.cropper.crop_to_size(
            source_path=source_path,
            target_path=target_path,
            target_width=target_size.width,
            target_height=target_size.height,
        )

        return target_path

    def _find_target_size(self, platform: str, variant: str) -> ImageTargetSize:
        for target_size in IMAGE_TARGET_SIZES:
            if target_size.platform == platform and target_size.variant == variant:
                return target_size

        raise ImagePrepareError(
            "Missing target size for platform="
            + platform
            + ", variant="
            + variant
        )


class QuoteCardValidator:
    def validate(self, request: QuoteCardRequest) -> None:
        if request.quote_text.strip() == "":
            raise ImagePrepareError("Quote text must not be empty.")

        if len(request.quote_text) > 220:
            raise ImagePrepareError("Quote text is too long for a quote card.")

        if request.author.strip() == "":
            raise ImagePrepareError("Author must not be empty.")

        if request.work_title.strip() == "":
            raise ImagePrepareError("Work title must not be empty.")


class QuoteCardGenerator:
    def create(self, request: QuoteCardRequest) -> None:
        QuoteCardValidator().validate(request)

        request.output_path.parent.mkdir(parents=True, exist_ok=True)

        image = Image.new(
            mode="RGB",
            size=(
                request.width,
                request.height,
            ),
            color=(245, 245, 245),
        )

        draw = ImageDraw.Draw(image)

        font = ImageFont.load_default()
        small_font = ImageFont.load_default()

        margin = int(request.width * 0.10)
        max_text_width = request.width - margin * 2

        quote_lines = self._wrap_text(
            text="»" + request.quote_text + "«",
            font=font,
            max_width=max_text_width,
            draw=draw,
        )

        y = int(request.height * 0.25)

        for line in quote_lines:
            draw.text(
                (
                    margin,
                    y,
                ),
                line,
                fill=(20, 20, 20),
                font=font,
            )
            y += 28

        y += 40

        draw.text(
            (
                margin,
                y,
            ),
            request.author,
            fill=(20, 20, 20),
            font=small_font,
        )

        y += 28

        draw.text(
            (
                margin,
                y,
            ),
            request.work_title,
            fill=(80, 80, 80),
            font=small_font,
        )

        image.save(
            request.output_path,
            quality=92,
            optimize=True,
        )

    def _wrap_text(
        self,
        text: str,
        font: ImageFont.ImageFont,
        max_width: int,
        draw: ImageDraw.ImageDraw,
    ) -> list[str]:
        words = text.split()
        lines: list[str] = []
        current_line = ""

        for word in words:
            if current_line == "":
                candidate = word
            else:
                candidate = current_line + " " + word

            bounding_box = draw.textbbox(
                (
                    0,
                    0,
                ),
                candidate,
                font=font,
            )

            candidate_width = bounding_box[2] - bounding_box[0]

            if candidate_width <= max_width:
                current_line = candidate
            else:
                if current_line != "":
                    lines.append(current_line)

                current_line = word

        if current_line != "":
            lines.append(current_line)

        return lines


def run_prepare(args: argparse.Namespace) -> None:
    service = ImagePrepareService()

    result_path = service.prepare_for_platform(
        source_path=Path(args.source),
        output_dir=Path(args.output_dir),
        platform=args.platform,
        variant=args.variant,
        content_type=args.content_type,
        source_id=args.source_id,
    )

    print("IMAGE_PREPARED")
    print(str(result_path))


def run_quote_card(args: argparse.Namespace) -> None:
    request = QuoteCardRequest(
        quote_id=args.quote_id,
        quote_text=args.quote,
        author=args.author,
        work_title=args.work_title,
        output_path=Path(args.output),
        width=args.width,
        height=args.height,
    )

    generator = QuoteCardGenerator()
    generator.create(request)

    print("QUOTE_CARD_CREATED")
    print(str(request.output_path))


def main() -> None:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    prepare_parser = subparsers.add_parser("prepare")
    prepare_parser.add_argument("--source", required=True)
    prepare_parser.add_argument("--output-dir", required=True)
    prepare_parser.add_argument("--platform", required=True)
    prepare_parser.add_argument("--variant", required=True)
    prepare_parser.add_argument("--content-type", required=True)
    prepare_parser.add_argument("--source-id", required=True)

    quote_parser = subparsers.add_parser("quote-card")
    quote_parser.add_argument("--quote-id", required=True)
    quote_parser.add_argument("--quote", required=True)
    quote_parser.add_argument("--author", required=True)
    quote_parser.add_argument("--work-title", required=True)
    quote_parser.add_argument("--output", required=True)
    quote_parser.add_argument("--width", required=True, type=int)
    quote_parser.add_argument("--height", required=True, type=int)

    args = parser.parse_args()

    if args.command == "prepare":
        run_prepare(args)
        return

    if args.command == "quote-card":
        run_quote_card(args)
        return

    raise ImagePrepareError("Unsupported command: " + str(args.command))


if __name__ == "__main__":
    main()

Aufrufe

Instagram-Hochformat erzeugen:

python -m src.image_prepare prepare \
  --source storage/images/source/stolz-und-vorurteil.jpg \
  --output-dir storage/images/prepared \
  --platform instagram \
  --variant feed_portrait \
  --content-type cover \
  --source-id book_001

LinkedIn-Linkbild erzeugen:

python -m src.image_prepare prepare \
  --source storage/images/source/stolz-und-vorurteil.jpg \
  --output-dir storage/images/prepared \
  --platform linkedin \
  --variant link \
  --content-type cover \
  --source-id book_001

Zitatkarte erzeugen:

python -m src.image_prepare quote-card \
  --quote-id quote_001 \
  --quote "Es ist eine allgemein anerkannte Wahrheit, dass ein alleinstehender Mann im Besitz eines schönen Vermögens nichts dringender braucht als eine Frau." \
  --author "Jane Austen" \
  --work-title "Stolz und Vorurteil" \
  --output storage/images/quote_cards/instagram_quote_quote_001_1080x1350.jpg \
  --width 1080 \
  --height 1350

Ergebnis dieses Kapitels

Die Bildpipeline kann jetzt:

Bildquellen strukturiert verwalten
Originale und Plattformvarianten trennen
stabile Dateinamen erzeugen
Zielgrößen pro Plattform vorbereiten
Bilder automatisch zuschneiden
textfreie Motive erzwingen
Zitatkarten lokal erzeugen
vorbereitete Bilder an die Posting-Queue übergeben

0 Kommentare