← posts / ai integrations

Structured data from photos with a local vision model: Ollama's format parameter and a Pydantic schema

Turn receipt or label photos into validated records with a local vision model: Ollama's format parameter, a Pydantic schema, temperature 0 and a retry loop.

if.codesNov 5, 2026 · 3 min read#ollama#vision#structured-output#pydantic#pythonAI-assisted

You have a folder of receipt or product-label photos and you want rows, not prose. Ollama's structured outputs let a vision model answer in JSON that matches a schema you give it, so the output can go straight into Pydantic validation. This post builds that as a small script you can run on a laptop.

A sample receipt used for data extraction tests.
A sample receipt used for data extraction tests.

One constraint up front: Ollama's documentation states that its cloud does not currently support structured outputs, so this works with a model running locally.

What the feature does

The Ollama structured-outputs page says vision models accept the same format parameter as text models, which gives deterministic descriptions of images. You pass a JSON schema as format, attach images to the message, and the reply content is JSON constrained to that schema. The docs also advise lowering the temperature, for example to 0, for more deterministic completions.

The process of transforming unstructured images into structured JSON records.
The process of transforming unstructured images into structured JSON records.

Constrained output guarantees shape, not truth. A model can return a perfectly valid receipt with a wrong total. So the pattern has three layers: schema-constrained generation, Pydantic validation, and your own sanity checks.

Setup

pip install ollama pydantic
ollama pull gemma4

Any vision-capable model you have pulled will do; the model name in the script is the only thing to change. Smaller models read blurry photos worse, so test on your own images before trusting a number.

The schema

Describe the record you want. Keep it flat and small: every extra field is another chance for the model to guess.

from typing import Optional
from pydantic import BaseModel, Field, field_validator


class LineItem(BaseModel):
    description: str
    quantity: float = Field(ge=0)
    price: float = Field(ge=0)


class Receipt(BaseModel):
    merchant: str
    date: Optional[str] = None
    currency: Optional[str] = None
    items: list[LineItem]
    total: float = Field(ge=0)

    @field_validator("items")
    @classmethod
    def at_least_one_item(cls, v):
        if not v:
            raise ValueError("no line items found")
        return v

The extraction script

The script sends one image, asks for the schema, and validates the reply. If validation fails it retries once with the error message added to the prompt, then gives up and reports the file.

import sys
from pathlib import Path

from ollama import chat
from pydantic import ValidationError

from schema import Receipt  # the models above, saved as schema.py

MODEL = "gemma4"
PROMPT = (
    "Read this receipt. Return merchant, date, currency, every line item "
    "and the total exactly as printed. Use null for anything you cannot read."
)


def extract(path: Path, retries: int = 1) -> Receipt:
    prompt = PROMPT
    last_error = None
    for _ in range(retries + 1):
        response = chat(
            model=MODEL,
            messages=[{"role": "user", "content": prompt, "images": [str(path)]}],
            format=Receipt.model_json_schema(),
            options={"temperature": 0},
        )
        try:
            return Receipt.model_validate_json(response.message.content)
        except ValidationError as err:
            last_error = err
            prompt = PROMPT + "\nYour previous answer was invalid: " + str(err)
    raise ValueError(f"{path.name}: {last_error}")


if __name__ == "__main__":
    for name in sys.argv[1:]:
        try:
            receipt = extract(Path(name))
            print(receipt.model_dump_json())
        except ValueError as err:
            print("FAILED", err, file=sys.stderr)

Run it over a folder and collect one JSON object per line:

python extract.py receipts/*.jpg > receipts.jsonl
Want AI wired into the systems you already run?I build LLM integrations with costs and quality you can see. The estimate is free.

Add a check the schema cannot do

The model can return line items that do not add up to the total. That is easy to test and worth flagging instead of storing silently:

def totals_match(r, tolerance=0.02):
    summed = sum(i.quantity * i.price for i in r.items)
    return abs(summed - r.total) <= tolerance

Receipts with discounts, tax lines or tips will fail this check legitimately, so treat a mismatch as a reason to look, not as proof of an error. Route those to a manual-review list.

Measure it before you use it

Do not trust the first demo. Take 30 photos, type the correct totals by hand once, and compare. Count three things: files that fail validation, files that validate but have a wrong total, and files where the merchant name is wrong. The second number is the dangerous one, because nothing flags it. Run this on the model size you actually plan to deploy and on the photo quality your users really produce.

Manual verification is essential for ensuring data accuracy.
Manual verification is essential for ensuring data accuracy.

Limits to plan for

  • Local only: the docs say Ollama's cloud does not support structured outputs.
  • Valid JSON is not correct JSON; keep the validators and the manual-review path.
  • Photo quality dominates results. Crop to the receipt and avoid glare before blaming the model.
  • Keep the schema small. If you need many fields, run two passes with two schemas.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.