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.
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:
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:
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:
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:
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.