Skip to main content

describe-it

Alt-text descriptions for PIL images, from a vision model running on your own machine.

What it is

describe-it takes a PIL.Image.Image and returns a short, alt-text-quality description of it as a plain string, by asking a vision model served by Ollama — on your own machine by default, and no third party at any point. That matters for two reasons: generating alt text at volume is cheap locally and expensive through a commercial API, and the images may be NSFW, which hosted services refuse or penalise — a local model has no policy layer beyond whatever is in its weights.

The contract is deliberately small: image in, description out, every failure is an exception. No result objects, no status codes, no None returns.

See ARCHITECTURE.md for how it is put together.

Requirements

  • Python 3.12 or newer.

  • A running Ollama with a vision-capable model pulled:

    $ ollama pull qwen3.5:4b
    

Pillow is the only runtime dependency; the Ollama transport is urllib from the standard library.

Install

The distribution on PyPI is describe-image (the name describe-it was already taken there). The import package is still describe_it and the command is still describe-it:

$ uv add describe-image
$ pip install describe-image

To work on it, clone and sync. uv sync creates the project's own virtual environment in .venv/ and installs describe-it into it, so run things through uv run rather than expecting the package on your system Python:

$ git clone git@github.com:Xof/describe-it.git
$ cd describe-it
$ uv sync
$ uv run describe-it --help

To track the repository instead of a release, install straight from it:

$ uv add git+https://github.com/Xof/describe-it
$ pip install git+https://github.com/Xof/describe-it

Usage

The zero-configuration call:

from PIL import Image
from describe_it import describe

alt = describe(Image.open("cat.jpg"))
# -> "A grey tabby cat asleep on a red armchair beside a sunlit window."

Any mode Pillow can open works — RGBA, palette GIFs, CMYK TIFFs, 16-bit scientific images, sideways phone photographs. You never have to convert first, and your image object is not modified.

For a batch, configure once and keep the model resident:

from describe_it import Describer

d = Describer(model="gemma4:e4b", max_words=20, keep_alive="1h")
d.ensure_model()  # downloads the model if it is missing (explicit, opt-in)
for img in images:
    alt = d.describe(img, context="gallery thumbnail")

Every failure is an exception, and they all share a base class:

from describe_it import DescribeItError, DescriptionRefusedError

try:
    alt = describe(image)
except DescriptionRefusedError as exc:
    # The model declined. Its actual words are on the exception.
    print(f"refused: {exc.response}")
except DescribeItError as exc:
    print(f"no description: {exc}")

Options

Both describe() and Describer() take these; context and prompt are per-call, so they are arguments to Describer.describe() rather than to its constructor.

Option Default Meaning
model $DESCRIBE_IT_MODEL, else "qwen3.5:4b" Ollama model tag. Must be vision-capable. Blank is a ValueError.
host $OLLAMA_HOST, else "http://localhost:11434" Base URL, or a bare host:port as Ollama's own CLI accepts. A scheme-less host gets http:// and, if it names no port, 11434; a host written with a scheme is used as written. Credentials, a query string or a fragment are a ValueError.
timeout 120.0 Socket timeout in seconds, applied when connecting and to each read. Generous because a cold model load can take tens of seconds. Must be positive and finite.
context None Free text about where the image appears ("product photo on a shoe listing"). Good alt text depends on context; this reaches the model verbatim.
language "English" Output language, as a plain English name the model will understand.
max_words 30 Requested upper bound on length. Asked of the model in the prompt, never enforced by truncation — the library will not cut a sentence in half.
max_image_size 1024 Longest edge in pixels to downscale to before upload. None sends the image at its original size. Never upscales.
prompt None A complete replacement for the built-in wording. When set, context, language and max_words no longer reach the model. The reply is still cleaned and still checked for a refusal.
keep_alive None (Ollama's default, 5 minutes) Passed through to Ollama: "30m" keeps the model resident across a batch, 0 unloads it as soon as the call finishes.
client None A ready-made OllamaClient, which replaces host and timeout. A test seam and an escape hatch, not part of the ordinary path.

Exceptions

Everything the library raises inherits from DescribeItError, so one except clause catches all of it. A wrong-type argument is the deliberate exception: a non-image raises plain TypeError, and out-of-range options raise ValueError.

DescribeItError
├── ImageError                  the image cannot be prepared (zero size, unloadable)
├── OllamaError                 anything involving the server
│   ├── OllamaConnectionError   cannot connect (refused, DNS, unreachable)
│   ├── OllamaTimeoutError      connecting or reading exceeded the timeout (a sibling, not a subclass)
│   ├── ModelNotFoundError      the server does not have the model; names the pull command
│   └── OllamaResponseError     any other non-2xx, or an unusable body; has .status_code and .body
└── DescriptionError            the server answered but produced no usable description
    └── DescriptionRefusedError the model refused; .response holds its actual words

Environment variables

Variable Effect
DESCRIBE_IT_MODEL The model tag to use when none is passed.
OLLAMA_HOST The server to talk to when no host is passed — the same variable Ollama's own CLI reads, so a machine already configured for ollama run needs nothing else.

Both are read when a describer is constructed — which is per call for describe(), and once for a long-lived Describer — never at import time. A blank value counts as unset.

Command line

describe-it [--model TAG] [--host URL] [--timeout SECONDS] [--context TEXT]
            [--language NAME] [--max-words N] [--max-image-size PX]
            [--version] FILE [FILE ...]

One file prints the description alone; several print <path>\t<description> per line. A file that cannot be read or described is reported on stderr and the run continues, with exit status 1 at the end if anything failed.

$ describe-it photo.jpg
A blue and yellow flag against a bright sky.

$ describe-it --max-words 15 photo.jpg diagram.png
photo.jpg	A blue and yellow flag against a bright sky.
diagram.png	A flow chart with three boxes joined by arrows.

Choosing a model

Any vision-capable Ollama model works; the tag is passed straight through. The default is qwen3.5:4b (~3 GB), chosen because Qwen models are generally less refusal-prone than the Gemma line — which matters when the images are the sort a hosted service would reject. No measurement backs that up; it is the judgement recorded when the default was chosen.

  • gemma4:e4b — reportedly better captions, and more likely to refuse. Also unmeasured.
  • llava:7b — older and widely available. In this project's live runs it answered in English whatever language asked for, and overran max_words in two runs out of seven.

If the default is too large for your machine, Ollama's library lists smaller vision tags; none of them have been tried here.

A model that declines to describe an image produces DescriptionRefusedError rather than alt text, so a refusal-prone model turns into exceptions rather than into garbage on your web pages.

Development

$ uv sync --locked --group dev

The four commands CI gates on, which should all be run before pushing:

$ uv run ruff check .
$ uv run ruff format --check .
$ uv run mypy src tests
$ uv run pytest --cov --cov-report=term-missing -m "not integration"

The unit suite is hermetic — no Ollama, no model — and the coverage gate is 100%.

The tests under tests/integration/ talk to a real server and a real model. They are opt-in, skipped unless both the environment variable is set and the host answers:

$ DESCRIBE_IT_INTEGRATION=1 DESCRIBE_IT_MODEL=qwen3.5:4b \
      uv run pytest -m integration -v

Their assertions are structural (length, language, exception type), because a small vision model's output is not deterministic enough to gate a merge on.

Metadata

Release files for describe-image 1.0.0

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

Source distribution (sdist)

Source distribution for describe-image 1.0.0
File Size Uploaded
describe_image-1.0.0.tar.gz 37.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for describe-image 1.0.0
File Interpreter ABI Platform
describe_image-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 79.0 kB

Release files / describe_image-1.0.0.tar.gz

Download URL describe_image-1.0.0.tar.gz
Size 37.0 kB
Tags Source
SHA-256 checksum
How to use checksums
81bfea7336ac381def0f9e52208f53f323999a67bf494e4de2908d82542284e2
BLAKE2b-256 checksum
How to use checksums
43cde496ef5ff5be8a79d79c3b79c8fc9d9366cbfbc7cce892941365074b90a2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release files / describe_image-1.0.0-py3-none-any.whl

Download URL describe_image-1.0.0-py3-none-any.whl
Size 42.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4387c98ac8a8869130d169738ca1f226f888cda05912bd653fec59a4676329d1
BLAKE2b-256 checksum
How to use checksums
30e39a9fcdca8a9accd0a3465a1bb74396f7d36323fb63a1aa17efe66fdbdc94
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

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