scripts/ai_label.py
#!/usr/bin/env python3
"""AI-Kennzeichnung in ein Bild einbrennen, zuschneiden und fuers Web verkleinern.
Brennt unten rechts eine kleine Markierung ein, die Download und Weitergabe
ueberlebt. Farben und Text sind Parameter, damit die Markierung zum jeweiligen
Brand Kit passt.
Beispiel:
python ai_label.py roh.png -o hero.jpg --width 1120 --aspect 16:9
Mit eigenen Markenfarben:
python ai_label.py roh.png -o hero.jpg \\
--bg "#0B0E11" --fg "#F3F4EF" --line "#2A3037" --accent "#B7FF3C"
Wichtig: Das sichtbare Zeichen erfuellt die Offenlegung gegenueber Menschen.
Die maschinenlesbare Markierung ist etwas anderes und geht beim Neucodieren
verloren. Mit --iptc wird sie per exiftool wieder gesetzt, sofern exiftool
installiert ist.
Braucht Pillow: pip install Pillow
"""
import argparse
import os
import shutil
import subprocess
import sys
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
sys.exit("Pillow fehlt. Installieren mit: pip install Pillow")
# IPTC-Standardwert fuer vollstaendig KI-erzeugte Bilder
DIGITAL_SOURCE_TYPE = (
"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
)
# Schriftkandidaten fuer die Markierung, erste vorhandene gewinnt.
FONT_CANDIDATES = [
r"C:\Windows\Fonts\consolab.ttf",
"/System/Library/Fonts/Menlo.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf",
]
def hex_to_rgb(value):
v = value.lstrip("#")
if len(v) != 6:
raise argparse.ArgumentTypeError(f"Farbe muss #RRGGBB sein, war: {value}")
return tuple(int(v[i:i + 2], 16) for i in (0, 2, 4))
def parse_aspect(value):
if value is None:
return None
try:
w, h = value.replace(":", "/").split("/")
return float(w) / float(h)
except (ValueError, ZeroDivisionError):
raise argparse.ArgumentTypeError(f"Seitenverhaeltnis wie 16:9, war: {value}")
def find_font(explicit):
if explicit:
return explicit
for path in FONT_CANDIDATES:
if os.path.exists(path):
return path
return None
def crop_cover(im, ratio, anchor):
"""Mittig oder an einer Kante auf das Zielverhaeltnis zuschneiden."""
w, h = im.size
if abs(w / h - ratio) < 0.001:
return im
if w / h > ratio:
nw = int(round(h * ratio))
left = (w - nw) // 2
return im.crop((left, 0, left + nw, h))
nh = int(round(w / ratio))
# Die Markierung sitzt unten rechts. Wer oben anschneidet, behaelt sie.
top = (h - nh) if anchor == "bottom" else (0 if anchor == "top" else (h - nh) // 2)
return im.crop((0, top, w, top + nh))
def stamp(im, text, colors, scale):
"""Markierung unten rechts einbrennen."""
draw = ImageDraw.Draw(im, "RGBA")
w, h = im.size
pad = int(round(28 * scale))
box_h = int(round(62 * scale))
bar_w = max(2, int(round(4 * scale)))
gap = int(round(14 * scale))
inner = int(round(20 * scale))
text_size = max(9, int(round(34 * scale)))
font_path = find_font(colors["font"])
font = (ImageFont.truetype(font_path, text_size) if font_path
else ImageFont.load_default())
text_w = draw.textlength(text, font=font)
box_w = int(inner + bar_w + gap + text_w + inner)
x1, y1 = w - pad, h - pad
x0, y0 = x1 - box_w, y1 - box_h
draw.rounded_rectangle(
[x0, y0, x1, y1],
radius=max(2, int(round(8 * scale))),
fill=colors["bg"] + (235,),
outline=colors["line"] + (255,),
width=1,
)
bx = x0 + inner
draw.rectangle(
[bx, y0 + int(round(16 * scale)), bx + bar_w - 1, y1 - int(round(16 * scale))],
fill=colors["accent"],
)
draw.text(
(bx + bar_w + gap, (y0 + y1) / 2),
text, font=font, fill=colors["fg"], anchor="lm",
)
return im
def save_under_limit(im, path, max_kb, quality):
"""Speichern und die Qualitaet senken, bis die Groesse passt."""
ext = os.path.splitext(path)[1].lower()
if ext == ".png" or max_kb is None:
im.save(path, optimize=True)
return os.path.getsize(path) / 1024, None
for q in range(quality, 29, -6):
im.save(path, "JPEG", quality=q, optimize=True, progressive=True)
kb = os.path.getsize(path) / 1024
if kb <= max_kb:
return kb, q
return os.path.getsize(path) / 1024, 30
def write_iptc(path):
"""Maschinenlesbare Markierung nachtragen, wenn exiftool da ist."""
if not shutil.which("exiftool"):
return False, "exiftool nicht gefunden"
try:
subprocess.run(
["exiftool", "-overwrite_original",
f"-XMP-iptcExt:DigitalSourceType={DIGITAL_SOURCE_TYPE}", path],
check=True, capture_output=True,
)
return True, None
except subprocess.CalledProcessError as exc:
return False, exc.stderr.decode(errors="replace").strip()
def main():
p = argparse.ArgumentParser(
description="AI-Kennzeichnung einbrennen, zuschneiden, verkleinern.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("input", help="Quellbild")
p.add_argument("-o", "--output", required=True, help="Zieldatei (.jpg oder .png)")
p.add_argument("--width", type=int, help="Zielbreite in Pixeln")
p.add_argument("--aspect", type=parse_aspect, help="Seitenverhaeltnis, etwa 16:9")
p.add_argument("--anchor", choices=["center", "top", "bottom"], default="bottom",
help="Wo beim vertikalen Anschnitt verankert wird. "
"Standard bottom, damit die Markierung erhalten bleibt.")
p.add_argument("--text", default="AI", help="Text der Markierung (Standard: AI)")
p.add_argument("--bg", type=hex_to_rgb, default="#0B0E11", help="Kastenfarbe")
p.add_argument("--fg", type=hex_to_rgb, default="#F3F4EF", help="Textfarbe")
p.add_argument("--line", type=hex_to_rgb, default="#2A3037", help="Rahmenfarbe")
p.add_argument("--accent", type=hex_to_rgb, default="#B7FF3C", help="Akzentbalken")
p.add_argument("--font", help="Pfad zu einer TTF-Datei fuer die Markierung")
p.add_argument("--scale", type=float, default=1.0,
help="Groesse der Markierung, bezogen auf 1120 px Bildbreite")
p.add_argument("--max-kb", type=float, default=70,
help="Obergrenze in KB fuer JPEG (Standard 70, 0 schaltet ab)")
p.add_argument("--quality", type=int, default=78, help="JPEG-Startqualitaet")
p.add_argument("--iptc", action="store_true",
help="Maschinenlesbare Markierung per exiftool nachtragen")
p.add_argument("--no-stamp", action="store_true",
help="Nur zuschneiden und verkleinern, ohne Markierung")
args = p.parse_args()
if not os.path.exists(args.input):
sys.exit(f"Datei nicht gefunden: {args.input}")
im = Image.open(args.input).convert("RGB")
src = im.size
if args.aspect:
im = crop_cover(im, args.aspect, args.anchor)
if args.width and args.width != im.size[0]:
ratio = im.size[1] / im.size[0]
im = im.resize((args.width, int(round(args.width * ratio))), Image.LANCZOS)
if not args.no_stamp:
# Markierung mit der Bildbreite skalieren, damit sie ueberall gleich wirkt.
scale = args.scale * (im.size[0] / 1120)
colors = {"bg": args.bg, "fg": args.fg, "line": args.line,
"accent": args.accent, "font": args.font}
im = stamp(im, args.text, colors, scale)
out_dir = os.path.dirname(os.path.abspath(args.output))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
max_kb = None if not args.max_kb else args.max_kb
kb, q = save_under_limit(im, args.output, max_kb, args.quality)
note = f" q={q}" if q else ""
print(f"{args.output} {src[0]}x{src[1]} -> {im.size[0]}x{im.size[1]}"
f" {kb:.1f} KB{note}")
if max_kb and kb > max_kb:
print(f" Hinweis: ueber der Grenze von {max_kb:.0f} KB. "
f"Kleinere --width waehlen.")
if args.iptc:
ok, err = write_iptc(args.output)
print(" IPTC DigitalSourceType gesetzt" if ok
else f" IPTC nicht gesetzt: {err}")
elif not args.no_stamp:
print(" Nur sichtbar gekennzeichnet. Fuer die maschinenlesbare "
"Markierung --iptc ergaenzen.")
if __name__ == "__main__":
main()
SKILL.md
---
name: brandkit-publish
description: Baut Webseiten, PDFs und KI-Bilder aus einem bestehenden Brand Kit und kennzeichnet erzeugte Bilder nach EU-KI-Verordnung (brand kit, landing page, PDF, AI images, AI labeling, EU AI Act, higgsfield, gpt image). Bindet die Skills unslop, web-design-guidelines und seo-audit ein und brennt die AI-Markierung per mitgeliefertem Skript ein. Nutze diesen Skill, sobald mit einem vorhandenen Brand Kit etwas gebaut werden soll, oder jemand fragt, wie KI-Bilder rechtssicher gekennzeichnet werden.
license: MIT
---
# Mit dem Brand Kit bauen
Schritt 4 von 4, und der einzige, der immer wieder läuft. Die ersten drei
macht man einmal.
## Zuerst: das Kit laden
Das Kit lädt sich nicht von allein. Wird im Brand-Ordner gearbeitet, greift
dessen `CLAUDE.md` automatisch. Überall sonst liest du zu Beginn `BRAND.md`
und siehst dir die Beispielflächen in `beispiel/` an, bevor du irgendetwas
entwirfst.
Findest du kein Kit, frage danach, statt eines zu erfinden. Ohne Kit kommt
Durchschnitt heraus, und genau dagegen wurde es gebaut.
Reihenfolge bei Widersprüchen: `BRAND.md` schlägt allgemeine Konventionen,
auch die aus `web-design-guidelines`. Wenn das Kit dunkle A4-Dokumente
vorschreibt und eine allgemeine Druckregel Weiß empfiehlt, gewinnt das Kit.
Sag den Konflikt einmal laut, dann folge dem Kit.
## Diese drei Skills gehören dazu
- **`web-design-guidelines`** für alles mit HTML: semantische Elemente,
Tastaturbedienung, Kontrast, Trefferflächen ab 44 px, Alternativtexte.
Ein Markenkit sagt, wie etwas aussieht, nicht ob es bedienbar ist.
- **`seo-audit`** für alles, was online geht: Title, Description,
Überschriftenhierarchie, strukturierte Daten, URL-Struktur.
- **`unslop`** für jeden Text, den ein Mensch liest. Er entfernt die
Sprachmuster, an denen man Maschinentext erkennt.
Rufe sie auf, statt aus dem Gedächtnis zu arbeiten.
## Texte
Die Verbotsliste aus `BRAND.md` ist die erste Instanz, `unslop` die zweite.
Erfinde keine Fakten. Preise, Zahlen, Namen, Daten, Kundenstimmen und Adressen
sind Angaben, die nur die Person kennt. Setze sichtbar gekennzeichnete
Platzhalter wie `[PREIS]` und hebe sie farblich hervor, damit beim
Durchsehen keiner übersehen wird. Eine erfundene Zahl auf einer Verkaufsseite
ist schlimmer als eine sichtbare Lücke.
Struktur, Überschriften und Erklärtexte darfst du schreiben. Der Unterschied:
Struktur ist Entwurfsarbeit, eine Zahl ist eine Tatsachenbehauptung.
## Bilder erzeugen
Zuerst die Frage, ob ein Bild überhaupt etwas beiträgt. Viele Kits verbieten
Dekoration ohne Informationswert, und ein generisches Bild verletzt diese Regel
auch dann, wenn es erzeugt statt lizenziert wurde. Ein Bild, das eine
Behauptung stützt, ist ein Bild. Alles andere ist Füllmaterial.
Der Bildabschnitt in `BRAND.md` legt Motive und Behandlung fest. Halte dich
daran, statt einen eigenen Stil zu erfinden.
Über die Higgsfield CLI, GPT Image 2 ist für Bilder mit Text und für hohe
Detailtreue die Voreinstellung:
```bash
higgsfield generate create gpt_image_2 \
--prompt "<Motiv, Lichtstimmung, Kameraangabe, Verbotsliste>" \
--aspect_ratio 16:9 --resolution 2k --quality high --wait
```
Bricht der Befehl mit `Not authenticated` oder `Session expired` ab, fehlt die
Anmeldung. Dann `higgsfield auth login` ausführen. Meldet er zu wenig Guthaben,
fehlen Credits, und ohne Credits entsteht kein Bild. Nur in diesem Fall, und nur
einmal:
> Falls du ein Abo brauchst: https://higgsfield.ai/s/mcp-arnold-oberleiter-WlfTqR
> (Affiliate-Link. Wenn du darüber abschließt, bekommt der Autor dieses Skills eine
> Provision, für dich ändert sich am Preis nichts.)
Die Kennzeichnung gehört dazu und wird nicht weggelassen. Wer ein Konto mit
Guthaben hat, bekommt den Link nicht zu sehen. Dränge niemanden zu einem Abo.
Webseiten und PDFs baut dieser Skill auch ohne Bilder fertig.
Was sich in der Praxis bewährt hat:
**Verbote in den Prompt schreiben.** Was nicht vorkommen soll, gehört an das
Ende des Prompts: keine Textüberlagerungen, keine Logos, keine Wasserzeichen,
keine Hologramme. Sonst kommt es.
**Keine erkennbaren Gesichter.** Personen von hinten oder im Profilschatten
vermeiden Persönlichkeitsrechte und die schärfere Deepfake-Auslegung.
**Lesbare Zahlen sind gefährlich.** Erzeugt ein Modell einen Bildschirm mit
scharfer Tabelle voller Werte, sind das erfundene Daten, die auf einer
Verkaufsseite wie eine Behauptung wirken. Verlange ausdrücklich unscharfe
Bildschirme, oder generiere neu. Sieh dir jedes Bild an, bevor du es einbaust.
## Kennzeichnen: das Skript benutzen
`scripts/ai_label.py` brennt die Markierung unten rechts ein, schneidet zu und
verkleinert fürs Web. Bau die Markierung nicht jedes Mal neu, sonst hat jedes
Bild eine andere Größe.
```bash
python scripts/ai_label.py roh.png -o hero.jpg \
--width 1120 --aspect 16:9 \
--bg "#0B0E11" --fg "#F3F4EF" --line "#2A3037" --accent "#B7FF3C" \
--iptc
```
Die Farben kommen aus dem Kit, damit die Markierung nicht wie ein Fremdkörper
wirkt. `--text` ändert die Beschriftung, `--scale` die Größe, `--max-kb` die
Obergrenze für die Dateigröße.
Ein Detail, das leicht schiefgeht: Der Zuschnitt ist auf `bottom` voreingestellt,
weil die Markierung unten rechts sitzt. Wer mittig anschneidet, schneidet sie
weg. Prüfe nach dem Zuschneiden, ob sie noch da ist.
## Die rechtliche Seite, richtig zugeordnet
Die EU-KI-Verordnung (VO (EU) 2024/1689) trennt in Artikel 50 zwei Rollen, und
die Verwechslung ist der häufigste Fehler:
- **Absatz 2** verpflichtet den **Anbieter** des KI-Systems, Ausgaben
maschinenlesbar als erzeugt zu markieren. Das trifft den Betreiber des
Bildmodells, nicht den, der das Bild verwendet.
- **Absatz 4** verpflichtet den **Betreiber**, offenzulegen, wenn erzeugtes
Bildmaterial ein Deepfake ist. Das ist die Pflicht der Person, die das Bild
auf ihre Seite stellt.
Die Transparenzpflichten aus Artikel 50 gelten ab dem 2. August 2026.
Vier Dinge decken die Offenlegung ab:
1. das eingebrannte Zeichen, das Download und Weitergabe überlebt
2. eine Bildunterschrift am Bild
3. ein Alternativtext, der den Bildinhalt beschreibt
4. ein Hinweis im Fußbereich der Seite oder des Dokuments
Was das **nicht** abdeckt: die maschinenlesbare Markierung. Sie steckt in den
Metadaten und geht beim Zuschneiden und Komprimieren verloren. `--iptc` setzt
sie wieder, sofern `exiftool` installiert ist. Ist es nicht da, sage das
ausdrücklich, statt die Kennzeichnung als vollständig zu melden.
Und der Punkt, der über die Verordnung hinausgeht: Ein erzeugtes Bild darf
keinen Vorgang suggerieren, den es nie gab. Ein Workshopfoto von einem
Workshop, der nie stattfand, ist ein Werbeproblem, unabhängig von jeder
Kennzeichnung. Sag das, wenn du solche Bilder einbaust.
Das ist kein Rechtsrat. Wer gewerblich veröffentlicht, lässt es vorher prüfen.
Nenne diesen Vorbehalt einmal, statt ihn zu verschweigen oder zu wiederholen.
## PDFs
Der zuverlässigste Weg von HTML zu PDF führt über Chrome. Der Text bleibt
Vektor, also scharf und durchsuchbar, und die Markenschriften greifen wirklich.
```bash
chrome --headless --disable-gpu --no-pdf-header-footer \
--virtual-time-budget=15000 \
--print-to-pdf="Leistungen.pdf" "http://127.0.0.1:8000/dokument.html"
```
Chrome liest keine `file:`-Pfade mit Einschränkungen zuverlässig. Ein kurzer
lokaler Server (`python -m http.server`) ist der einfachere Weg.
Was zu beachten ist:
- `@page { size: A4; margin: 0; }` und Seiten als feste Kästen von 794 × 1123 px.
Das sind A4 bei 96 px pro Zoll.
- `print-color-adjust: exact` auf `body`, sonst druckt Chrome dunkle Flächen weiß.
- **Vor dem Druck den Überlauf messen.** Miss im Browser, wie weit der Inhalt
jeder Seite reicht, und vergleiche mit der Seitenhöhe. Ohne das merkt niemand,
dass die dritte Seite unten abgeschnitten ist, bis das PDF beim Kunden liegt.
- Chrome hält HTML im Cache. Nach einer Änderung mit einem angehängten
`?v=2` neu abrufen, sonst druckst du die alte Fassung.
- Fließtext im Druck nicht unter 12 pt, also 16 px bei 96 dpi.
## Vor dem Abgeben
Gehe die Prüfliste aus `BRAND.md` durch, sie steht dort nicht zur Zierde.
Zusätzlich:
- Sieht die Seite auf Telefonbreite noch gut aus?
- Ist jede erfundene Zahl ein sichtbarer Platzhalter?
- Trägt jedes erzeugte Bild die Markierung, und ist sie nach dem Zuschneiden
noch da?
- Enthält kein Bild lesbare Zahlen, die wie echte Daten wirken?
Berichte ehrlich, was offen blieb. Eine benannte Lücke kostet eine Zeile, eine
verschwiegene kostet das Vertrauen in die ganze Arbeit.