A Frame Becomes a Sentence
An eye with nothing behind it
Volume 7 built her an eye that moves and a ring of light around it, and volume 8 put a board behind that eye with a GPU on it. What has never been in the socket is a camera. Everything she knows arrived as text: typed at her, transcribed from a microphone, or pulled out of a table by chapter 85's retriever. A camera is the first source that hands her something no part of the stack can read.
What comes back from a webcam is a grid of numbers, three per pixel. What a vision model wants is a JSON request over HTTP, the same conversation she has been having with Ollama since volume 1 with one field added. Between those two facts sit four steps: capture the frame, compress it, turn the compressed bytes into text a JSON document can legally carry, and post it. Skip any one and nothing works, and only one of the four fails loudly.
Chapter 4 made the rule that governs the first step. Capture is a measured operation, because a muted microphone records perfect uncomplaining silence and a wrong device records the wrong room, and neither of those raises anything. A camera has the same problem wearing a lens cap. A covered lens, a driver that answers before the sensor has woken up, a room with the lights off: every one of them produces a valid JPEG of a dark rectangle, and a vision model asked to describe a dark rectangle will describe one, at length, with confidence.
So this chapter carries two rules at once. The transport rule: a frame is bytes, a request is text, and base64 is the adapter between them. And the rule chapter 4 already set, applied to a second sensor: nothing goes across that adapter until the frame has proved on this machine that there is something in it.
uv add opencv-python gives you cv2, the capture and image
side of this chapter. On the Jetson prefer opencv-python-headless: the
board has no monitor attached, and the headless wheel skips the GUI libraries that
exist only to open a preview window you will never see. Then
ollama pull llava:7b, about 4.7 GB, into the same Ollama service
that already holds her chat model. On a CPU a single frame takes minutes; on the
Jetson's GPU it takes seconds, so volume 8 is what makes this chapter affordable at
all.
Capture, then prove the capture
# glados/vision.py
import base64
import json
import time
import urllib.request
import cv2
import numpy as np
CAMERA_INDEX = 0
WARMUP_FRAMES = 5
def capture_frame(index: int = CAMERA_INDEX, warmup: int = WARMUP_FRAMES) -> np.ndarray:
cap = cv2.VideoCapture(index)
if not cap.isOpened():
raise RuntimeError(f"camera {index} would not open")
try:
for _ in range(warmup):
cap.read()
ok, frame = cap.read()
finally:
cap.release()
if not ok:
raise RuntimeError(f"camera {index} opened but returned no frame")
return frame
if __name__ == "__main__":
frame = capture_frame()
height, width, channels = frame.shape
print(f"{width}x{height}, {channels} channels, dtype {frame.dtype}")
print(f"corner pixel (B, G, R): {frame[0, 0]}")
$ uv run python glados/vision.py # your camera and its resolution will differ
640x480, 3 channels, dtype uint8
corner pixel (B, G, R): [ 41 38 34]
Two independent failures, two checks. isOpened() is false when the
device could not be acquired at all: wrong index, no permission, another process
already holding it. ok is false when the device opened and a particular
read came back empty, which is what an unplugged cable looks like halfway through a
session. Testing one and not the other leaves a real failure with no message
attached to it.
The finally matters more than it looks. A camera is an exclusive
operating-system resource: while your process holds it, nothing else on the machine
can open it, including the next run of this same script. Releasing inside
finally hands the device back within milliseconds even when the read
raises, and it happens before the compression and the network call, which are the
slow parts. Note the printed pixel too: OpenCV stores blue, green, red in that order,
and the dimensions run height before width, because NumPy counts rows before
columns.
DARK_MEAN = 12.0 # 0-255 brightness; below this there is nothing to describe
FLAT_SPREAD = 8.0 # below this the frame is one flat colour
def frame_stats(frame: np.ndarray) -> tuple[float, float]:
"""Mean brightness and its standard deviation, on a 0-255 scale."""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return float(gray.mean()), float(gray.std())
def frame_verdict(mean: float, spread: float) -> str:
if mean < DARK_MEAN:
return "too dark"
if spread < FLAT_SPREAD:
return "no contrast"
return "usable"
if __name__ == "__main__":
cap = cv2.VideoCapture(CAMERA_INDEX)
for i in range(6):
ok, frame = cap.read()
mean, spread = frame_stats(frame)
print(f"frame {i}: mean {mean:6.1f} spread {spread:6.1f} {frame_verdict(mean, spread)}")
cap.release()
$ uv run python glados/vision.py # your camera and your room will differ
frame 0: mean 4.6 spread 2.1 too dark
frame 1: mean 21.9 spread 14.7 usable
frame 2: mean 63.4 spread 35.0 usable
frame 3: mean 99.8 spread 49.6 usable
frame 4: mean 116.2 spread 53.8 usable
frame 5: mean 118.7 spread 54.2 usable
That ramp is the reason capture_frame throws away five frames before
keeping one. The sensor wakes with its exposure closed and opens it over the first
fraction of a second, so frame 0 is nearly black on a lit desk while frame 5 is the
room. It is the pixel version of the bug chapter 4 caught in audio, where saving
before the buffer was full wrote five seconds of zeros: in both cases the device
answered before it had anything to say, and in both cases the file that came out was
perfectly valid and completely empty.
Mean brightness alone would not be enough, since a lens cap and a dark room both read low. The spread is the sturdier signal, because it measures how much the pixels differ from each other: a real room has edges, highlights, a lamp, and its pixels scatter, while a covered lens is sensor noise around one value and lands near zero however the exposure is set. Neither number can tell you which of the two happened, and neither needs to. Both answers mean there is nothing here for a model to look at.
Bytes that have to become text
JPEG_QUALITY = 85
def encode_jpeg(frame: np.ndarray, quality: int = JPEG_QUALITY) -> bytes:
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
if not ok:
raise RuntimeError("JPEG encode failed")
jpeg = buf.tobytes()
if jpeg[:2] != b"\xff\xd8" or jpeg[-2:] != b"\xff\xd9":
raise RuntimeError(f"incomplete JPEG: {len(jpeg)} bytes")
return jpeg
def to_base64(jpeg: bytes) -> str:
return base64.b64encode(jpeg).decode("ascii")
if __name__ == "__main__":
frame = capture_frame()
jpeg = encode_jpeg(frame)
text = to_base64(jpeg)
height, width, channels = frame.shape
raw = height * width * channels
print(f"raw pixels: {raw} bytes")
print(f"jpeg: {len(jpeg)} bytes ({raw / len(jpeg):.1f}x smaller)")
print(f"base64: {len(text)} chars ({len(text) / len(jpeg):.3f}x the jpeg)")
print(f"first 14: {text[:14]}")
print(f"type: {type(text).__name__}")
$ uv run python glados/vision.py # byte counts depend on what the camera saw
raw pixels: 921600 bytes
jpeg: 41682 bytes (22.1x smaller)
base64: 55576 chars (1.333x the jpeg)
first 14: /9j/4AAQSkZJRg
type: str
Three of those numbers are arithmetic you can check. 480 rows times 640 columns
times 3 colour channels is 921,600 bytes of raw pixels. Base64 turns every 3 bytes
into exactly 4 characters, so 41,682 divides by 3 to give 13,894 groups and the
string is exactly 55,576 characters long, four thirds of the input, every time. And
/9j/ is not a coincidence: every JPEG in existence starts with the bytes
ff d8 ff, and those three bytes always encode to those four characters.
If your string starts with anything else, what you compressed was not an image.
The order of the two encodes is the whole design. Base64 the raw pixel array and you would be posting 1,228,800 characters over HTTP. Compress to JPEG first and you post 55,576, for a picture the model cannot tell apart from the original, because it resizes whatever you send down to its own input size of a few hundred pixels a side before it looks at anything. Shrink, then make it text. Doing it the other way round works and costs twenty-two times the bytes.
OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
VISION_MODEL = "llava:7b"
VISION_PROMPT = (
"You are GLaDOS from Portal. In one or two sentences, describe what you see. "
"Be clinical and unimpressed. Do not describe anything you cannot see."
)
def build_payload(b64_image: str, prompt: str = VISION_PROMPT) -> bytes:
return json.dumps({
"model": VISION_MODEL,
"prompt": prompt,
"images": [b64_image],
"stream": False,
}).encode("utf-8")
if __name__ == "__main__":
payload = build_payload(to_base64(encode_jpeg(capture_frame())))
parsed = json.loads(payload)
print(f"payload: {len(payload)} bytes")
print(f"keys: {list(parsed.keys())}")
print(f"images: {len(parsed['images'])} item, type {type(parsed['images'][0]).__name__}")
print(f"prompt: {len(parsed['prompt'])} characters")
$ uv run python glados/vision.py
payload: 55789 bytes
keys: ['model', 'prompt', 'images', 'stream']
images: 1 item, type str
prompt: 145 characters
Parsing the payload back and printing it costs one line and settles a whole category of argument. The request is 213 bytes of everything else wrapped around 55,576 bytes of picture, the image field really is a list holding one string, and no model was needed to establish either. When a call later comes back strange, you already know the request was well formed and can go looking elsewhere.
images is a list because the endpoint accepts several pictures in one
request, which the last exercise in this chapter puts to use. stream: False
asks for the finished reply in a single JSON object instead of one object per token,
and that single object is also where the timing counters live, which is what the next
stage reads.
Twenty seconds of loading, one sentence back
def describe(b64_image: str, timeout: float = 180.0) -> dict:
"""One non-streamed vision generation. Returns Ollama's whole reply."""
request = urllib.request.Request(
OLLAMA_URL,
data=build_payload(b64_image),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as reply:
return json.loads(reply.read().decode("utf-8"))
if __name__ == "__main__":
frame = capture_frame()
mean, spread = frame_stats(frame)
print(f"frame: mean {mean:.1f} spread {spread:.1f} {frame_verdict(mean, spread)}")
started = time.monotonic()
reply = describe(to_base64(encode_jpeg(frame)))
print(f"load {reply['load_duration'] / 1e9:.1f} s "
f"total {time.monotonic() - started:.1f} s")
print(f"GLaDOS: {reply['response'].strip()}")
$ uv run python glados/vision.py # her sentence will differ every time
frame: mean 118.7 spread 54.2 usable
load 11.6 s total 19.4 s
GLaDOS: A desk, four cables that appear to lead nowhere, and a mug containing
something that stopped being coffee several hours ago.
$ uv run python glados/vision.py # run again within the keep-alive window
frame: mean 121.4 spread 55.0 usable
load 0.0 s total 7.8 s
GLaDOS: The same desk. The mug has not moved. Neither, I notice, have you.
The three-minute timeout comes straight out of that first run. Eleven and a half of
those nineteen seconds are load_duration, the weights coming off the drive
into memory, and urllib's default would have given up in the middle of it. Run it again
while the model is still resident and the load cost is zero. Anything that opens a
model for the first time needs a timeout sized for the load, not for the work.
There is a second thing hiding in that load time, and ollama ps names
it:
$ ollama ps
NAME ID SIZE PROCESSOR UNTIL
llava:7b 8dd30f6b0cb1 6.0 GB 100% GPU 4 minutes from now
Her chat model is gone from that list. The Jetson has one memory pool that the CPU
and the GPU share, chapter 83 sized her chat model against it, and a six-gigabyte
vision model does not fit beside it, so Ollama evicted one to make room for the other.
The next thing you say to her pays a reload of the model that answers you. Either
accept the swap because looking is rare, or pick a lighter vision model such as
gemma3:4b that co-resides with her chat model. The measurement is what
turns this from a surprise into a decision.
# labs/she_sees.py
from glados.core import GladOSCore
from glados.vision import (capture_frame, describe, encode_jpeg, frame_stats,
frame_verdict, to_base64)
from labs.system_config import build_default_config
from labs.wire_core import build_core
BLIND_REPLY = "I am looking at nothing at all. Riveting."
def look(core: GladOSCore) -> str:
frame = capture_frame()
mean, spread = frame_stats(frame)
if frame_verdict(mean, spread) != "usable":
core.speak(BLIND_REPLY)
return BLIND_REPLY
text = describe(to_base64(encode_jpeg(frame)))["response"].strip()
core.speak(text)
return text
if __name__ == "__main__":
print(look(build_core(build_default_config())))
$ uv run python labs/she_sees.py # lens covered on purpose
Loading whisper base on cpu...
Loading F5-TTS...
I am looking at nothing at all. Riveting.
The guard from stage 2 is doing more here than catching a bug. A vision model always answers, so an unusable frame costs nineteen seconds and buys a fluent description of a room the camera never saw. Two cheap statistics turn that into an instant honest sentence in her own voice, which is the cost cascade chapter 13 built for audio: a free check standing in front of an expensive one.
Why this works: six bits at a time
A JPEG is arbitrary binary. Any of the 256 byte values can appear in it, including the quote character, the backslash, and the null byte, all of which are illegal or ambiguous inside a JSON string. JSON has strings, numbers, booleans, null, arrays and objects, and no type at all for a run of bytes. So the file cannot travel as itself.
Base64 solves that by using fewer bits per character. It takes the input three bytes at a time, 24 bits, and cuts them into four groups of six. Six bits can count to 63, and the alphabet has exactly 64 symbols in it: A to Z, a to z, 0 to 9, plus and slash, with the equals sign padding the end when the input length does not divide by three. Every one of those characters is safe in a JSON string, in a URL query, in an email header, in a certificate file. The cost is fixed and predictable at four characters per three bytes, and you now know what to expect on the wire before you send anything: 41,682 bytes always becomes 55,576 characters, on any machine, in any language, forever.
Ollama runs that backwards, decoding the string into the original JPEG bytes and handing them to the model. Nothing in the middle needed to understand pictures, and that is what makes the adapter the general lesson here: every service that takes an image over HTTP works this way, and so does the base64 you have already seen inside data URLs and PEM certificates without knowing what it was.
OpenCV stores pixels as blue, green, red, and cv2.imencode expects
exactly that, so capture and compression agree without a conversion. Hand it a frame
some other library produced in the usual red, green, blue order and it will encode a
perfectly valid JPEG with the reds and blues traded. No error, no warning, and the only
symptom is a model insisting your red mug is blue. If a description ever disagrees with
you about colour, check the channel order before you doubt the model.
The .decode("ascii") in to_base64 looks like ceremony, so
sooner or later somebody writes the obvious version instead:
payload = build_payload(base64.b64encode(jpeg)) # BUG: no .decode()
$ uv run python glados/vision.py
Traceback (most recent call last):
File "glados/vision.py", line 132, in <module>
payload = build_payload(base64.b64encode(jpeg))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "glados/vision.py", line 96, in build_payload
return json.dumps({
^^^^^^^^^^^^
File ".venv/lib/python3.11/json/__init__.py", line 231, in dumps
return _default_encoder.encode(obj)
File ".venv/lib/python3.11/json/encoder.py", line 200, in encode
chunks = self.iterencode(o, _one_shot=True)
File ".venv/lib/python3.11/json/encoder.py", line 180, in default
raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type bytes is not JSON serializable
base64.b64encode returns bytes, not a string. Every character in those
bytes is plain ASCII, so printing them shows something that reads like text,
b'/9j/4AAQSkZJRg...', and the two-character b' prefix is the
entire difference. Read the traceback from the bottom and it names the type and the
encoder that refused it; read it from the top and it points at line 132, where the
value was used, not line 128, where the value was made. Errors surface where a value
is consumed, so look upstream of the frame that raised.
There is a worse version of this bug that does not crash. Coerce the value with
str() to make the encoder stop complaining and JSON will happily carry the
literal characters b'/9j/4AAQ, prefix, quotes and all. The request is now
valid JSON containing a base64 string that decodes to nothing, so the failure moves
across the network and comes back as a rejection from the server about an image you
can see is fine. Fix the type where the type is wrong. Silencing a type error moves
the bug somewhere you have less information.
Checkpoint, and a clock that still runs too long
- I can name the two independent camera failures
capture_frametests for, and say which one a cable pulled mid-session produces. - I can explain why the first frames are thrown away, and connect that to the audio buffer that got saved before it was full.
- I can compute the base64 length of any JPEG from its byte count, and say why the compression has to happen before the encoding and not after.
- I can say what mean brightness and spread each measure, and why spread is the more reliable of the two for catching a blind camera.
- I can read
ollama ps, say which model is resident, and predict what her next spoken answer will cost. - I know what she says when the frame is unusable, and why that beats letting a vision model answer anyway.
Exercise 1 — find your camera. Open indices 0 through 3,
print which ones report isOpened() along with the width and height each
one hands back, and release every handle you opened.
Read cap.get(cv2.CAP_PROP_FRAME_WIDTH) and the height property on any
capture that opened. On most Linux machines you get a table with one row that
works, and on a laptop with an infrared face-unlock sensor you get two, where the
second is a camera you cannot see anything with. Release each handle before trying
the next index, or the failed attempts hold devices open until the process exits.
Write the index that worked into CAMERA_INDEX and stop guessing.
Exercise 2 — calibrate your own two thresholds. Capture three frames: lens covered, room dark, room normal. Print mean and spread for each and pick the cuts that separate them on your camera.
A covered lens usually lands under 5 on both numbers. A dark room reads low on the
mean and noticeably higher on the spread, because a standby light or a monitor
still puts edges in the picture. A lit room lands somewhere above 80 with a spread
in the tens. Set DARK_MEAN above your covered reading and below your
dark-room reading, set FLAT_SPREAD just above the covered one, and
write the date beside them: a camera with automatic gain drifts, and a threshold
you cannot date is a threshold you cannot re-check.
Exercise 3 — two frames, one question. Capture a frame, wait
ten seconds while you change something in the room, capture another, put both in the
images list and ask what is different.
The field takes a list for exactly this: {"images": [before, after]}
with a prompt naming which is which. The reply is more interesting than either
description alone, and it is the first thing in this chapter she could not have
said from a single frame. It also doubles the payload and the prompt evaluation
time, so time it and see what a second picture actually costs before you build
anything that watches a room continuously.
She sees when asked, and says so in her own voice. What she still cannot do is judge time. Every recording she takes runs for a fixed number of seconds, so a two-word answer leaves her listening to an empty room and a long sentence gets cut off mid-word. Chapter 87 hands that decision to the audio itself, thirty milliseconds at a time.