Guides
Tutorial: images, with checks and a judge that sees them
Evaluate a function that makes images, here a product thumbnailer that also writes a caption. Check the thumbnails with measurements that need no model, judge the captions with a model that sees each thumbnail, measure that judge against a person, then ship a real change. Every step runs on this machine with no model and no network, and an optional step swaps in a real vision model.
What you will build
A catalogue service takes a product photo, saves a thumbnail, and writes a one-line caption naming the product's colour. Three questions decide whether a version may ship:
- Does each thumbnail fit the catalogue: a web format, at most 256 by 256 pixels, at most 50 kB? That is a measurement, made by media_check from the thumbnail's bytes.
- Is each thumbnail still the product's photo, and not a blank, a crop or another product? image_matches_reference compares it with the photo by perceptual hash.
- Does each caption name the colour a shopper sees? That needs eyes, so a rubric judge is shown the thumbnail.
The photos and thumbnails stay in the project folder. The system records them by reference (media/v1): where each file is, its type, size and sha256. Terms such as case, run, judge and gate are defined in Concepts; everything about media is in Images, audio and video.
Prerequisites
- Python 3.11 or later, and Oloproof with its media extra, which adds Pillow for comparing images:
python3 -m venv .venv
. .venv/bin/activate
pip install 'oloproof[media]'- The example project, which ships with the package. Copy it into a new directory and work there:
oloproof init --example media product-thumbnails
cd product-thumbnails- Port 8798 free for the stand-in judge (change it in both places if not).
Every step until "Optional: a real vision model" is offline and deterministic: no API key, no provider account, no cost.
The files
product-thumbnails/
app.py the thumbnailer under test (baseline)
app_v2.py the candidate change
judge_server.py a stand-in vision judge speaking the OpenAI API on 127.0.0.1
rubrics/caption_names_colour.md the judge's rubric
oloproof.yaml the suite
release.yaml rules for a run
compare.yaml a rule for a comparison
data/products.jsonl 20 cases
photos/ the 20 product photos the cases name
make_photos.py how the photos were drawn
labels/reviewer_verdicts.csv one person's verdicts on the baseline's captions
fill_labels.py copies those verdicts into a labelling sheetRun every command from product-thumbnails/.
The application records its images
# app.py (excerpt)
@system(name="thumbnailer", version=VERSION, records=("media/v1",))
def describe(case: dict[str, Any]) -> dict[str, str]:
photo_path = HERE / case["photo"]
photo = Image.open(photo_path).convert("RGB")
thumb = photo.copy()
thumb.thumbnail((320, 320))
out = HERE / "thumbs" / VERSION / photo_path.name
out.parent.mkdir(parents=True, exist_ok=True)
thumb.save(out)
given = media_file(photo_path, role="input")
made = media_file(out, width=thumb.width, height=thumb.height)
current_case().media([given, made])
centre = photo.getpixel((photo.width // 2, photo.height // 2))
return {"caption": f"A {colour_name(centre)} product."}media_file(path) describes a file: its file:// location, MIME type, size and sha256, named after the file. role="input" marks the photo the system was given; the thumbnail is an output, the default. current_case().media(...) records both as one media/v1 artifact, and records: [media/v1] in oloproof.yaml declares that the system records it. Nothing is copied: a check or a judge reads each file back later and refuses one whose bytes no longer match the record.
This baseline has two faults. Its thumbnails are 320 pixels wide, and its caption names the colour of the photo's centre pixel, which is the backdrop whenever the product sits to one side. Each version writes its thumbnails under thumbs/<version>/, so a later run never overwrites the files an earlier run recorded.
The dataset
{"id": "p03", "input": {"photo": "photos/p03.png"}, "expected": {"photo": "photos/p03.png", "colour": "yellow", "shape": "triangle"}}input is what the system receives. expected.photo is the reference the thumbnail is compared with; colour and shape are there for a person reading the case, and no evaluator reads them.
Choosing the evaluators
evaluators:
# The catalogue's limits, measured from the thumbnail's bytes: a web format, at most 256 by
# 256 pixels, at most 50 kB.
- type: media_check
criterion: thumbnail_fits
kind: image
formats: [png, jpeg, webp]
max_width: 256
max_height: 256
max_bytes: 50000
# The thumbnail is still the product's photo: a perceptual hash within 10 of 64 bits.
- type: image_matches_reference
criterion: same_photo
reference_field: photo
max_distance: 10
# The caption, judged by a model that sees the thumbnail.
- type: rubric_judge
criterion: caption_names_colour
provider: openai_compatible
model: stand-in-vision
base_url: http://127.0.0.1:8798/v1
rubric_file: rubrics/caption_names_colour.md
media: outputmedia_check measures each output image: the format from its opening bytes, the size from its header, never from what the system declared. image_matches_reference hashes the thumbnail and the photo expected.photo names, and passes when the hashes differ in at most 10 of their 64 bits; a resize or a recompression moves a hash a few bits, another picture moves it many. Neither calls a model, so both decide from the first run.
media: output attaches the case's output images to the judge's request, each named in its prompt. A judge decides nothing until people's labels show it agrees with them, so the third rule waits for that.
The stand-in judge
A rubric judge sends its prompt and the attached images to a model and reads back {"pass": true|false, "rationale": "..."}. Here the model is judge_server.py, which speaks the same API on 127.0.0.1 and is not a model: it decodes the attached thumbnail, finds its most common colour other than the backdrop's white and grey, and passes a caption that names it. Start it in a second terminal and leave it running:
python judge_server.py --port 8798The policy
version: 1
confidence_level: 0.95
block_on: [FAIL, INSUFFICIENT_EVIDENCE, MANUAL_REVIEW]
require_validated_evaluators: true
rules:
- id: thumbnails-fit
metric: thumbnail_fits
kind: observed_count
max_failures: 0
- id: same-photo
metric: same_photo
kind: observed_count
max_failures: 0
- id: caption-floor
metric: caption_names_colour
min: 0.80Every thumbnail must fit and must be its photo. At least 80% of captions must name the right colour, decided only when the interval's lower bound clears 80%.
Run it
oloproof runGate: BLOCK (exit 1)
│ thumbnails-fit │ thumbnail_fits │ FAIL │ observed_failures_exceed_limit │
│ same-photo │ same_photo │ PASS │ observed_failures_within_limit │
│ caption-floor │ caption_names_colour │ INSUFFICIENT_EVIDENCE │ evaluator_not_validated │
│ thumbnail_fits │ 0.0% │ [0.0%, 16.9%] │ 0 / 20 observed · 0 missing · 0 excluded │
│ same_photo │ 100.0% │ [83.1%, 100.0%] │ 20 / 20 observed · 0 missing · 0 excluded │
│ caption_names_colour │ 60.0% │ [36.0%, 80.9%] │ 12 / 20 observed · 0 missing · 0 excluded │No thumbnail fits, every thumbnail is its photo, and 12 of 20 captions name the right colour. The judge's rule is undecided because nobody has measured the judge yet.
Inspect the failures
oloproof inspect RUN_ID --failuresp03
output: {"caption": "A white product."}
thumbnail_fits: failed
measured: p03.png: width 320 px is above 256 px
caption_names_colour: failed
judge text, not verified: the product is yellow; the caption says 'a white product.'A check's reason is a measurement, marked measured; a judge's is its own words, which nobody has checked. The photos and thumbnails also appear beside each verdict on the case's page in the workbench, after oloproof export RUN_ID, read from this folder; a hosted workspace has your browser fetch them from oloproof collect and never holds them (Images, audio and video).
Measure the judge against a person
Draw a random sample of the run's cases into a sheet, without the judge's verdicts, have a person look at each thumbnail and caption, and import what they said. Here fill_labels.py stands in for that person, copying their verdicts from labels/reviewer_verdicts.csv:
oloproof labels export RUN_ID --criterion caption_names_colour --sample 20 --local --out sample.csv
python fill_labels.py sample.csv
oloproof labels import sample.csvThen measure the judge against those labels, and record who did:
oloproof evaluators list
oloproof evaluators validate EVALUATOR_ID --by alicecaption_names_colour: sha256:53605dd6... is now VALIDATED
agreement 100.0% [83.1%, 100.0%] · 20 of 20 labelled cases agreed · 0 labelled but not judged · kappa 1.000A judge that sees images is measured exactly as one that reads text. In a hosted workspace the review queue shows each case's images to the reviewer, fetched by their browser from your collector (Judges).
oloproof gate RUN_ID --policy release.yamlthumbnails-fit: FAIL (observed_failures_exceed_limit)
same-photo: PASS (observed_failures_within_limit)
caption-floor: INSUFFICIENT_EVIDENCE (interval_overlaps_threshold)
Gate: BLOCK (exit 1)The judge may now decide, and 60% is not enough.
Make a real change
app_v2.py makes 256-pixel thumbnails and names the photo's most common colour other than the backdrop, wherever the product is. Copy it over app.py, set version: main-colour under system in oloproof.yaml, keep the judge running, and:
oloproof runGate: ALLOW (exit 0)
│ thumbnails-fit │ thumbnail_fits │ PASS │ observed_failures_within_limit │
│ same-photo │ same_photo │ PASS │ observed_failures_within_limit │
│ caption-floor │ caption_names_colour │ PASS │ lower_bound_meets_minimum │The judge is the same evaluator version that was validated, so its verdicts count.
Compare the candidate with the baseline
oloproof compare CANDIDATE_RUN_ID BASELINE_RUN_ID --policy compare.yamlthumbnail_fits: +100.0 points [+52.9, +100.0] · 20 paired · 0 missing · 0 excluded
caption_names_colour: +40.0 points [+1.8, +72.7] · 20 paired · 0 missing · 0 excluded
captions-better caption_names_colour superiority PASS difference_above_zeroOptional: a real vision model
This step leaves the offline path. It needs a model that reads images: locally, Ollama with a vision model (ollama pull qwen2.5vl), no key and no cost; in the cloud, provider: anthropic or openai with api_key_env, billed per image as well as per word.
# live_judge.yaml
type: rubric_judge
criterion: caption_names_colour
provider: openai_compatible
model: qwen2.5vl
base_url: http://localhost:11434/v1
rubric_file: rubrics/caption_names_colour.md
media: output
timeout_s: 300oloproof evaluators try live_judge.yamlThat judges the cases your reviewer labelled with the real model and reports its agreement, without adopting it. To adopt it, put it in oloproof.yaml in place of the stand-in: a new model is a new evaluator version, so validate it against the labels as above.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| caption_names_colour all missing | The judge server is not running or not on base_url; oloproof inspect RUN_ID --failures shows why. |
| image_matches_reference stops the run, naming Pillow | Install the media extra: pip install 'oloproof[media]'. |
| A case is missing with media_unavailable | A recorded file was moved or deleted after the run. Keep the files a run recorded for as long as you keep the run. |
| A run stops with is not the recorded content | A recorded file was changed after the run. A check or judge never reads different bytes as the recorded ones; write each version's files to their own place. |
| evaluator_not_validated after you validated | You changed the judge (model, endpoint, port, rubric, media) and made a new version. Validate that one. |
Limitations
- The stand-in judge counts pixels. It demonstrates the workflow, not judging quality.
- The perceptual hash reads layout and light, not colour: it tells a thumbnail of the photo from a blank or a different scene, not one product from another in the same studio. Flat graphics with large blank areas move it more than photographs do.
- Twenty labels give a wide agreement interval. Label more, at random and blind, for a judge you rely on.
- Audio and video work the same way with other settings: see Images, audio and video.