AI & Computer Vision, lesson 38 of 52

Claude Vision & Multimodal AI

advanced28 min read

Introduction

YOLO is a specialist: it finds players and balls thousands of times a second, but it can't tell you what's going on. Claude can look at a picture and describe the scene, read a scoreboard, or explain why a moment matters. Today you'll learn when to call in the detective, how to ask for answers your code can use, and where its limits are.

By the end of this lesson you'll be able to:

  • Explain what multimodal means and how a picture is sent in a request
  • Compare YOLO and Claude vision honestly: speed, cost, offline, counting, understanding
  • Prepare a picture (shrink, encode) and estimate its token cost
  • Ask for JSON and validate it strictly
  • Choose when to ask Claude instead of asking on every frame
  • Explain the privacy question before sending pictures of people

Working principle

Two different kinds of seeing

YOLO (lesson 23 to lesson 25)Claude vision
Trained forOne job: find these classes, draw boxesGeneral understanding of pictures and language
OutputBoxes and scores, every frameWords or JSON you ask for
SpeedMilliseconds, on your laptopAbout a second or more, over the internet
CostFree to run after trainingPays per picture
Knows your pitchYes, if trained on itOnly what it can see now
Counting, exact positionsGoodApproximate (Anthropic says so)
Understanding a situationNoYes: "the keeper is out of position"
Works offlineYesNo

Neither is better. They are good at different things.

What multimodal means

A multimodal model takes more than one kind of input (text and images) and answers in text. You send the picture and the question together. The picture goes in a content block of type image, the question in one of type text.

Pictures cost tokens

A picture is chopped into 28 by 28 pixel patches; each patch is one token. A 640 by 360 picture is 23 by 13 = 299 tokens. A large photo is capped (about 1,568 tokens on older models, up to about 4,784 on newer ones). Cost and speed both grow with tokens, so send no more detail than you need. Shrinking first is free accuracy for your wallet.

When to ask Claude (and when not to)

Never ask on every frame. Ask:

  • On a schedule (every 5 seconds, say).
  • When your detector is unsure or silent (a possible miss).
  • When something important just happened (a goal, a whistle), as a second opinion.

should_ask_claude in Cell 2 does the first two and waits at least a second between calls.

Ask for JSON, validate the JSON

Free text is lovely for people and awful for programs. Ask for a fixed JSON shape, then check it: right keys, right types, sensible ranges, allowed values. Four of our tests are about rejecting broken answers. Anything that crosses a boundary (network, model, user) gets validated.

Know the limits

From Anthropic's own documentation, Claude vision can:

  • Be approximate at counting, especially lots of small things.
  • Be approximate at exact positions and sizes. Verify before trusting coordinates.
  • Make mistakes on small, rotated or low-quality pictures.
  • Not identify people. It will describe what it sees but will not name a person from their face.

A ball seen from the far side of a pitch is exactly the kind of small thing that is hard. So expect disagreements, and treat them as a reason to look, not as a verdict.

Privacy, again

A picture of children leaves your school's network and goes to a company's servers. Before you do that: do you have consent (lesson 14)? Can you crop out faces, or send a smaller picture that does not show them? Is a number enough instead of a picture? In India's data protection law, children's data carries stricter duties (lesson 14 explains the dates). When in doubt, send the facts, not the faces.

A good partnership

Your detector finds, fast and cheaply. Claude understands, slowly, and costs money. The best systems let the first filter and the second explain. That is also what lesson 42 (highlights) and lesson 46 (tactics) will do.

What you'll need

  • Your cv_course environment with the Anthropic SDK and your API key (from lesson 37)
  • A few match frames

Build it

Needs pip install anthropic opencv-python and an API key for Cells 3 and 5 (see lesson 37 for keys). Cells 1, 2 and 4 use the pretend client. Copy fake_client.py (lesson 37), and scene.py and detector.py (lesson 36), into the folder.

Cell 1: prepare the picture and ask

"""Send a picture to Claude and get structured facts back."""
import base64
import json
import math
import re
 
import cv2
 
MODEL = "claude-sonnet-5-5"
LONG_EDGE = 1568                      # bigger pictures are shrunk by the service anyway: shrink first, it is faster and safer
 
PROMPT = """Look at this football match photo.
Answer ONLY with JSON, no other text, in exactly this shape:
{"players_visible": <integer>, "ball_visible": <true or false>, "ball_location": "<short words, e.g. near the left goal>",
 "goal_scored_this_moment": <true or false>, "confidence": "low" | "medium" | "high", "notes": "<one short sentence>"}
If you are unsure, say so in confidence and notes. Do not guess names. Do not identify anyone."""
 
 
def prepare_image(frame, long_edge=LONG_EDGE, quality=85):
    """OpenCV frame -> (base64 text, media type, width, height) ready for the API."""
    h, w = frame.shape[:2]
    scale = min(1.0, long_edge / max(h, w))
    if scale < 1.0:
        frame = cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
    ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
    if not ok:
        raise ValueError("could not encode the frame")
    h, w = frame.shape[:2]
    return base64.standard_b64encode(buf.tobytes()).decode(), "image/jpeg", w, h
 
 
def image_tokens(w, h, high_res=True):
    """Rough cost of one picture: one token per 28x28 patch (from Anthropic's vision docs).
    Newer models allow a larger picture; older ones cap at 1568 tokens."""
    long_edge, max_tokens = (2576, 4784) if high_res else (1568, 1568)
    scale = min(1.0, long_edge / max(w, h))
    w, h = w * scale, h * scale
    return min(max_tokens, math.ceil(w / 28) * math.ceil(h / 28))
 
 
def ask_about_frame(frame, client, model=MODEL, prompt=PROMPT, max_tokens=300):
    data, media_type, w, h = prepare_image(frame)
    msg = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}},   # picture first,
                {"type": "text", "text": prompt},                                                           # then the question
            ],
        }],
    )
    return "".join(b.text for b in msg.content if b.type == "text")
 
 
def parse_answer(text):
    """Claude is asked for JSON, but may wrap it in markdown code fences or add a sentence. Be tolerant, then strict."""
    m = re.search(r"\{.*\}", text, re.S)
    if not m:
        raise ValueError("no JSON found in the answer")
    data = json.loads(m.group(0))
    need = {"players_visible": int, "ball_visible": bool, "goal_scored_this_moment": bool, "confidence": str}
    for key, typ in need.items():
        if key not in data or not isinstance(data[key], typ) or (typ is int and isinstance(data[key], bool)):
            raise ValueError(f"missing or wrong type: {key}")
    if data["confidence"] not in ("low", "medium", "high"):
        raise ValueError("confidence must be low, medium or high")
    if data["players_visible"] < 0 or data["players_visible"] > 50:
        raise ValueError("players_visible out of range")
    return data

Read three things in the code. The picture is shrunk before sending (the service would shrink it anyway, and a smaller upload is faster). The image block comes before the text, which Anthropic's documentation recommends. The reply is asked for as JSON, then parsed strictly, because free text is hard for code to use.

Cell 2: compare with your detector, and decide when to ask

"""Claude versus your detector: where do they agree? Only trust a disagreement after you have looked at the picture."""
 
 
def compare(frame_id, yolo_ball, claude_answer):
    """yolo_ball: dict with x,y or None. claude_answer: parsed JSON from parse_answer()."""
    y, c = yolo_ball is not None, claude_answer["ball_visible"]
    if y and c:
        verdict = "agree: ball seen by both"
    elif not y and not c:
        verdict = "agree: no ball"
    elif y and not c:
        verdict = "CHECK: detector sees a ball, Claude does not (false alarm, or tiny ball?)"
    else:
        verdict = "CHECK: Claude sees a ball, detector does not (missed ball?)"
    return {"frame": frame_id, "detector_ball": y, "claude_ball": c,
            "claude_confidence": claude_answer["confidence"], "verdict": verdict}
 
 
def should_ask_claude(frame_index, detector_ball, every=150, last_asked=-10**9, min_gap=30):
    """Claude is slow and costs money, so ask rarely: on a schedule, or when the detector is unsure/silent."""
    if frame_index - last_asked < min_gap:
        return False
    if frame_index % every == 0:
        return True
    return detector_ball is None and frame_index % 30 == 0

Cell 3: your first real question

import cv2
from vision import ask_about_frame, parse_answer
from vision import MODEL
from commentary import get_client        # from lesson 37
 
frame = cv2.imread("frame.jpg")           # any frame from your own video
text = ask_about_frame(frame, get_client())
print(text)
print(parse_answer(text))

Try five frames: a clear one, a dark one, a blurry one, one with the ball hidden, one where the ball is tiny. Fill in a table: what is true, what YOLO says, what Claude says. Are the disagreements the same ones?

Cell 4: tests (no internet)

import json
import numpy as np
from compare import compare, should_ask_claude
from detector import find_ball
from fake_client import FakeClient
from scene import make_clip
from vision import ask_about_frame, image_tokens, parse_answer, prepare_image
 
bad = 0
def check(name, ok, extra=""):
    global bad; bad += not ok; print("PASS" if ok else "FAIL", name, extra)
 
frames, truth = make_clip(n=130)
f = frames[60]
b64, mt, w, h = prepare_image(f)
check("image prepared as jpeg base64", mt == "image/jpeg" and len(b64) > 100 and (w, h) == (640, 360), (w, h, len(b64)))
big = np.zeros((2000, 4000, 3), np.uint8)
_, _, w2, h2 = prepare_image(big)
check("big picture shrunk to 1568 on the long edge", max(w2, h2) == 1568, (w2, h2))
 
check("tokens 640x360 = 23*13 = 299", image_tokens(640, 360) == 299, image_tokens(640, 360))
check("tokens capped at 1568 (older tier)", image_tokens(4000, 3000, high_res=False) <= 1568)
check("tokens capped at 4784 (newer tier)", image_tokens(8000, 8000, high_res=True) <= 4784)
 
good = '{"players_visible": 4, "ball_visible": true, "ball_location": "near the goal", "goal_scored_this_moment": false, "confidence": "high", "notes": "ok"}'
fc = FakeClient(good)
ans = parse_answer(ask_about_frame(f, fc))
call = fc.calls[0]; content = call["messages"][0]["content"]
check("image block comes before the text", content[0]["type"] == "image" and content[1]["type"] == "text")
check("source is base64 jpeg", content[0]["source"]["type"] == "base64" and content[0]["source"]["media_type"] == "image/jpeg")
check("parsed answer", ans["players_visible"] == 4 and ans["ball_visible"] is True)
 
tick = chr(96) * 3                                   # three backticks, written this way so this page stays tidy
fenced = "Sure! " + tick + "json\n" + good + "\n" + tick
check("fenced JSON still parses", parse_answer(fenced)["confidence"] == "high")
for name, text in [("no json", "I see a lovely match."), ("bad type", good.replace("4,", '"four",', 1)),
                   ("bad confidence", good.replace("high", "certain")), ("too many players", good.replace(": 4,", ": 400,")),
                   ("boolean as int", good.replace(": 4,", ": true,")), ("missing key", '{"players_visible": 3}')]:
    try:
        parse_answer(text); check(f"rejects {name}", False)
    except (ValueError, json.JSONDecodeError):
        check(f"rejects {name}", True)
 
ball = find_ball(f)
check("compare agree", compare(60, ball, ans)["verdict"].startswith("agree"))
check("compare flags missed ball", compare(60, None, ans)["verdict"].startswith("CHECK"))
check("compare flags false alarm", compare(60, ball, {**ans, "ball_visible": False})["verdict"].startswith("CHECK"))
 
last, sent = -10**9, 0
for i in range(600):
    if should_ask_claude(i, {"x": 1}, last_asked=last):
        last, sent = i, sent + 1
check("with a ball always seen, only the schedule fires (every 150 frames)", sent == 4, sent)
print("failures:", bad)

All should print PASS. Four of them check that bad answers are rejected, not trusted.

Cell 5: Claude as a second opinion on goals

The goal trigger (lesson 31) can be wrong. When it fires, ask Claude whether it looks like a goal, and log both answers. A human then reviews only the disagreements.

from events import EventBus
from goal_detector import GoalDetector
from vision import ask_about_frame, parse_answer
from commentary import get_client
 
client = get_client()
latest = {"frame": None}                     # the match loop keeps this up to date
 
def second_opinion(zone, t):
    answer = parse_answer(ask_about_frame(latest["frame"], client))
    verdict = "agrees" if answer["goal_scored_this_moment"] else "DISAGREES"
    print(f"goal at {t:.1f} s: Claude {verdict} (confidence {answer['confidence']})")
 
bus = EventBus()
bus.on("goal", second_opinion)

Run this on the lesson 36 clips (or your own). Remember to keep the call out of the match loop with the worker from lesson 37. Wiring this up is the exercise; the pieces above are tested, the live call is not.

Questions: what does a disagreement tell you? What if Claude is wrong and the detector is right? Where would you never use Claude (hint: speed)? How many pictures per minute can your budget afford?

If it goes wrong

ErrorCauseFix
invalid_request_error about the imageWrong media type, too big, or bad base64Use prepare_image; check the type matches the bytes
Answer is not JSONThe model added words or fencesparse_answer finds the braces; if it still fails, tighten the prompt
ValueError: missing or wrong typeClaude's answer broke the shapeThat is the validation working. Retry once, then log it.
Counts differ from YOLOClaude's counting is approximateLook at the frame. Trust the measured count for statistics.
Very slowLarge picture, slow networkShrink; ask less often; use the worker (lesson 37)
Claude refuses to name a playerBy design: it does not identify peopleUse tracker ids from your own system
Cost is climbingAsking every frameUse should_ask_claude; set a daily cap
cv2.imread returns NoneWrong pathCheck the file exists; print(frame is None)

Try it

  • Ask for the formation (lesson 46) as JSON and validate it
  • Run 20 frames and compute how often YOLO and Claude agree about the ball
  • Add a cost estimate: total tokens times the price from the pricing page

Check what you learnt

  1. Why not send every frame to Claude?

  2. Why shrink the picture before sending it?

  3. Why ask Claude to answer in JSON?

  4. What doesn't Claude do with faces?

  5. Claude and YOLO disagree about a frame. What now?

Conclusion

Multimodal AI understands pictures and text together. Use YOLO for fast, repeated detection and Claude for occasional understanding: describing a scene, reading text, explaining a moment. Shrink pictures to save tokens, ask for JSON and validate it, and remember it won't identify people.

What next?

Next: FIELD TRIP: live sports event capture. Unit 9 starts with Field Trip 2: deploying at a live sports event.