Skip to main content

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:

  1. 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.
  2. 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 the aix AI façade and ir retrieval substrate; the narrated-video and persistence hooks reuse the burns / walkthru / lacing ecosystem 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)

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

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for illustration 0.0.7
File Size Uploaded
illustration-0.0.7.tar.gz 168.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for illustration 0.0.7
File Interpreter ABI Platform
illustration-0.0.7-py3-none-any.whl Python 3 none any Details

Total release size: 251.5 kB

Release files / illustration-0.0.7.tar.gz

Download URL illustration-0.0.7.tar.gz
Size 168.9 kB
Tags Source
SHA-256 checksum
How to use checksums
fb9db71edeebfb652ec7ac61e1ea8d4576acaa09258e8b769d160e38ed55bc9a
BLAKE2b-256 checksum
How to use checksums
eac158ea84e4d1d0bb6a1e8909cd3abfa6f0bd32f65295ecce582f5c1a49a49b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","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.7-py3-none-any.whl

Download URL illustration-0.0.7-py3-none-any.whl
Size 82.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a781009febac4f92ceebfcfcb40cf361dca356cbe0b60d7f9eac33e8f1efa428
BLAKE2b-256 checksum
How to use checksums
ff77ccb1a833d878e3e7f86bd33db6589b5b706f27e15e96051c1ea37cb3ff46
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","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 history Release notifications | RSS feed

0.0.17

2 release files

0.0.16

2 release files

0.0.15

2 release files

0.0.14

2 release files

0.0.13

2 release files

0.0.12

2 release files

0.0.11

2 release files

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

This release

0.0.7 This release

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page