17. Bildgenerierung und Bildverwaltung
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