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.
From the browser
The same search() ships to npm as illustration-search (the ts/
directory): the result schema, rights fields, licence tables and provider
registry are generated from this package, and the provider code is pinned to
it by fixtures. All four providers answer browser requests directly; Pexels and
Pixabay take the caller's own key as an argument.
import { search } from 'illustration-search';
const hits = await search('stormy harbour at dusk', { n: 5 }); // Openverse, no key
See ts/README.md.
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())
A provider ships on both sides: after registering it, run
illustration export-schema (its declared vocabulary and a
schema/fixtures/<name>.payload.json canned page become the contract the
TypeScript twin is generated from and tested against), then port its hooks
under ts/src/providers/.
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.17
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.17.tar.gz | 273.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| illustration-0.0.17-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 379.6 kB
Release files / illustration-0.0.17.tar.gz
| Download URL | illustration-0.0.17.tar.gz |
|---|---|
| Size | 273.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bdc3e189eccfcec0f7956d8d2468f0b07b94f0b1ec60f9796f066e081a1e0c6c
|
|
BLAKE2b-256 checksum How to use checksums |
79320ddc7ca0a428582ff52a8e48673520591a75d6a342f188200909eb4edba4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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.17-py3-none-any.whl
| Download URL | illustration-0.0.17-py3-none-any.whl |
|---|---|
| Size | 106.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
50406334d1ec0b5039b13c9894e97193ffc59fe8fdeb2eced713449959a7d759
|
|
BLAKE2b-256 checksum How to use checksums |
4db040b9fdc6dc197d46628a55d0587c30fa9df37d160287c397f6e2610838a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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}
|