Skip to content

Configuration

Everything is set in thumbor.conf. The plugin adds no services, no sidecars and no runtime of its own.

Policy

AI_LABEL_POLICY decides what happens when nothing asserts how an image was made. It is the one setting that materially changes what your readers see.

Situation strict (default) relaxed
AI asserted label label
Camera asserted no label no label
Provenance block present, inconclusive unknown unknown
EXIF only, no provenance block unknown no label
No metadata at all unknown no label

The row that decides your deployment is EXIF only. EXIF defines no provenance field, so an EXIF block asserts nothing either way — counting it as "metadata present" puts an unknown label on essentially every camera photograph ever taken. DigitalSourceType is a young field, so images predating it carry no provenance at all.

unknown has no basis in Article 50

The law obliges you to disclose content you know is AI, not content whose provenance you cannot establish. strict is a defensive posture, not a legal requirement, and it may mislead readers in its own way.

Measure before you choose

Both policies are defensible; which suits you depends on what your source images actually carry. Point a second Thumbor at the same source storage and ask it. Meta requests never draw a label, so a measurement instance cannot alter an image even if production traffic reaches it by mistake.

while read -r path; do
  curl -s "http://localhost:8888/unsafe/meta/600x400/$path" \
    | jq -r '.ai_label | if .label == null then "no label" else .label end'
done < paths.txt | sort | uniq -c | sort -rn

Run it under each policy. Against this project's own 24 fixtures:

Verdict strict relaxed
ai_generated 12 12
ai_manipulated / ai_composite 1 / 1 1 / 1
unknown 6 3
no label 4 7

Three move — those carrying only EXIF, or nothing. Across real source images that gap is far wider, and it is the whole decision.

Worth counting too: how often labelled is false while label is set. Those are images the plugin identified but which are too small to carry a visible mark, so the disclosure your CMS writes is the only one a reader gets.

while read -r path; do
  curl -s "http://localhost:8888/unsafe/meta/120x80/$path" \
    | jq -r '.ai_label | select(.label != null) | .labelled | tostring'
done < paths.txt | sort | uniq -c
If a second instance is impractical

Setting AI_LABEL_MIN_IMAGE_SIZE above any image you serve gives byte-identical output to the plugin being off while detection still runs.

Do not leave it in place: it reads like a fat-fingered threshold, and a compliance tool that looks enabled while marking nothing is worse than one that is plainly off.

AI_LABEL_OPACITY = 0 looks equivalent and is not — it reports labelled: true while drawing nothing, so the meta payload lies.

Detectors

Selected and ordered by config, resolved through an entry point group so a deployment can add its own without forking. See Extending.

AI_LABEL_DETECTORS = ["iptc", "exif"]

Detectors run in order and stop at the first HIGH-confidence verdict. A LOW-confidence one never short-circuits, so a heuristic cannot pre-empt the standard.

iptc — the primary signal

Reads IPTC DigitalSourceType at HIGH confidence. The only signal that is both standardised and unambiguous: the field exists to state how an image was made, so reading it is not inference.

Named for the schema, not the carrier — IPTC's provenance fields live in the XMP packet, the way EXIF lives in APP1.

Term State
trainedAlgorithmicMedia ai_generated
compositeWithTrainedAlgorithmicMedia ai_composite
algorithmicallyEnhanced ai_manipulated
digitalCapture, digitalArt, compositeCapture, … no label
anything unrecognised unknown

An unfamiliar term resolves to unknown, never to "not AI". A term this build has not heard of must not read as a clean bill of health.

exif — the weak fallback

EXIF defines no field that means "this image is AI." Nothing in the specification carries a provenance assertion. What it has is free-text fields that tools write their own name into: Software, ProcessingSoftware, Make, Model, ImageDescription, XPComment, and UserComment in the Exif sub-IFD.

So this detector reads standard EXIF tags, not vendor-private MakerNote data. The tags are standard; the matching vocabulary is what is vendor-specific. That is why the verdict is always LOW confidence — an assertion is inferred from a tool name, not read from a field that means what we need it to mean.

Patterns are deliberately narrow. A generic editor — Adobe Photoshop 25.0 — must never match, because most images through Photoshop are not AI and a false positive labels a real photograph.

AI_LABEL_MIN_CONFIDENCE gates only the positive AI claim. A not-AI assertion is honoured at any confidence, because discarding it would push the image into the unknown bucket and label it — the opposite of what raising the bar was for.

Icon variants

Four sets ship, each covering ai_generated, ai_manipulated, ai_composite and unknown. NOT_AI has no icon by design: a positively identified photograph gets no label at all.

AI_LABEL_ICON_SET = "default"        # this plugin's own marks, dark, for light imagery
AI_LABEL_ICON_SET = "default-light"  # the same marks, light, for dark imagery
AI_LABEL_ICON_SET = "eu"             # official EU labels, dark, for light imagery
AI_LABEL_ICON_SET = "eu-white"       # official EU labels, light, for dark imagery

Every set comes in a variant for light imagery and one for dark, so the choice is which family of marks you want, then which way round it reads against your pictures.

The artwork is in ai-labels/ at the repository root, one directory per set.

A house style

Copy a set directory, edit the four PNGs, put it somewhere your container can reach, and name that directory:

AI_LABEL_ICON_DIR = "/etc/thumbor/icon-sets"
AI_LABEL_ICON_SET = "house-style"

A set needs all four icons, including states you do not draw

ai_generated.png, ai_manipulated.png, ai_composite.png and unknown.png — every one, even if AI_LABEL_DRAW_STATES leaves some of them unmarked. A set is validated once when it is built, so it cannot break later because someone added a state back to that key; the person flipping the key is rarely the person who made the artwork.

Building a set from scratch is where this bites, because a DRAW_STATES list naming three states makes three files look like the whole job. The set then fails at boot, and with AI_LABEL_STRICT_ERRORS off — the default — every request serves unlabelled. A placeholder is enough to satisfy the rule for a state you never draw.

A set is a subdirectory of AI_LABEL_ICON_DIR holding the same four filenames, so your own artwork resolves exactly like a bundled set. That is one mounted volume and one name — rather than a rebuild to get files into the installed package, or a bind-mount landing on top of it. Both keys are plain strings, so both survive a thumbor.conf rendered from the environment, which the dict-valued AI_LABEL_ICONS cannot do.

While AI_LABEL_ICON_DIR is set, the bundled names do not resolve

Sets are looked up there and nowhere else, so AI_LABEL_ICON_SET = "eu" against a directory with no eu/ in it fails at boot rather than falling back. A silent fallback would turn a typo in a house-style name into this plugin's default marks going out under your byline — a wrong label rather than a missing one. To use a bundled set again, unset the key, or copy the set into your directory.

The eu sets are the European Commission's harmonised icons, published 10 June 2026 and free to use without attribution. Their use is optional; the disclosure obligation is not.

Official mark Plugin state
AI GENERATED ai_generated
AI MODIFIED ai_manipulated, ai_composite
(none) unknown

ai_composite maps to AI MODIFIED because a composite containing AI elements is exactly "pre-existing human-made content partially modified with AI".

unknown never draws an official EU mark

Those marks assert that content is AI. Using one on an image whose provenance merely could not be established would make a claim the evidence does not support, so that state keeps this plugin's own neutral icon.

Labels are not assumed to be square — the EU marks are icon-plus-text lockups around 3:1, so size settings describe height and width follows the icon. Per-state overrides, for swapping one mark rather than a whole set:

AI_LABEL_ICONS = {"ai_generated": "/etc/thumbor/icons/house-style.png"}

Overrides are validated and decoded once at startup, so a missing or corrupt path fails loudly rather than becoming a broken image mid-request. A value that is not a path — "", None, False — is rejected too; to leave a state unmarked, use AI_LABEL_DRAW_STATES below.

Which states get a mark

By default all four states draw. AI_LABEL_DRAW_STATES narrows that to the states you want burnt into the pixels:

AI_LABEL_DRAW_STATES = ["ai_generated", "ai_manipulated", "ai_composite"]

That is the common case: mark what a detector positively identified as AI, and leave unknown — provenance that could not be established — unmarked. unknown is this plugin's fail-closed hedge, not an AI claim, and a publisher may reasonably decide it does not warrant a mark on the image itself.

A state left out is still detected and still published. Only the drawing stops. The /meta/ payload carries the verdict as before with "labelled": false, exactly as it does for an image below AI_LABEL_MIN_IMAGE_SIZE:

{"label": "unknown", "reason": "inconclusive", "labelled": false,
 "disclosure": "Image provenance could not be established"}

The DOM disclosure becomes the only one

For a suppressed state, the label a CMS writes from /meta/ is the entire disclosure the image carries. Narrowing this key is a decision about your obligations under Article 50(4), so it is announced in the log at startup.

An empty list — AI_LABEL_DRAW_STATES = [] — is a valid "meta only" mode: detection runs, verdicts are published, no pixels are touched. Use AI_LABEL_ENABLED = False if you want detection off as well.

Narrowing this key does not shrink the icon set. A house set still needs all four PNGs, including the states this list leaves out — see A house style.

Two things this key is not for. Setting an icon override to "", None or False does not suppress a state; it is rejected at startup, because a map of paths holds paths. And not_ai is not a valid entry — a positively identified photograph never had a mark to suppress.

Because the value is a list of plain strings, it also survives being passed through the environment: AI_LABEL_DRAW_STATES=ai_generated,ai_manipulated is read as a comma-separated list, which AI_LABEL_ICONS cannot be. AI_LABEL_ICON_DIR and AI_LABEL_ICON_SET are the same shape of key for the same reason.

GIFs

GIFs need a section of their own, because Thumbor treats them unlike every other format and the plugin has to work around it.

Thumbor skips the phase the label lives in

BaseHandler.after_transform guards the whole post-transform filter phase:

if self.context.request.extension != ".gif" or self.context.config.USE_GIFSICLE_ENGINE is None:
    await self.filters_runner.apply_filters(thumbor.filters.PHASE_POST_TRANSFORM)

That reads is None, and USE_GIFSICLE_ENGINE defaults to False — so the guard is false for every GIF whether or not gifsicle is enabled, and no post-transform filter runs at all. ai_label is a post-transform filter, so for a long time no GIF carried a label and no GIF got a verdict, on any engine.

The handler now does both itself for exactly those requests: it computes the verdict before the response is assembled, and draws the label directly. Only this label — not the rest of the phase. Turning apply_filters on for GIFs would silently start applying every other post-transform filter to them, and a URL's filters:blur() would begin working where it never has. That is not this plugin's decision to make.

Every frame of an animation is labelled, not just the first. An animated GIF is read back through frame_engines(), so a mark composited onto the top-level image alone is discarded and the output carries nothing.

USE_GIFSICLE_ENGINE = True means no visible mark

USE_GIFSICLE_ENGINE = True swaps Thumbor's PIL engine for one that shells out to the gifsicle binary and holds no image in memory at all — thumbor.engines.gif.Engine sets image to "" and delegates every operation to the binary. There is nothing to composite a label onto, so on that engine a GIF carries no visible mark however the rest of this page is configured.

Detection still runs, and the verdict is still published with "labelled": false — which means what it means everywhere else: the /meta/ disclosure is the only one that image carries. A CMS reading the field already knows to write the alt text.

This is reported once per engine in the log rather than on every request, because it is a property of the engine and not of the image:

WARNING [AiLabel] thumbor.engines.gif.Engine holds no PIL image, so no visible label can
be drawn on it; /meta/ reports labelled: false for these images and the DOM disclosure is
the only one they carry. …

It is not treated as a labelling failure, so AI_LABEL_STRICT_ERRORS does not turn it into a 500 — the same category as an image below AI_LABEL_MIN_IMAGE_SIZE. The engine is asked per request, so JPEGs in the same deployment are labelled normally.

Per-frame labelling through gifsicle would mean decoding, compositing and re-encoding outside the binary the engine exists to use. That is a feature rather than a fix, and is not implemented.

Provenance inside a GIF is read

GIF89a carries XMP in an Application Extension labelled XMP DataXMP, and the scanner walks it like any other container — so a DigitalSourceType assertion in a GIF reaches the iptc detector exactly as it does from a JPEG:

{"label": "ai_generated", "reason": "ai_asserted", "labelled": true,
 "disclosure": "AI generated"}

Only XMP. There is no interoperable place for EXIF in a GIF, and Comment Extensions are free text with no agreed structure, so the exif detector's vendor heuristics never fire on one. In practice that costs little: the assertion this plugin acts on is an XMP one.

The meta endpoint

The verdict is published on Thumbor's /meta/ endpoint under a top-level ai_label key. This is how a CMS obtains the verdict to write alt text or an ARIA label, which is what Article 50(5) accessibility asks for and what a label burnt into pixels cannot provide.

{
  "thumbor": { "source": {}, "operations": [], "target": {} },
  "ai_label": {
    "label": "ai_generated",
    "reason": "ai_asserted",
    "policy": "strict",
    "labelled": true,
    "disclosure": "AI generated"
  }
}

labelled is the field that matters. It says whether an image request at those dimensions would actually carry a visible mark. Below AI_LABEL_MIN_IMAGE_SIZE nothing is drawn, so {"label": "ai_generated", "labelled": false} means the image is AI but the pixels do not say so — and the disclosure you write into the DOM is the only one. The other two reasons it can be false are a state left out of AI_LABEL_DRAW_STATES and an engine with no pixels to mark, such as the gifsicle engine.

The field is answered by the same predicates the drawing itself uses — including asking the request's own engine — so what the pixels carry and what /meta/ reports cannot drift.

disclosure is English by default and overridable per state:

AI_LABEL_META_DISCLOSURES = {
    "ai_generated": "KI-generiert",
    "ai_manipulated": "KI-bearbeitet",
    "unknown": "Herkunft nicht feststellbar",
}

The unknown string is deliberately not phrased as an AI claim. Someone relying on a screen reader cannot judge the image for themselves.

Diagnostics are off by default

detector, confidence, evidence and generator appear only with AI_LABEL_META_VERBOSE = True. evidence can carry a fragment of a generation prompt read out of EXIF UserComment, and this endpoint is publicly reachable.

Reference

Pulled directly from the project README, so the two cannot drift.

Key Default Meaning
AI_LABEL_ENABLED True Master switch
AI_LABEL_DETECTORS None Ordered detector names; None uses the default order
AI_LABEL_POLICY "strict" strict or relaxed
AI_LABEL_MIN_CONFIDENCE "low" Lowest confidence that may raise an AI label
AI_LABEL_ICON_DIR None Directory of icon sets; None uses the bundled artwork
AI_LABEL_ICON_SET "default" default, default-light, eu, eu-white, or a set in AI_LABEL_ICON_DIR
AI_LABEL_ICONS {} Per-state icon path overrides; win over the set
AI_LABEL_DRAW_STATES None States that get a visible mark; None draws all four
AI_LABEL_OPACITY 100 Label opacity, 0–100
AI_LABEL_POSITION "bottom-right" Corner, or center
AI_LABEL_SIZE_RATIO 0.14 Label height as a fraction of the shorter edge
AI_LABEL_MIN_SIZE / MAX_SIZE 20 / 96 Height clamps, in pixels
AI_LABEL_MIN_IMAGE_SIZE 120 Below this shorter edge, no label
AI_LABEL_MARGIN_RATIO 0.04 Margin as a fraction of the shorter edge
AI_LABEL_MIN_MARGIN 3 Smallest margin, in pixels
AI_LABEL_STRICT_ERRORS False Make labelling failures fatal instead of logged
AI_LABEL_META True Publish the verdict on /meta/
AI_LABEL_META_VERBOSE False Include detector, confidence, evidence, generator
AI_LABEL_META_DISCLOSURES None Per-state disclosure strings; None uses English

Sizes track the shorter edge, so a label carries the same visual weight on a panorama as on a square crop. Images below AI_LABEL_MIN_IMAGE_SIZE get no label: on a 64 px thumbnail it is an unreadable smudge that costs bytes and tells the viewer nothing.