illustration
Find existing images to illustrate narrated video — cross-modal text-to-image retrieval (semantic image search) over stock / open-media corpora. Not an image generator: given narration text, it retrieves fitting images.
import illustration
# No API key needed — Openverse is the default source.
hits = illustration.search("a stormy harbour at dusk", n=10)
hit = hits[0]
hit.url # full-resolution image URL
hit.license # e.g. 'by-sa' (license carried through from day one)
hit.attribution # ready-to-render attribution sentence
hit.cacheable # may you download/cache the bytes?
From the shell:
illustration search "a stormy harbour at dusk" --n 10
illustration search "rusty bicycle" --source openverse --size large --json
illustration sources
illustration info openverse
That's the whole common case. Everything below is optional depth.
What it is
illustration has two layers:
- The provider façade — one unified
search()over many heterogeneous image-search backends (Openverse, Pexels, …), normalizing every result into one schema with license/attribution/cacheability first-class. - An agentic curation layer — query expansion, multi-provider search,
classical-CV + vision-LM inspection, reranking, a bounded corrective loop that
returns one vetted image per beat (
illustration.curate), and sequence-level selection across a whole storyboard with cross-shot coherence + near-duplicate suppression (illustration.curate_sequence). Built on theaixAI façade andirretrieval substrate; the narrated-video and persistence hooks reuse theburns/walkthru/lacingecosystem packages rather than reinventing them.
The design — provider comparison, canonical parameter mapping, escape-hatch
design, result schema, and roadmap — is in
misc/docs/design/illustration_design.md.
AI agents: .claude/skills/illustration/SKILL.md
is the condensed usage guide (including the licence/attribution obligations);
CLAUDE.md is the contributor's map of the package.
Install
pip install illustration
Local-ecosystem dependencies (dol, config2py) are developed alongside this
package; in the dev environment they resolve to local source.
The result schema
search() returns a list of ImageResult (Pydantic v2 — the single source of
truth shared with the agentic layer):
| field | meaning |
|---|---|
provider, id |
which source, and its native id |
url, thumbnail_url |
full-resolution image, and a thumbnail |
width, height |
pixel dimensions |
title, description, tags |
text metadata (normalized) |
license, license_url, attribution |
licensing, populated from day one |
source_page_url, author, author_url |
provenance |
cacheable |
may the bytes be downloaded/cached to your server? |
raw |
the untranslated provider payload (nothing is lost) |
Same-subject duplicates
A search for one person returns several reproductions of the same portrait — a painting, engravings after it, a library's re-scan of an engraving. Different ids, different bytes, different rasters; one picture, as far as a viewer is concerned. A film that uses four of them looks like it ran out of pictures.
illustration.dedupe(hits) # best of each subject, order kept
illustration.dedupe(hits, strategy="all") # group, but keep everything
illustration.group_duplicates(hits) # inspect the grouping yourself
"Best" is the largest reproduction, then the most permissive licence, then the
one with a recorded author — all replaceable via quality=.
This is a different question from the near-duplicate suppression in sequence,
which uses a perceptual hash. pHash finds the same raster; it cannot tell that
two engravings after one painting are one subject. Measured on a real
43-image set, pHash found 1 duplicate pair and DINOv2 found all four engravings
of the sitter, three portraits of her husband, and two near-identical genre
paintings — while keeping her sister correctly separate.
Three tiers, strongest first: dinov2_signature (pip install 'illustration[dedupe]') and phash_signature (always available, and honest
about it — Signature.subject_level is False) are chosen automatically by
what is installed. siglip_signature is opt-in by name — it needs the same
wheels as DINOv2 and is weaker here, so it is worth asking for only when a
rerank has already paid for its embeddings.
Files on disk
The same question gets asked of a folder — "here are 200 stills, which of them are the same picture?" — so there is a path-shaped pair of the above:
illustration.dedupe_paths(folder.glob("*.jpg")) # -> the surviving Paths
illustration.group_duplicate_paths(folder.glob("*.jpg")) # -> DuplicateGroups
Same strategy=/quality=/threshold= vocabulary, and dedupe_paths hands
back Path objects rather than wrappers. Nothing is fetched: local_signature
points the chosen tier's image loader at the file instead of at a URL, so a
local pass touches no network at all. Pin the cheap tier with
signature=local_signature(illustration.phash_signature) when you only want
"same raster" and don't want the torch download.
Telling subjects apart needs the pixels, so search() defaults to
dedupe="auto": it dedupes when the call already fetches images (i.e. when
reranking) and leaves a bare metadata search offline. Pass dedupe=True to
force it. select_sequence(signature=...) applies the same test across beats,
so one subject cannot win four of them.
Browsing a Wikimedia category
A Commons category is a curated list; free-text search is a guess at the
filename. Searching "Hamilton Grange" returns a branch library of that name —
"Category:Hamilton Grange National Memorial" returns the house.
illustration.search("Category:Elizabeth Schuyler Hamilton", source="wikimedia")
Detected from MediaWiki's own namespace prefix, so it works through the façade,
the cache and the CLI with no new parameter. The File: prefix works the same
way and fetches exact titles — relevance ranking cannot reliably surface a
generic filename, and a caller who already knows the file should not have to
hope:
illustration.search("File:Alexander Hamilton.jpg", source="wikimedia")
illustration.search("File:A.jpg|File:B.jpg", source="wikimedia") # several
Sources (providers)
| source | key needed? | notes |
|---|---|---|
| openverse (default) | no | 800M+ CC / public-domain images; works out of the box |
| wikimedia | no | 140M+ free media; historical / editorial / fine-art; deep metadata |
| pexels | PEXELS_API_KEY |
curated high-quality stock photos |
| pixabay | PIXABAY_API_KEY |
free commercial-use; license permits caching / self-hosting |
Pick a source (or several), and filter:
illustration.search(
"harbour", source="openverse", orientation="landscape", size="large"
)
illustration.search(
"harbour", source=["openverse", "wikimedia"], n=5
) # per-source, no key
Canonical filters (orientation, size, safe, license_type, color,
content_type) translate to each provider's native parameters and degrade
gracefully where a provider doesn't support one.
Keys
Pexels and Pixabay need a key (Openverse and Wikimedia do not). Provide it however suits you:
import os
os.environ["PEXELS_API_KEY"] = "..." # env var
# or per-request (the bring-your-own-key seam, e.g. a web backend):
with illustration.using_credentials(pexels="...", pixabay="..."):
illustration.search("harbour", source=["pexels", "pixabay"])
A missing key raises an informative MissingCredentialError that names the key,
how to set it, and where to get one — and never logs the value.
Caching
Results are SHA-256 content-addressed and cached behind an injectable dol
store (default: JSON files under ~/.cache/illustration/), so an identical
second call is free:
illustration.search("harbour") # hits the network
illustration.search("harbour") # served from cache
illustration.search("harbour", refresh=True) # force a re-fetch
illustration.search("harbour", cache=False) # bypass the cache
# to inject your own store, wrap it — `cache=` takes True/False or a SearchCache
from illustration import SearchCache
illustration.search("harbour", cache=SearchCache(my_mutable_mapping))
Rerank (precision)
Provider tag/lexical search is a cheap, high-recall stage. For precision, rerank the candidates by true cross-modal (text↔image) similarity with a local SigLIP-2 model — the recall→rerank pattern:
hits = illustration.search("a stormy harbour at dusk", n=50) # recall
top = illustration.rerank("a stormy harbour at dusk", hits)[:10] # precision
# or the one-liner:
top = illustration.search("a stormy harbour at dusk", n=50, rerank=True)[:10]
rerank populates each result's .score and sorts by it. The default SigLIP-2
encoder needs the optional extra (pip install 'illustration[rerank]'); a clear
error tells you if it's missing. The scorer is injectable — pass any
(query, results) -> scores callable to use a different model:
illustration.rerank("harbour", hits, scorer=my_scorer)
Image embeddings are content-addressed and cached, so re-ranking overlapping candidates is cheap.
Curate (agentic, the bounded CRAG loop)
search + rerank give you ranked candidates; curate goes one step further
and returns one vetted image for a narration beat, self-correcting across a
hard-bounded number of rounds. The loop is corrective-RAG-shaped — retrieve →
grade → conditionally re-query — with a classical-CV pre-filter gating the
expensive vision-LM, caption-first / judge-on-ambiguity escalation, and a
Budget of caps enforced in code:
from illustration import curate, Budget
result = curate(
"a stormy harbour at dusk, fishermen hauling nets",
sources=["openverse", "pexels"],
budget=Budget(max_iter=3, max_judge_calls=8, accept_threshold=0.62),
)
result.best.result.url # the chosen image
result.best.rubric.overall # its VLM rubric score (when judged)
result.accepted, result.reason
for step in result.trace: # per-iteration run-log (queries, grade, action, spend)
print(step.iteration, step.grade, step.action)
illustration curate "a stormy harbour at dusk" --source openverse --max-iter 3
This needs the optional [curate] extra (pip install 'illustration[curate]' —
aix + ir + Pillow/NumPy) plus provider and LLM API keys; the NSFW safety gate
and SigLIP rerank additionally want [rerank] (so illustration[rerank,curate]
for the full pipeline). Every paid step is an injectable seam — pass your own
search_fn, expander/refiner, scorer, describe, grader, or checks to
swap a model, add a test double, or run the loop entirely offline.
The image→text capability the judge/caption uses lives in aix
(aix.describe_image, provider-neutral over LiteLLM), so any vision-capable
model (Claude, GPT-4o, Gemini, …) works by model id alone.
Sequence (storyboard selection)
curate picks the best image for one beat; curate_sequence picks one image
per beat across a whole storyboard, optimizing cross-shot coherence and
diversity with a perceptual-hash near-duplicate hard constraint — so
consecutive shots cohere and no two beats land on the same picture:
from illustration import curate_sequence
result = curate_sequence(
[
"a stormy harbour at dawn",
"fishermen hauling nets",
"the catch unloaded at the quay",
]
)
for bs in result.selection.selections:
print(bs.beat_index, bs.chosen.url, bs.coherence, bs.forced_duplicate)
illustration curate-sequence "dawn harbour" "hauling nets" "the quay"
The selection math is in-house and dependency-light (NumPy MMR + a DCT
perceptual hash; coherence reuses the SigLIP embeddings the reranker already
caches) — select_sequence(per_beat_candidates, *, relevance, embed, hasher, shortlist, alpha, beta, phash_threshold) exposes every part as an injectable
seam. apricot (submodular shortlisting) and imagededup (CNN dedup) are
optional upgrades you plug into the shortlist / hasher seams.
Persist & render (ecosystem hooks)
The narrated-video and persistence steps are owned by other ecosystem packages; illustration provides thin, opt-in hooks rather than reinventing them.
# Render the selected stills into a Ken-Burns film (burns + your narration audio):
from illustration import render_sequence_video
render_sequence_video(result, saveas="film.mp4", narration_audio="narration.mp3")
# Or hand a walkthru DemoDocument to a walkthru/reelee consumer to render its way:
from illustration import to_walkthru_document
doc = to_walkthru_document(result, narration=["dawn…", "nets…", "quay…"])
# Persist selections (and director overrides) as lacing standoff annotations:
from illustration import persist_sequence, record_override, resolve_selection
store = persist_sequence(result)
record_override(store, 1, my_preferred_image, reason="better composition")
resolve_selection(store, 1) # the director's choice now supersedes the machine's
# The stored body keeps the whole rights record (illustration.RIGHTS_FIELDS),
# under ImageResult's own field names — attribution survives the round trip:
resolve_selection(store, 1)["selected"]["attribution"]
These need the opt-in extras: illustration[video] (burns + walkthru) and
illustration[persist] (lacing; add lacing[otio] for export_otio). The
render hook uses burns directly — the same renderer walkthru uses — so it
never pulls the app layer.
The escape hatch
A pure façade exposes only the common interface — but you can always reach a provider's special powers, via a four-rung ladder (cleanest → rawest):
# 1. pick the source(s)
illustration.search("q", source="pexels")
# 2. canonical filters (translated per provider)
illustration.search("q", orientation="portrait", size="large")
# 3. native passthrough — anything the façade doesn't name; flat for one
# source, namespaced for many (flat raises on a multi-source fan-out)
illustration.search("q", source="pixabay", image_type="photo")
illustration.search(
"q",
source=["openverse", "pixabay"],
provider_params={"pixabay": {"image_type": "photo"}},
)
# 4. the raw provider client
illustration.sources["openverse"].raw_search(q="q", page_size=2) # raw JSON
hits[0].raw # raw item
A parameter is promoted from the escape hatch to a canonical façade argument once two or more providers support it (so the interface evolves predictably).
Adding a provider
Open-closed — subclass RetrievalSource and register it; the façade is
untouched:
from illustration import RetrievalSource, ImageResult, register_source
class MySource(RetrievalSource):
name = "mysource"
endpoint = "https://api.example.com/search"
query_param = "q"
per_page_param = "limit"
max_per_page = 50
param_map = {"size": "size", "orientation": {"name": "orient"}}
def _items(self, response):
return response.get("results", [])
def _normalize(self, item, *, query):
return ImageResult(
provider=self.name, id=str(item["id"]), url=item["image_url"], query=query
)
register_source(MySource())
Licensing
Licensing is first-class for commercial-adjacent video. Each result carries its
license, license URL, attribution, and a cacheable flag. Aggregators
(Wikimedia, Openverse) disclaim license accuracy, so gate on a known-good set
when it matters — either inline on search() or with the standalone helper:
# inline gate (R3): keep only commercial-safe licenses
illustration.search("harbour", source="openverse", license_allow=True)
illustration.search("harbour", license_allow={"cc0", "pdm"}) # public-domain only
# or filter an existing result list
from illustration import license_allowlist
safe = license_allowlist(hits) # CC0/PD/BY/BY-SA default
safe = license_allowlist(hits, allow={"cc0", "pdm"}) # public-domain only
The gate normalizes both sides with illustration.normalize_license before
comparing, so every provider's own spelling reaches the same canonical code —
Wikimedia's cc-by-sa-4.0 and Pixabay's Pixabay License both match the
default DFLT_LICENSE_ALLOWLIST (cc0, pdm, by, by-sa,
pexels-license, pixabay-license). Mechanically, normalization only ever
strips a cc- prefix and a trailing version number: restrictions survive
(cc-by-nc-nd-4.0 → by-nc-nd, dropped), and an unrecognized code is left
as-is and therefore dropped too — unknown is not allowed. On top of that,
illustration.LICENSE_ALIASES is a short hand-written table folding ten further
public-domain spellings onto cc0/pdm — so pd (what Wikimedia Commons
actually emits for many files), public domain, publicdomain,
public-domain-mark, cc-zero, cc-0, zero, cc-pdm, cc-publicdomain and
pdm-owner all pass the default gate. Read that table, not just the
prefix/version rule, when auditing what license_allow=True will keep. Name
anything else you want explicitly in allow={...}.
License
MIT
Release files for illustration 0.0.12
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| illustration-0.0.12.tar.gz | 192.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| illustration-0.0.12-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 288.7 kB
Release files / illustration-0.0.12.tar.gz
| Download URL | illustration-0.0.12.tar.gz |
|---|---|
| Size | 192.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e0c30206f1cd08b8ce6fbb9ce7a5d2501d8f80f5cd1d4924ae671c6dc94590be
|
|
BLAKE2b-256 checksum How to use checksums |
9273188915ebe1d1367c872ff1239510c4364c07268d5c386e91a54906bab6bd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / illustration-0.0.12-py3-none-any.whl
| Download URL | illustration-0.0.12-py3-none-any.whl |
|---|---|
| Size | 96.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3b8cf0e65856b6680d39b509831c83e6b4c1a4ae4a487ac86d7c18c0a327fcb6
|
|
BLAKE2b-256 checksum How to use checksums |
886dec808aab35efff3e3037aebfe03454973b6db33f955a4be7bc568333baa3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|